[
  {
    "path": ".dockerignore",
    "content": "__pycache__\n*.pyc\n*.pyo\n*.pyd\n.Python\nenv\npip-log.txt\npip-delete-this-directory.txt\n.tox\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.log\n.git\n.mypy_cache\n../.pytest_cache\n.hypothesis\n\nbackend/src/module/tests\nbackend/src/module/conf/const_dev.py\nconfig/bangumi.json/config/bangumi.json\n/docs\n/.github\n/.config\n/.data\n/.cache\n/LICENSE\n/README.md\n/setup.py\ndist.zip\ndata\nconfig\n/backend/src/config\n/backend/src/data\n.pytest_cache\ntest\n.env\ntest.py"
  },
  {
    "path": ".gitattributes",
    "content": "# Don't allow people to merge changes to these generated files, because the result\r\n# may be invalid.  You need to run \"rush update\" again.\r\npnpm-lock.yaml               merge=binary\r\nshrinkwrap.yaml              merge=binary\r\nnpm-shrinkwrap.json          merge=binary\r\nyarn.lock                    merge=binary\r\n\r\n# Rush's JSON config files use JavaScript-style code comments.  The rule below prevents pedantic\r\n# syntax highlighters such as GitHub's from highlighting these comments as errors.  Your text editor\r\n# may also require a special configuration to allow comments in JSON.\r\n#\r\n# For more information, see this issue: https://github.com/microsoft/rushstack/issues/1088\r\n#\r\n*.json                       linguist-language=JSON-with-Comments\r\n"
  },
  {
    "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        请确认以下信息，如果你没有完成以下检查，那么你的 issue 将会被直接关闭。\n        解析器问题请转到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=parser_bug.yml&title=%5B解析器错误%5D)，\n        重命名问题请到[专用模板](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=rename_bug.yml&title=%5B重命名错误%5D)\n  - type: checkboxes\n    id: ensure\n    attributes:\n      label: 确认\n      description: 在提交 issue 之前，请确认你已经阅读并确认以下内容\n      options:\n        - label: 我的版本是最新版本，我的版本号与 [version](https://github.com/EstrellaXD/Auto_Bangumi/releases/latest) 相同。\n          required: true\n        - label: 我已经查阅了[已知问题](https://autobangumi.org/faq/)，并确认我的问题不在其中。\n          required: true\n        - label: 我已经 [issue](https://github.com/EstrellaXD/Auto_Bangumi/issues) 中搜索过，确认我的问题没有被提出过。\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: type\n    attributes:\n      label: 问题类型\n      description: 你在以下哪个部分碰到了问题\n      options:\n        - WebUI\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"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: 使用说明\n    url: https://github.com/EstrellaXD/Auto_Bangumi/wiki/Home\n    about: 使用说明\n  - name: Twitter\n    url: https://twitter.com/Estrella_Pan\n    about: 推特联系我\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/discussion.yml",
    "content": "name: 项目讨论\ndescription: discussion\ntitle: \"[Discussion] \"\nlabels: [\"discussion\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        [BUG](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=bug&template=bug_report.yml&title=%5BBUG%5D%3A) 与 [Feature Request](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D%3A+) 请转到对应位置提交。\n  - type: textarea\n    id: discussion\n    attributes:\n      label: 项目讨论\n      description: 请详细描述需要讨论的内容。\n      placeholder: \"项目讨论\"\n    validations:\n      required: true"
  },
  {
    "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: textarea\n    id: feature-request\n    attributes:\n      label: 功能改进\n      description: 请详细描述需要改进或者添加的功能。\n      placeholder: \"功能改进\"\n    validations:\n      required: true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/parser_bug.yml",
    "content": "name: 解析器错误\ndescription: Report parser bug\ntitle: \"[解析器错误]\"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        描述问题前，请先更新到最新版本。\n        最新版本: [version](https://img.shields.io/docker/v/estrellaxd/auto_bangumi)\n        本模板仅限于解析器匹配错误。目前 AB 并不能解析类似 `[1-12]` 这样的合集\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 遇到问题时程序所在的版本号\n    validations:\n      required: true\n  - type: dropdown\n    id: language\n    attributes:\n      label: 解析器语言设置\n      description: 你是用那种语言碰到了问题\n      options:\n        - 默认：zh\n        - en\n        - jp\n    validations:\n      required: true\n  - type: dropdown\n    id: TMDB\n    attributes:\n      label: TMDB 解析\n      description: 是否开启 TMDB 解析\n      options:\n        - 是\n        - 否\n    validations:\n      required: true\n  - type: input\n    id: RawName\n    attributes:\n      label: 字幕组提供的名称\n    validations:\n      required: true\n  - type: input\n    id: ErrorName\n    attributes:\n      label: 错误识别名称\n      description: 解析错误的名称，如果出现 `Not Matched` 确实非合集之类的无法解析的名称后再提交 issue\n    validations:\n      required: true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/rename_bug.yml",
    "content": "name: 重命名错误\ndescription: Report parser bug\ntitle: \"[重命名错误]\"\nlabels: [\"bug\"]\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        描述问题前，请先更新到最新版本。\n        最新版本: [version](https://img.shields.io/docker/v/estrellaxd/auto_bangumi)\n        本模板仅限于重命名错误。目前 AB 并不能重命名合集，或者以文件夹形式下载的番剧，如果出现类似错误请等待版本更新！\n  - type: input\n    id: version\n    attributes:\n      label: 当前程序版本\n      description: 遇到问题时程序所在的版本号\n    validations:\n      required: true\n  - type: dropdown\n    id: language\n    attributes:\n      label: 重命名设置\n      description: 你是用那重命名设置出现问题\n      options:\n        - 默认：pn\n        - normal\n        - advance\n    validations:\n      required: true\n  - type: input\n    id: RawName\n    attributes:\n      label: 种子名称\n      description: 原本种子的名称\n    validations:\n      required: true\n  - type: input\n    id: path\n    attributes:\n      label: 文件路径\n      description: 种子所在的目录，请以 AB 创建的文件夹为例子，如：`/Lycoris Recoil/Season 1/`，如果没有创建类似的文件夹请参考 [FAQ]() 中的排错指南。\n    validations:\n      required: true\n  - type: input\n    id: ErrorName\n    attributes:\n      label: 错误命名\n      description: 重命名错误的名称\n    validations:\n      required: true\n  - type: textarea\n    id: logs\n    attributes:\n      label: 发生问题时系统日志\n      description: 如果有条件，请打开 Debug 模式复制针对错误命名的日志。\n      render: shell"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/rfc.yml",
    "content": "# https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms\n\nname: 功能提案\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/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)\n\n  - type: textarea\n    id: background\n    attributes:\n      label: 背景 or 问题\n      description: 简单描述遇到的什么问题或需要改动什么。可以引用其他 issue、讨论、文档等。\n    validations:\n      required: true\n\n  - type: textarea\n    id: goal\n    attributes:\n      label: \"目标 & 方案简述\"\n      description: 简单描述提案此提案实现后，**预期的目标效果**，以及简单大致描述会采取的方案/步骤，可能会/不会产生什么影响。\n    validations:\n      required: true\n\n  - type: textarea\n    id: design\n    attributes:\n      label: \"方案设计 & 实现步骤\"\n      description: |\n        详细描述你设计的具体方案，可以考虑拆分列表或要点，一步步描述具体打算如何实现的步骤和相关细节。\n        这部份不需要一次性写完整，即使在创建完此提案 issue 后，依旧可以再次编辑修改。\n    validations:\n      required: false\n\n\n  - type: textarea\n    id: alternative\n    attributes:\n      label: \"替代方案 & 对比\"\n      description: |\n        [可选] 为来实现目标效果，还考虑过什么其他方案，有什么对比？\n    validations:\n      required: false\n\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE/pull_request_template.md",
    "content": "## New\n\n## Change\n\n## Fix"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: Build Docker\n\non:\n  pull_request:\n    types:\n      - opened\n      - synchronize\n      - closed\n    branches:\n      - main\n  push:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Set up Python 3.13\n        uses: actions/setup-python@v5\n        with:\n          python-version: \"3.13\"\n      - uses: astral-sh/setup-uv@v4\n        with:\n          version: \"latest\"\n      - name: Install dependencies\n        run: cd backend && uv sync --group dev\n      - name: Test\n        run: |\n          mkdir -p backend/config\n          cd backend && uv run pytest src/test -v\n\n  webui-test:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - uses: pnpm/action-setup@v4\n        name: Install pnpm\n        with:\n          version: 9\n          run_install: false\n\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n\n      - uses: actions/cache@v4\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: cd webui && pnpm install\n\n      - name: build test\n        run: |\n          cd webui && pnpm test:build\n\n  version-info:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n      - name: If release\n        id: release\n        run: |\n          if [[ '${{ github.event_name }}' == 'pull_request' && '${{ github.event.pull_request.head.ref }}' == *'dev'* ]]; then\n            if [ ${{ github.event.pull_request.merged }} == true ]; then\n              echo \"release=1\" >> $GITHUB_OUTPUT\n            else\n              echo \"release=0\" >> $GITHUB_OUTPUT\n            fi\n          elif [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then\n            echo \"release=1\" >> $GITHUB_OUTPUT\n          else\n            echo \"release=0\" >> $GITHUB_OUTPUT\n          fi\n      - name: If dev\n        id: dev\n        run: |\n          if [[ '${{ github.event_name }}' == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then\n            echo \"dev=1\" >> $GITHUB_OUTPUT\n          else\n            echo \"dev=0\" >> $GITHUB_OUTPUT\n          fi\n      - name: Check version\n        id: version\n        run: |\n          if [ '${{ github.event_name }}' == 'pull_request' ]; then\n            if [ ${{ github.event.pull_request.merged }} == true ]; then\n              # Extract version from PR title (handles \"Release X.Y.Z\", \"vX.Y.Z\", or \"X.Y.Z\")\n              PR_TITLE=\"${{ github.event.pull_request.title }}\"\n              VERSION=$(echo \"$PR_TITLE\" | grep -oE '[0-9]+\\.[0-9]+\\.[0-9]+(-[a-zA-Z0-9.]+)?' | head -1)\n              if [ -n \"$VERSION\" ]; then\n                echo \"version=$VERSION\" >> $GITHUB_OUTPUT\n              else\n                echo \"version=$PR_TITLE\" >> $GITHUB_OUTPUT\n              fi\n            fi\n          elif [[ ${{ github.event_name }} == 'push' && (${{ github.ref }} == *'alpha'* || ${{ github.ref }} == *'beta'*) ]]; then\n            echo \"version=${{ github.ref_name }}\" >> $GITHUB_OUTPUT\n          else\n            echo \"version=Test\" >> $GITHUB_OUTPUT\n          fi\n      - name: If build test\n        id: build_test\n        run: |\n          if [[ '${{ github.event_name }}' == 'pull_request' && '${{ github.event.pull_request.merged }}' != 'true' && '${{ github.event.pull_request.head.ref }}' == *'dev'* ]]; then\n            echo \"build_test=1\" >> $GITHUB_OUTPUT\n          else\n            echo \"build_test=0\" >> $GITHUB_OUTPUT\n          fi\n      - name: Check result\n        run: |\n          echo \"release: ${{ steps.release.outputs.release }}\"\n          echo \"dev: ${{ steps.dev.outputs.dev }}\"\n          echo \"build_test: ${{ steps.build_test.outputs.build_test }}\"\n          echo \"version: ${{ steps.version.outputs.version }}\"\n    outputs:\n      release: ${{ steps.release.outputs.release }}\n      dev: ${{ steps.dev.outputs.dev }}\n      build_test: ${{ steps.build_test.outputs.build_test }}\n      version: ${{ steps.version.outputs.version }}\n\n  build-webui:\n    runs-on: ubuntu-latest\n    needs: [test, webui-test, version-info]\n    if: ${{ needs.version-info.outputs.release == 1 || needs.version-info.outputs.dev == 1 || needs.version-info.outputs.build_test == 1 }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Install Node.js\n        uses: actions/setup-node@v4\n        with:\n          node-version: 20\n\n      - uses: pnpm/action-setup@v4\n        name: Install pnpm\n        with:\n          version: 9\n          run_install: false\n\n      - name: Get pnpm store directory\n        shell: bash\n        run: |\n          echo \"STORE_PATH=$(pnpm store path --silent)\" >> $GITHUB_ENV\n\n      - uses: actions/cache@v4\n        name: Setup pnpm cache\n        with:\n          path: ${{ env.STORE_PATH }}\n          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}\n          restore-keys: |\n            ${{ runner.os }}-pnpm-store-\n\n      - name: Install dependencies\n        run: cd webui && pnpm install\n\n      - name: Build\n        run: |\n          cd webui && pnpm build\n\n      - name: Upload artifact\n        uses: actions/upload-artifact@v4\n        with:\n          name: dist\n          path: webui/dist\n\n  build-docker:\n    runs-on: ubuntu-latest\n    needs: [build-webui, version-info]\n    if: ${{ needs.version-info.outputs.release == 1 || needs.version-info.outputs.dev == 1 || needs.version-info.outputs.build_test == 1 }}\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n\n      - name: Create Version info via tag\n        working-directory: ./backend/src\n        run: |\n          echo ${{ needs.version-info.outputs.version }}\n          echo \"VERSION='${{ needs.version-info.outputs.version }}'\" >> module/__version__.py\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v2\n\n      - name: Set up Docker Buildx\n        id: buildx\n        uses: docker/setup-buildx-action@v2\n\n      - name: Docker metadata main\n        if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }}\n        id: meta\n        uses: docker/metadata-action@v4\n        with:\n          images: |\n            estrellaxd/auto_bangumi\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ needs.version-info.outputs.version }}\n            type=raw,value=latest\n\n      - name: Docker metadata dev\n        if: ${{ needs.version-info.outputs.dev == 1 }}\n        id: meta-dev\n        uses: docker/metadata-action@v4\n        with:\n          images: |\n            estrellaxd/auto_bangumi\n            ghcr.io/${{ github.repository }}\n          tags: |\n            type=raw,value=${{ needs.version-info.outputs.version }}\n            type=raw,value=dev-latest\n\n      - name: Login to DockerHub\n        if: ${{ needs.version-info.outputs.release == 1 }}\n        uses: docker/login-action@v2\n        with:\n          username: ${{ secrets.DOCKER_HUB_USERNAME }}\n          password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}\n\n      - name: Login to ghcr.io\n        if: ${{ needs.version-info.outputs.release == 1 }}\n        uses: docker/login-action@v2\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.ACCESS_TOKEN }}\n\n      - name: Download artifact\n        uses: actions/download-artifact@v4\n        with:\n          name: dist\n          path: backend/src/dist\n\n      - name: Build and push\n        if: ${{ needs.version-info.outputs.release == 1 && needs.version-info.outputs.dev != 1 }}\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          builder: ${{ steps.buildx.output.name }}\n          platforms: linux/amd64,linux/arm64\n          push: True\n          tags: ${{ steps.meta.outputs.tags }}\n          labels: ${{ steps.meta.outputs.labels }}\n          cache-from: type=gha, scope=${{ github.workflow }}\n          cache-to: type=gha, scope=${{ github.workflow }}\n\n      - name: Build and push dev\n        if: ${{ needs.version-info.outputs.dev == 1 }}\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          builder: ${{ steps.buildx.output.name }}\n          platforms: linux/amd64,linux/arm64\n          push: ${{ github.event_name == 'push' }}\n          tags: ${{ steps.meta-dev.outputs.tags }}\n          labels: ${{ steps.meta-dev.outputs.labels }}\n          cache-from: type=gha, scope=${{ github.workflow }}\n          cache-to: type=gha, scope=${{ github.workflow }}\n\n      - name: Build test\n        if: ${{ needs.version-info.outputs.release == 0 }}\n        uses: docker/build-push-action@v4\n        with:\n          context: .\n          builder: ${{ steps.buildx.output.name }}\n          platforms: linux/amd64,linux/arm64\n          push: false\n          tags: estrellaxd/auto_bangumi:test\n          cache-from: type=gha, scope=${{ github.workflow }}\n          cache-to: type=gha, scope=${{ github.workflow }}\n\n  release:\n    runs-on: ubuntu-latest\n    needs: [build-docker, version-info]\n    if: ${{ needs.version-info.outputs.release == 1 }}\n    outputs:\n      url: ${{ steps.release.outputs.url }}\n      version: ${{ needs.version-info.outputs.version }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n\n      - name: Download artifact webui\n        uses: actions/download-artifact@v4\n        with:\n          name: dist\n          path: webui/dist\n\n      - name: Zip webui\n        run: |\n          cd webui && ls -al && tree && zip -r dist.zip dist\n\n      - name: Download artifact app\n        uses: actions/download-artifact@v4\n        with:\n          name: dist\n          path: backend/src/dist\n\n      - name: Create Version info via tag\n        working-directory: ./backend/src\n        run: |\n          echo ${{ needs.version-info.outputs.version }}\n          echo \"VERSION='${{ needs.version-info.outputs.version }}'\" >> module/__version__.py\n\n      - name: Zip app\n        run: |\n          cd backend && zip -r app-v${{ needs.version-info.outputs.version }}.zip src\n\n      - name: Generate Release info\n        id: release-info\n        run: |\n          if ${{ needs.version-info.outputs.dev == 1 }}; then\n            echo \"version=🌙${{ needs.version-info.outputs.version }}\" >> $GITHUB_OUTPUT\n            echo \"pre_release=true\" >> $GITHUB_OUTPUT\n          else\n            echo \"version=🌟${{ needs.version-info.outputs.version }}\" >> $GITHUB_OUTPUT\n            echo \"pre_release=false\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Read changelog\n        id: changelog\n        run: |\n          if [ -f docs/changelog/3.2.md ]; then\n            echo \"body<<EOF\" >> $GITHUB_OUTPUT\n            cat docs/changelog/3.2.md >> $GITHUB_OUTPUT\n            echo \"EOF\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Release\n        id: release\n        uses: softprops/action-gh-release@v1\n        with:\n          tag_name: ${{ needs.version-info.outputs.version }}\n          name: ${{ steps.release-info.outputs.version }}\n          body: ${{ github.event.pull_request.body || steps.changelog.outputs.body }}\n          draft: false\n          prerelease: ${{ steps.release-info.outputs.pre_release == 'true' }}\n          files: |\n            webui/dist.zip\n            backend/app-v${{ needs.version-info.outputs.version }}.zip\n        env:\n          GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }}\n\n  telegram:\n    runs-on: ubuntu-latest\n    needs: [release]\n    steps:\n      - name: send telegram message on push\n        uses: appleboy/telegram-action@master\n        with:\n          to: ${{ secrets.TELEGRAM_TO }}\n          token: ${{ secrets.TELEGRAM_TOKEN }}\n          message: |\n            New release: ${{ needs.release.outputs.version }}\n            Link: ${{ needs.release.outputs.url }}\n"
  },
  {
    "path": ".gitignore",
    "content": "# Byte-compiled / optimized / DLL files\n__pycache__/\n*.py[cod]\n*$py.class\n\n# C extensions\n*.so\n\n# Distribution / packaging\n.Python\nbuild/\ndevelop-eggs/\ndist/\ndownloads/\neggs/\n.eggs/\nlib/\nlib64/\nparts/\nsdist/\nvar/\nwheels/\nshare/python-wheels/\n*.egg-info/\n.installed.cfg\n*.egg\nMANIFEST\n\n# PyInstaller\n#  Usually these files are written by a python script from a template\n#  before PyInstaller builds the exe, so as to inject date/other infos into it.\n*.manifest\n*.spec\n\n# Installer logs\npip-log.txt\npip-delete-this-directory.txt\n\n# Unit test / coverage reports\nhtmlcov/\n.tox/\n.nox/\n.coverage\n.coverage.*\n.cache\nnosetests.xml\ncoverage.xml\n*.cover\n*.py,cover\n.hypothesis/\n.pytest_cache/\ncover/\n\n# Translations\n*.mo\n*.pot\n\n# Django stuff:\n*.log\nlocal_settings.py\ndb.sqlite3\ndb.sqlite3-journal\n\n# Flask stuff:\ninstance/\n.webassets-cache\n\n# Scrapy stuff:\n.scrapy\n\n# Sphinx documentation\ndocs/_build/\n\n# PyBuilder\n.pybuilder/\ntarget/\n\n# Jupyter Notebook\n.ipynb_checkpoints\n\n# IPython\nprofile_default/\nipython_config.py\n\n# pyenv\n#   For a library or package, you might want to ignore these files since the code is\n#   intended to run in multiple environments; otherwise, check them in:\n# .python-version\n\n# pipenv\n#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.\n#   However, in case of collaboration, if having platform-specific dependencies or dependencies\n#   having no cross-platform support, pipenv may install dependencies that don't work, or not\n#   install all needed dependencies.\n#Pipfile.lock\n\n# poetry\n#   Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.\n#   This is especially recommended for binary packages to ensure reproducibility, and is more\n#   commonly ignored for libraries.\n#   https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control\n#poetry.lock\n\n# pdm\n#   Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.\n#pdm.lock\n#   pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it\n#   in version control.\n#   https://pdm.fming.dev/#use-with-ide\n.pdm.toml\n\n# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm\n__pypackages__/\n\n# Celery stuff\ncelerybeat-schedule\ncelerybeat.pid\n\n# SageMath parsed files\n*.sage.py\n\n# Environments\n.idea\n.env\n.venv\nenv/\nvenv/\nENV/\nenv.bak/\nvenv.bak/\n\n# Spyder project settings\n.spyderproject\n.spyproject\n\n# Rope project settings\n.ropeproject\n\n# mkdocs documentation\n/site\n\n# mypy\n.mypy_cache/\n.dmypy.json\ndmypy.json\n\n# Pyre type checker\n.pyre/\n\n# pytype static type analyzer\n.pytype/\n\n# Cython debug symbols\ncython_debug/\n\n# PyCharm\n#  JetBrains specific template is maintained in a separate JetBrains.gitignore that can\n#  be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore\n#  and can be added to the global gitignore or merged into this file.  For a more nuclear\n#  option (not recommended) you can uncomment the following to ignore the entire idea folder.\n#.idea/\n\n# Custom\n#\n# backend\n/backend/src/test.py\n\n/backend/src/module/run_debug.sh\n/backend/src/module/debug_run.sh\n/backend/src/module/__version__.py\n/backend/src/data/\n\n/src/module/conf/config_dev.ini\n\n\n.run\n/backend/src/templates/\n/backend/src/config/\n/src/debuger.py\n/backend/src/dist.zip\n/pyrightconfig.json\n\n# webui\nlogs\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\ndist\nwebui/.vite/deps/*\ndist.zip\ndist-ssr\n*.local\ndev-dist\n\n# Editor directories and files\n.vscode/*\n!.vscode/settings.json\n!.vscode/tasks.json\n!.vscode/launch.json\n!.vscode/extensions.json\n.DS_Store\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n\n\n# vitepress\n/docs/.vitepress/cache/\n\n\n# test file\ntest.*\n\n# local config\n/backend/config/\n.claude/settings.local.json\n"
  },
  {
    "path": ".vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\n    // https://marketplace.visualstudio.com/items?itemName=antfu.unocss\n    \"antfu.unocss\",\n    // https://marketplace.visualstudio.com/items?itemName=formulahendry.auto-rename-tag\n    \"formulahendry.auto-rename-tag\",\n    // https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker\n    \"streetsidesoftware.code-spell-checker\",\n    // https://marketplace.visualstudio.com/items?itemName=naumovs.color-highlight\n    \"naumovs.color-highlight\",\n    // https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance\n    \"ms-python.vscode-pylance\",\n    // https://marketplace.visualstudio.com/items?itemName=ms-python.python\n    \"ms-python.python\",\n    // https://marketplace.visualstudio.com/items?itemName=vue.volar\n    \"vue.volar\"\n  ]\n}"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  // Use IntelliSense to learn about possible attributes.\n  // Hover to view descriptions of existing attributes.\n  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Dev Backend\",\n      \"type\": \"python\",\n      \"request\": \"launch\",\n      \"cwd\": \"${workspaceFolder}/backend/src\",\n      \"program\": \"main.py\",\n      \"env\": {\n        \"HOST\": \"127.0.0.1\",\n      },\n      \"console\": \"integratedTerminal\",\n      \"justMyCode\": true\n    }\n  ]\n}"
  },
  {
    "path": ".vscode/settings.json",
    "content": "{\n  \"files.associations\": {\n    \"settings.json\": \"json5\",\n    \"launch.json\": \"json5\",\n    \"extensions.json\": \"json5\",\n    \"tsconfig.json\": \"json5\",\n    \"tsconfig.*.json\": \"json5\",\n  },\n  \"[markdown]\": {\n    \"editor.wordWrap\": \"off\",\n  },\n  \"python.venvPath\": \"./backend/venv\",\n  \"cSpell.words\": [\n    \"Bangumi\",\n    \"fastapi\",\n    \"mikan\",\n    \"starlette\"\n  ],\n}\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# [Unreleased]\n\n## Backend\n\n### Added\n\n- 新增 `Security` 配置模型，支持登录 IP 白名单、MCP IP 白名单和 Bearer Token 认证\n- 新增登录端点 IP 白名单检查中间件 (`check_login_ip`)\n- MCP 安全中间件升级为可配置模式：支持 CIDR 白名单 + Bearer Token 双重认证\n- 认证端点支持 `Authorization: Bearer` 令牌绕过 Cookie 登录\n- 配置 API `_sanitize_dict` 修复：仅对字符串值进行脱敏，避免误处理非字符串字段\n\n- 新增番剧放送日手动设置 API (`PATCH /api/v1/bangumi/{id}/weekday`)，支持锁定放送日防止日历刷新覆盖\n- 数据库迁移 v9：`bangumi` 表新增 `weekday_locked` 列\n\n### Fixed\n\n- 修复 qBittorrent 下载器 SSL 连接问题：解耦 HTTPS 协议选择与证书验证，自签名证书不再导致连接失败 (#923)\n- 修复 `torrents_rename_file` 重命名验证循环中 `continue` 应为 `break` 的逻辑错误\n\n### Changed\n\n- 重构认证模块：提取 `_issue_token` 公共方法，消除 3 处重复的 JWT 签发逻辑\n- `get_current_user` 简化为三级认证（DEV 绕过 → Bearer Token → Cookie JWT）\n- `LocalNetworkMiddleware` 重命名为 `McpAccessMiddleware`，从硬编码 RFC 1918 改为读取配置\n\n### Tests\n\n- 新增 101 个单元测试覆盖安全、认证、配置、下载器和 MockDownloader 模块\n\n## Frontend\n\n### Added\n\n- 新增日历拖拽排列功能：可将「未知」番剧拖入星期列，自动设置放送日并锁定\n  - 拖入后显示紫色图钉图标，鼠标悬停显示取消按钮\n  - 锁定的番剧在日历刷新时不会被覆盖\n  - 使用 vuedraggable 实现流畅拖拽动画\n- 新增安全设置组件 (`config-security.vue`)，支持在 WebUI 中配置 IP 白名单和 Token\n- 前端 `Security` 类型定义和初始化配置\n\n---\n\n# [3.2.3] - 2026-02-23\n\n## Backend\n\n### Added\n\n- 新增 MCP (Model Context Protocol) 服务器，支持通过 Claude Desktop 等 LLM 工具管理番剧订阅\n  - SSE 传输层挂载在 `/mcp/sse`，支持 MCP 客户端连接\n  - 10 个工具：list_anime、get_anime、search_anime、subscribe_anime、unsubscribe_anime、list_downloads、list_rss_feeds、get_program_status、refresh_feeds、update_anime\n  - 4 个资源：anime/list、anime/{id}、status、rss/feeds\n  - 本地网络 IP 白名单安全中间件（RFC 1918 + 回环地址），无需 JWT 认证\n- 新增通知系统重构，支持多通知渠道同时启用\n  - 支持 Telegram、Bark、Server 酱、企业微信、Discord、Gotify、Pushover、Webhook 八种渠道\n  - 新增通知管理 API：`GET/PUT /api/notification/providers`\n- 新增 E2E 集成测试套件，覆盖 RSS→下载→重命名全流程\n\n### Fixes\n\n- 修复第 0 集（SP/OVA）被错误重命名为第 1 集的问题 (#977)\n  - Episode 0 现在免受集数偏移影响，不再覆盖正常集数文件\n- 修复 RSS 过滤器包含特殊字符（如 `[字幕组`）时导致程序崩溃的问题 (#974)\n  - 无效正则表达式自动降级为字面量匹配\n- 修复聚合 RSS 解析时 `title_raw` 为空导致 `TypeError` 崩溃的问题 (#976)\n- 修复解析器处理无括号种子名称时 `IndexError` 崩溃的问题 (#973)\n- 修复删除番剧时未清理关联种子记录的问题\n- 修复认证路由、JWT 刷新和 WebAuthn 注册流程的多个安全问题\n- 修复程序生命周期管理和后台任务取消逻辑\n- 修复数据库迁移在部分场景下未正确执行的问题\n\n### Performance\n\n- 优化日志系统：`RotatingFileHandler` 轮转（5 MB × 3）、`QueueHandler` 异步写入、`GET /api/log` 限读 512 KB\n- 优化重命名器：批量数据库查询，并发获取种子文件列表\n- 所有 `logger.debug(f\"...\")` 转为惰性 `%s` 格式化（~80 处）\n\n### Tests\n\n- 新增 26 个回归测试覆盖 #974、#976、#977、#986\n- 扩展 raw_parser、torrent_parser、path_parser 测试覆盖率\n\n## Frontend\n\n### Fixes\n\n- 修复认证路由守卫和 i18n 初始化顺序问题\n- 修复通知设置组件与项目设计系统的对齐问题\n- 修复组件生命周期管理问题\n\n## Docs\n\n- README 移除未实现的 Aria2 和 Transmission 下载器 (#987)\n\n---\n\n# [3.2.0-beta.13] - 2026-01-26\n\n## Frontend\n\n### Features\n\n- 重新设计搜索面板\n  - 新增筛选区域，支持按字幕组、分辨率、字幕类型、季度分类筛选\n  - 多选筛选器，智能禁用不兼容的选项（灰色显示）\n  - 结果项标签改为非点击式彩色药丸样式\n  - 统一标签样式（药丸形状、12px 字体）\n  - 标签值标准化（分辨率：FHD/HD/4K，字幕：简/繁/双语）\n  - 筛选分类和结果变体支持展开/收起\n  - 海报高度自动匹配 4 行变体项（168px）\n  - 点击弹窗外部自动关闭\n\n---\n\n# [3.2.0-beta.12] - 2026-01-26\n\n## Backend\n\n### Features\n\n- 偏移检查面板新增建议值显示（解析的季度/集数和建议的偏移量）\n\n### Fixes\n\n- 修复季度偏移未应用到下载文件夹路径的问题\n  - 设置季度偏移后，qBittorrent 保存路径会自动更新（如 `Season 2` → `Season 1`）\n  - RSS 规则的保存路径也会同步更新\n- 优化集数偏移建议逻辑\n  - 简单季度不匹配时不再建议集数偏移（仅虚拟季度需要）\n  - 改进提示信息，明确说明是否需要调整集数\n\n---\n\n# [3.2.0-beta.11] - 2026-01-25\n\n## Backend\n\n### Features\n\n- 新增季度/集数偏移自动检测功能\n  - 通过分析 TMDB 剧集播出日期检测「虚拟季度」（如芙莉莲第一季分两部分播出）\n  - 当播出间隔超过6个月时自动识别为不同部分\n  - 自动计算集数偏移量（如 RSS 显示 S2E1 → TMDB S1E29）\n- 新增后台扫描线程，自动检测已有订阅的偏移问题\n- 新增搜索源配置 API 端点：\n  - `GET /search/provider/config` - 获取搜索源配置\n  - `PUT /search/provider/config` - 更新搜索源配置\n- 新增 API 端点：\n  - `POST /bangumi/detect-offset` - 检测季度/集数偏移\n  - `PATCH /bangumi/dismiss-review/{id}` - 忽略偏移检查提醒\n- 数据库新增 `needs_review` 和 `needs_review_reason` 字段\n\n## Frontend\n\n### Features\n\n- 新增搜索源设置面板\n  - 支持查看、添加、编辑、删除搜索源\n  - 默认搜索源（mikan、nyaa、dmhy）不可删除\n  - URL 模板验证，确保包含 `%s` 占位符\n- 新增 iOS 风格通知角标系统\n  - 黄色角标 + 紫色边框显示需要检查的订阅\n  - 支持组合显示（如 `! | 2` 表示有警告且有多个规则）\n  - 卡片黄色发光动画提示需要注意\n- 编辑弹窗新增警告横幅，支持一键自动检测和忽略\n- 规则选择弹窗高亮显示有警告的规则\n- 首页空状态新增「添加 RSS 订阅」按钮，引导新用户快速上手\n- 日历页面海报图片添加懒加载，提升性能\n- 日历页面「未知播出日」独立为单独区块，优化视觉节奏\n\n### Fixes\n\n- 修复移动端设置页面水平溢出问题\n  - 输入框添加 `max-width: 100%` 防止超出容器\n  - 折叠面板添加宽度约束和溢出隐藏\n  - 设置栅格添加 `min-width: 0` 允许收缩\n- 修复移动端顶栏布局\n  - 搜索按钮改为弹性布局，填充 Logo 和图标之间的空间\n  - 减小图标按钮尺寸和间距，优化紧凑型布局\n  - 添加「点击搜索」文字提示\n- 修复移动端搜索弹窗关闭按钮被截断问题\n  - 减小弹窗头部内边距和元素尺寸\n  - 搜索源选择按钮缩小至适配移动端\n- 修复设置页面保存/取消按钮缺少加载状态\n- 修复侧边栏展开动画抖动（rotateY → rotate）\n- 移动端底部导航标签字号从 10px 增至 11px，提升可读性\n- 登录页背景动画添加 `will-change: transform` 优化 GPU 性能\n\n---\n\n# [3.2.0-beta.8] - 2026-01-25\n\n## Backend\n\n### Features\n\n- Passkey 登录支持无用户名模式（可发现凭证）\n\n### Fixes\n\n- 修复搜索和订阅流程中的多个问题\n- 改进种子获取可靠性和错误处理\n\n## Frontend\n\n### Features\n\n- Passkey 登录支持无用户名模式（可发现凭证）\n\n---\n\n# [3.2.0-beta.7] - 2026-01-25\n\n## Backend\n\n### Features\n\n- 数据库迁移自动填充 NULL 值为模型默认值\n\n### Fixes\n\n- 修复下载器连接检查添加最大重试次数\n- 修复添加种子时的网络瞬态错误，添加重试逻辑\n\n## Frontend\n\n### Features\n\n- 重新设计搜索面板，新增模态框和过滤系统\n- 重新设计登录面板，采用现代毛玻璃风格\n- 日志页面新增日志级别过滤功能\n\n### Fixes\n\n- 修复日历页面未知列宽度问题\n- 统一下载器页面操作栏按钮尺寸\n\n---\n\n# [3.2.0-beta.6] - 2026-01-25\n\n## Backend\n\n### Features\n\n- 新增番剧归档功能：支持手动归档/取消归档，已完结番剧自动归档\n\n### Fixes\n\n- 修复 `add_all()` 方法缺少去重检查导致重复添加番剧规则的问题\n- 去重逻辑基于 `(title_raw, group_name)` 组合，同时支持批量内部去重\n- 新增剧集偏移自动检测：根据 TMDB 季度集数自动计算偏移量（如 S02E18 → S02E05）\n- TMDB 解析器新增 `series_status` 和 `season_episode_counts` 字段提取\n- 新增数据库迁移 v4：为 `bangumi` 表添加 `archived` 字段\n- 新增 API 端点：\n  - `PATCH /bangumi/archive/{id}` - 归档番剧\n  - `PATCH /bangumi/unarchive/{id}` - 取消归档\n  - `GET /bangumi/refresh/metadata` - 刷新元数据并自动归档已完结番剧\n  - `GET /bangumi/suggest-offset/{id}` - 获取建议的剧集偏移量\n- 重命名模块支持从数据库查询偏移量并应用到文件名\n\n## Frontend\n\n### Features\n\n- 番剧列表页新增可折叠的「已归档」分区\n- 日历页新增番剧分组功能：相同番剧的多个规则合并显示，点击可选择具体规则\n- 番剧列表页新增骨架屏加载动画\n\n### Fixes\n\n- 修复弹窗 z-index 层级问题，新增 CSS 变量管理层级系统\n- 改善无障碍体验：按钮最小触摸区域 44px、焦点状态可见、添加 aria-label\n- 规则编辑弹窗新增归档/取消归档按钮\n- 规则编辑器新增剧集偏移字段和「自动检测」按钮\n- 新增 i18n 翻译（中文/英文）\n- 优化规则编辑弹窗布局：统一表单字段对齐、统一按钮高度、修复移动端底部弹窗 z-index 层级问题\n- 修复下载器页面仅显示季度文件夹名的问题，现在会显示「番剧名 / Season 1」格式\n\n---\n\n# [3.2.0-beta.5] - 2026-01-24\n\n## Backend\n\n### Features\n\n- RSS 订阅源新增连接状态追踪：每次刷新后记录 `connection_status`（healthy/error）、`last_checked_at` 和 `last_error`\n- 新增数据库迁移 v2：为 `rssitem` 表添加连接状态相关字段\n\n### Performance\n\n- 新增共享 HTTP 客户端连接池，复用 TCP/SSL 连接，减少每次请求的握手开销\n- RSS 刷新改为并发拉取所有订阅源（`asyncio.gather`），多源场景下速度提升约 10 倍\n- 种子文件下载改为并发获取，下载多个种子时速度提升约 5 倍\n- 重命名模块并发获取所有种子文件列表，速度提升约 20 倍\n- 通知发送改为并发执行，移除 2 秒硬编码延迟\n- 新增 TMDB 和 Mikan 解析结果的内存缓存，避免重复 API 调用\n- 为 `Torrent.url`、`Torrent.rss_id`、`Bangumi.title_raw`、`Bangumi.deleted`、`RSSItem.url` 添加数据库索引\n- RSS 批量启用/禁用改为单次事务操作，替代逐条提交\n- 预编译正则表达式（种子名解析规则、过滤器匹配），避免运行时重复编译\n- `SeasonCollector` 在循环外创建，复用单次认证\n- `check_first_run` 缓存默认配置字典，避免每次创建新对象\n- 通知模块中的同步数据库调用改为 `asyncio.to_thread`，避免阻塞事件循环\n- RSS 解析去重从 O(n²) 列表查找改为 O(1) 集合查找\n- 文件后缀判断使用 `frozenset` 替代列表，提升查找效率\n- `Episode`/`SeasonInfo` 数据类添加 `__slots__`，减少内存占用\n- RSS XML 解析返回元组列表，替代三个独立列表再 zip 的模式\n- qBittorrent 规则设置改为并发执行\n\n## Frontend\n\n### Features\n\n- RSS 管理页面新增连接状态标签：健康时显示绿色「已连接」，错误时显示红色「错误」并通过 tooltip 显示错误详情\n\n### Performance\n\n- 下载器 store 使用 `shallowRef` 替代 `ref`，避免大数组的深层响应式代理\n- 表格列定义改为 `computed`，避免每次渲染重建\n- RSS 表格列与数据分离，数据变化时不重建列配置\n- 日历页移除重复的 `getAll()` 调用\n- `ab-select` 的 `watchEffect` 改为 `watch`，消除挂载时的无效 emit\n- `useClipboard` 提升到 store 顶层，避免每次 `copy()` 创建新实例\n- `setInterval` 替换为 `useIntervalFn`，自动生命周期管理，防止内存泄漏\n- 共享 `ruleTemplate` 对象改为浅拷贝，避免意外的引用共变\n- `ab-add-rss` 移除不必要的 `setTimeout` 延迟\n\n### Fixes\n\n- 修复 `ab-image.vue` 中 `<style scope>` 的拼写错误（应为 `scoped`）\n- 修复 `ab-edit-rule.vue` 中 `String` 类型应为 `string`\n- `bangumi` ref 初始化为 `[]` 而非 `undefined`，减少下游空值检查\n- `ab-bangumi-card` 模板类型安全：动态属性访问改为显式枚举\n- 启用 `noImplicitAny: true` 提升类型安全\n\n### Types\n\n- `ab-button`、`ab-search` 的 `defineEmits` 改为类型化声明\n- `ab-data-list` 使用明确的 `DataItem` 类型替代 `any`\n\n---\n\n# [3.2.0-beta.4] - 2026-01-24\n\n## Backend\n\n### Bugfixes\n\n- 修复从 3.1.x 升级后数据库缺少 `air_weekday` 列导致服务器错误的问题 (#956)\n- 修复重命名模块中 `'dict' object has no attribute 'files'` 的错误\n- 新增 `schema_version` 表追踪数据库版本，确保迁移可靠执行\n- 修复 qBittorrent 下载器中缺少 `torrents_files` API 调用的问题\n\n### Changes\n\n- 数据库迁移机制重构：使用 `schema_version` 表替代仅依赖应用版本号的迁移策略\n- 启动时始终检查并执行未完成的迁移，防止迁移中断后无法恢复\n\n### Tests\n\n- 新增全面的测试套件，覆盖核心业务逻辑：\n  - RSS 引擎测试：pull_rss、match_torrent、refresh_rss、add_rss 全流程\n  - 下载客户端测试：init_downloader、set_rule、add_torrent（磁力/文件）、rename\n  - 路径工具测试：save_path 生成、文件分类、is_ep 深度检查\n  - 重命名器测试：gen_path 命名方法（pn/advance/none/subtitle）、单文件/集合重命名\n  - 认证测试：JWT 创建/解码/验证、密码哈希、get_current_user\n  - 通知测试：getClient 工厂、send_msg 成功/失败、poster 查询\n  - 搜索测试：URL 构建、关键词清洗、special_url\n  - 配置测试：默认值、序列化、迁移、环境变量覆盖\n  - Bangumi API 测试：CRUD 端点 + 认证要求\n  - RSS API 测试：CRUD/批量端点 + 刷新\n  - 集成测试：RSS→下载全流程、重命名全流程、数据库一致性\n- 新增 `pytest-mock` 开发依赖\n- 新增共享测试 fixtures（`conftest.py`）和数据工厂（`factories.py`）\n\n---\n\n# [3.1] - 2023-08\n\n- 合并了后端和前端仓库，优化了项目目录\n- 优化了版本发布流程。\n- Wiki 迁移至 Vitepress，地址：https://autobangumi.org\n\n## Backend\n\n### Features\n\n- 新增 `RSS Engine` 模块，从现在起，AB 可以自主对 RSS 进行更新支持 `RSS` 订阅并且发送种子给下载器。\n  - 现在支持多个聚合 RSS 订阅源，可以通过 `RSS Engine` 模块进行管理。\n  - 支持下载去重功能，重复的订阅的种子不会被下载。\n  - 增加手动刷新 API，可以手动刷新 RSS 订阅。\n  - 新增 RSS 订阅管理 API。\n- 新增 `Search Engine`模块，可以通过关键词搜索种子并解析成收集或者订阅任务。\n  - 插件化的搜索引擎，可以通过插件的方式添加新的搜索目标，目前支持 `mikan`、`dmhy` 和 `nyaa`\n- 新增对字幕组的特异性规则，可以针对不同的字幕组进行单独设置。\n- 新增 IPv6 监听支持，需要在环境变量中设置 `IPV6=1`。\n- API 新增批量操作，可以批量管理规则和 RSS 订阅。\n\n### Changes\n\n- 数据库结构变更，更换为 `sqlmodel` 管理数据库。\n- 新增版本管理，可以无缝更新软件数据。\n- 调整 API 格式，更加统一。\n- 增加 API 返回语言选项。\n- 增加数据库 mock test。\n- 优化代码。\n\n### Bugfixes\n\n- 修复了一些小问题。\n- 增加了一些大问题。\n\n## Frontend\n\n### Features\n\n- 增加 `i18n` 支持，目前支持 `zh-CN` 和 `en-US`。\n- 增加 pwa 支持。\n- 增加 RSS 管理页面。\n- 增加搜索顶栏。\n\n### Changes\n\n- 调整一些 UI 细节。"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Project Overview\n\nAutoBangumi is an RSS-based automatic anime downloading and organization tool. It monitors RSS feeds from anime torrent sites (Mikan, DMHY, Nyaa), downloads episodes via qBittorrent, and organizes files into a Plex/Jellyfin-compatible directory structure with automatic renaming.\n\n## Development Commands\n\n### Backend (Python)\n\n```bash\n# Install dependencies\ncd backend && uv sync\n\n# Install with dev tools\ncd backend && uv sync --group dev\n\n# Run development server (port 7892, API docs at /docs)\ncd backend/src && uv run python main.py\n\n# Run tests\ncd backend && uv run pytest\ncd backend && uv run pytest src/test/test_xxx.py -v  # run specific test\n\n# Linting and formatting\ncd backend && uv run ruff check src\ncd backend && uv run black src\n\n# Add a dependency\ncd backend && uv add <package>\n\n# Add a dev dependency\ncd backend && uv add --group dev <package>\n```\n\n### Frontend (Vue 3 + TypeScript)\n\n```bash\ncd webui\n\n# Install dependencies (uses pnpm, not npm)\npnpm install\n\n# Development server (port 5173)\npnpm dev\n\n# Build for production\npnpm build\n\n# Type checking\npnpm test:build\n\n# Linting and formatting\npnpm lint\npnpm lint:fix\npnpm format\n```\n\n### Docker\n\n```bash\ndocker build -t auto_bangumi:latest .\ndocker run -p 7892:7892 -v /path/to/config:/app/config -v /path/to/data:/app/data auto_bangumi:latest\n```\n\n## Architecture\n\n```\nbackend/src/\n├── main.py                 # FastAPI entry point, mounts API at /api\n├── module/\n│   ├── api/               # REST API routes (v1 prefix)\n│   │   ├── auth.py        # Authentication endpoints\n│   │   ├── bangumi.py     # Anime series CRUD\n│   │   ├── rss.py         # RSS feed management\n│   │   ├── config.py      # Configuration endpoints\n│   │   ├── program.py     # Program status/control\n│   │   └── search.py      # Torrent search\n│   ├── core/              # Application logic\n│   │   ├── program.py     # Main controller, orchestrates all operations\n│   │   ├── sub_thread.py  # Background task execution\n│   │   └── status.py      # Application state tracking\n│   ├── models/            # SQLModel ORM models (Pydantic + SQLAlchemy)\n│   ├── database/          # Database operations (SQLite at data/data.db)\n│   ├── rss/               # RSS parsing and analysis\n│   ├── downloader/        # qBittorrent integration\n│   │   └── client/        # Download client implementations (qb, aria2, tr)\n│   ├── searcher/          # Torrent search providers (Mikan, DMHY, Nyaa)\n│   ├── parser/            # Torrent name parsing, metadata extraction\n│   │   └── analyser/      # TMDB, Mikan, OpenAI parsers\n│   ├── manager/           # File organization and renaming\n│   ├── notification/      # Notification plugins (Telegram, Bark, etc.)\n│   ├── conf/              # Configuration management, settings\n│   ├── network/           # HTTP client utilities\n│   └── security/          # JWT authentication\n\nwebui/src/\n├── api/                   # Axios API client functions\n├── components/            # Vue components (basic/, layout/, setting/)\n├── pages/                 # Router-based page components\n├── router/                # Vue Router configuration\n├── store/                 # Pinia state management\n├── i18n/                  # Internationalization (zh-CN, en-US)\n└── hooks/                 # Custom Vue composables\n```\n\n## Key Data Flow\n\n1. RSS feeds are parsed by `module/rss/` to extract torrent information\n2. Torrent names are analyzed by `module/parser/analyser/` to extract anime metadata\n3. Downloads are managed via `module/downloader/` (qBittorrent API)\n4. Files are organized by `module/manager/` into standard directory structure\n5. Background tasks run in `module/core/sub_thread.py` to avoid blocking\n\n## Code Style\n\n- Python: Black (88 char lines), Ruff linter (E, F, I rules), target Python 3.10+\n- TypeScript: ESLint + Prettier\n- Run formatters before committing\n\n## Git Branching\n\n- `main`: Stable releases only\n- `X.Y-dev` branches: Active development (e.g., `3.2-dev`)\n- Bug fixes → PR to current released version's `-dev` branch\n- New features → PR to next version's `-dev` branch\n\n## Releasing a Beta Version\n\n1. Update version in `backend/pyproject.toml`\n2. Update `CHANGELOG.md` with the new version heading\n3. Commit and push to the dev branch\n4. Create and push a tag with the version name (e.g., `3.2.0-beta.4`):\n   ```bash\n   git tag 3.2.0-beta.4\n   git push origin 3.2.0-beta.4\n   ```\n5. The CI/CD workflow (`.github/workflows/build.yml`) detects the tag contains \"beta\", uses the tag name as the VERSION string, generates `module/__version__.py`, and builds the Docker image\n\nThe VERSION is injected at build time via CI — `module/__version__.py` does not exist in the repo. At runtime, `module/conf/config.py` imports it or falls back to `\"DEV_VERSION\"`.\n\n## Database Migrations\n\nSchema migrations are tracked via a `schema_version` table in SQLite. To add a new migration:\n\n1. Increment `CURRENT_SCHEMA_VERSION` in `backend/src/module/database/combine.py`\n2. Append a new entry to the `MIGRATIONS` list: `(version, \"description\", [\"SQL statements\"])`\n3. Migrations run automatically on startup via `run_migrations()`\n\n## Notes\n\n- Documentation and comments are in Chinese\n- Uses SQLModel (hybrid Pydantic + SQLAlchemy ORM)\n- External integrations: qBittorrent API, TMDB API, OpenAI API\n- Version tracked in `/config/version.info` (for cross-version upgrade detection)\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 贡献指南 Contributing\n\n我们欢迎各位 Contributors 参与贡献帮助 AutoBangumi 更好的解决大家遇到的问题，\n\n这篇指南会指导你如何为 AutoBangumi 贡献功能修复代码，可以在你要提出 Pull Request 之前花几分钟来阅读一遍这篇指南。\n\n这篇文章包含什么？\n\n- [项目规划 Roadmap](#项目规划-roadmap)\n  - [提案寻求共识 Request for Comments](#提案寻求共识-request-for-comments)\n- [分支管理 Git Branch](#分支管理-git-branch)\n  - [版本号](#版本号)\n  - [分支开发，主干发布](#分支开发主干发布)\n  - [Branch 生命周期](#branch-生命周期)\n  - [Git Workflow 一览](#git-workflow-一览)\n- [Pull Request](#pull-request)\n- [版本发布介绍](#版本发布介绍)\n\n\n## 项目规划 Roadmap\n\nAutoBangumi 开发组使用 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板来管理预计开发的规划、在修复中的问题，以及它们处理的进度；\n\n这将帮助你更好的了解\n- 开发团队在做什么？\n- 有什么和你想贡献的方向一致的，可以直接参与实现与优化\n- 有什么已经在进行中的，避免自己重复不必要的工作\n\n在 [Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 中你可以看到除通常的 `[Feature Request]`, `[BUG]`, 一些小优化项以外，还有一类 **`[RFC]`**；\n\n### 提案寻求共识 Request for Comments\n\n> 在 issue 中通过 `RFC` label 能找到到现有的 [AutoBangumi RFCs](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+label%3ARFC)\n\n对于一些小的优化项或者 bug 修复，你大可以直接帮忙调整代码然后提出 Pull Request，只需要简单阅读下 [分支管理](#分支管理-Git-Branch) 章节以基于正确的版本分支修复、以及通过 [Pull Request](#Pull-Request) 章节了解 PR 将如何被合并。\n\n<br/>\n\n而如果你打算做的是一项**较大的**功能重构，改动范围大而涉及的方面比较多，那么希望你能通过 [Issue: 功能提案](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=RFC&projects=&template=rfc.yml&title=%5BRFC%5D%3A+) 先写一份 RFC 提案来简单阐述「你打算怎么做」的简短方案，来寻求开发者的讨论和共识。\n\n因为有些方案可能是开发团队原本讨论并且认为不要做的事，而上一步可以避免你浪费大量精力。\n  \n> 如果仅希望讨论是否添加或改进某功能本身，而非「要如何实现」，请使用 -> [Issue: 功能改进](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)\n\n\n<br/>\n\n一份 [提案(RFC)](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+is%3Aopen+label%3ARFC) 定位为 **「在某功能/重构的具体开发前，用于开发者间 review 技术设计/方案的文档」**，\n\n目的是让协作的开发者间清晰的知道「要做什么」和「具体会怎么做」，以及所有的开发者都能公开透明的参与讨论；\n\n以便评估和讨论产生的影响 (遗漏的考虑、向后兼容性、与现有功能的冲突)，\n\n因此提案侧重在对解决问题的 **方案、设计、步骤** 的描述上。\n\n\n## 分支管理 Git Branch\n\n### 版本号\n\nAutoBangumi 项目中的 Git 分支使用与发布版本规则密切相关，因此先介绍版本规范；\n\nAutoBangumi 发布的版本号遵循 [「语义化版本 SemVer」](https://semver.org/lang/zh-CN/) 的规范，\n\n使用 `<Major>.<Minor>.<Patch>` 三位版本的格式，每一位版本上的数字更新含义如下：\n\n- **Major**: 大版本更新，很可能有不兼容的 配置/API 修改\n- **Minor**: 向下兼容的功能性新增\n- **Patch**: 向下兼容的 Bug 修复 / 小优化修正\n\n### 分支开发，主干发布\n\nAutoBangumi 项目使用「分支开发，主干发布」的模式，\n\n[**`main`**](https://github.com/EstrellaXD/Auto_Bangumi/commits/main) 分支是稳定版本的 **「主干分支」**，只用于发布版本，不用于直接开发新功能或修复。\n\n每一个 Minor 版本都有一个对应的 **「开发分支」** 用于开发新功能、与发布后维护修复问题，\n\n开发分支的名字为 `<Major>.<Minor>-dev`，如 `3.1-dev`, `3.0-dev`, `2.6-dev`， 你可以在仓库的 [All Branches 中搜索到它们](https://github.com/EstrellaXD/Auto_Bangumi/branches/all?query=-dev)。\n\n\n### Branch 生命周期\n\n当一个 Minor 开发分支(以 `3.1-dev` 为例) 完成新功能开发，**首次**合入 main 分支后，\n- 发布 Minor 版本 (如 `3.1.0`)\n- 同时拉出**下一个** Minor 开发分支(`3.2-dev`)，用于下一个版本新功能开发\n  - 而**上一个**版本开发分支(`3.0-dev`)进入归档不再维护\n- 且这个 Minor 分支(`3.1-dev`)进入维护阶段，不再增加新功能/重构，只维护 Bugs 修复\n  - Bug 修复到维护阶段的 Minor 分支(`3.1-dev`)后，会再往 main 分支合并，并发布 `Patch` 版本\n\n根据这个流程，对于各位 Contributors 在开发贡献时选择 Git Branch 来说，则是：\n- 若「修复 Bug」，则基于**当前发布版本**的 Minor 分支开发修复，并 PR 到这个分支\n- 若「添加新功能/重构」，则基于**还未发布的下一个版本** Minor 分支开发，并 PR 到这个分支\n\n> 「当前发布版本」为 [[Releases 页面]](https://github.com/EstrellaXD/Auto_Bangumi/releases) 最新版本，这也与 [[GitHub Container Registry]](https://github.com/EstrellaXD/Auto_Bangumi/pkgs/container/auto_bangumi) 中最新版本相同\n\n\n### Git Workflow 一览\n\n> 图中 commit timeline 从左到右 --->\n\n```mermaid\n%%{init: {'theme': 'base', 'gitGraph': {'showCommitLabel': true}}}%%\n\ngitGraph:\n  checkout main\n  commit id: \".\"\n  branch 3.0-dev\n  commit id: \"feat 1\"\n  commit id: \"feat 2\"\n  commit id: \"feat 3\"\n\n  checkout main\n  merge 3.0-dev tag: \"3.0.9\"\n  commit id: \"..\"\n\n  branch 3.1-dev\n  commit id: \"feat 4\"\n\n  checkout 3.0-dev\n  commit id: \"PR merge (fix)\"\n  checkout main\n  merge 3.0-dev tag: \"3.0.10\"\n\n  checkout 3.1-dev\n  commit id: \"feat 5\"\n  commit id: \"feat 6\"\n\n  checkout main\n  merge 3.1-dev tag: \"3.1.0\"\n  commit id: \"...\"\n\n  branch 3.2-dev\n  commit id: \"feat 7\"\n  commit id: \"feat 8\"\n\n  checkout 3.1-dev\n  commit id: \"PR merge (fix) \"\n  checkout main\n  merge 3.1-dev tag: \"3.1.1\"\n\n  checkout 3.2-dev\n  commit id: \"PR merge (feat)\"\n```\n\n\n## Pull Request\n\n请确保你根据上文的 Git 分支管理 章节选择了正确的 PR 目标分支，\n> - 若「修复 Bug」，则 PR 到**当前发布版本**的 Minor 维护分支\n> - 若「添加新功能/重构」，则 PR **下一个版本** Minor 开发分支\n\n<br/>\n\n- 一个 PR 应该只对应一件事，而不应引入不相关的更改；\n\n  对于不同的事情可以拆分提多个 PR，这能帮助开发组每次 review 只专注一个问题。\n\n- 在提 PR 的标题与描述中，最好对修改内容做简短的说明，包括原因和意图，\n  \n  如果有相关的 issue 或 RFC，应该把它们链接到 PR 描述中，\n  \n  这将帮助开发组 code review 时能最快了解上下文。\n\n- 确保勾选了「允许维护者编辑」(`Allow edits from maintainers`) 选项。这使我们可以直接进行较小的编辑/重构并节省大量时间。\n\n- 请确保本地通过了「单元测试」和「代码风格 Lint」，这也会在 PR 的 GitHub CI 上检查\n  - 对于 bug fix 和新功能，通常开发组也会请求你添加对应改动的单元测试覆盖\n\n\n开发组会在有时间的最快阶段 Review 贡献者提的 PR 并讨论或批准合并(Approve Merge)。\n\n## 版本发布介绍\n\n版本发布目前由开发组通过手动合并「特定发版 PR」后自动触发打包与发布。\n\n通常 Bug 修复的 PR 合并后会很快发版，通常不到一周；\n\n而新功能的发版时间则会更长而且不定，你可以在我们的 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板中看到开发进度，一个版本规划的新功能都开发完备后就会发版。\n\n## 贡献文档\n\n如果要为文档做贡献，请注意以下几点：\n\n- 更新分支为 `docs-update`，并基于它做修改.\n- 请确保你的 PR 标题和描述中包含了你的修改的目的和意图。\n\n撰写文档请尽量使用规范的书面化用语，遵照 Markdown 语法，以及 [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) 中的规范。\n\n\n"
  },
  {
    "path": "Dockerfile",
    "content": "# syntax=docker/dockerfile:1\n\nFROM ghcr.io/astral-sh/uv:0.5-python3.13-alpine AS builder\n\nWORKDIR /app\nENV UV_COMPILE_BYTECODE=1\nENV UV_LINK_MODE=copy\n\n# Install dependencies (cached layer)\nCOPY backend/pyproject.toml backend/uv.lock ./\nRUN uv sync --frozen --no-dev\n\n# Copy application source\nCOPY backend/src ./src\n\n\nFROM python:3.13-alpine AS runtime\n\nRUN apk add --no-cache \\\n    bash \\\n    su-exec \\\n    shadow \\\n    tini \\\n    tzdata\n\nENV LANG=\"C.UTF-8\" \\\n    TZ=Asia/Shanghai \\\n    PUID=1000 \\\n    PGID=1000 \\\n    UMASK=022\n\nWORKDIR /app\n\n# Copy venv and source from builder\nCOPY --from=builder /app/.venv /app/.venv\nCOPY --from=builder /app/src .\nCOPY --chmod=755 entrypoint.sh /entrypoint.sh\n\n# Add user\nRUN mkdir -p /home/ab && \\\n    addgroup -S ab -g 911 && \\\n    adduser -S ab -G ab -h /home/ab -s /sbin/nologin -u 911\n\nENV PATH=\"/app/.venv/bin:$PATH\"\n\nEXPOSE 7892\nVOLUME [\"/app/config\", \"/app/data\"]\n\nENTRYPOINT [\"tini\", \"-g\", \"--\", \"/entrypoint.sh\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Estrella Pan\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<p align=\"center\">\n    <img src=\"docs/public/image/icons/light-icon.svg#gh-light-mode-only\" width=50%/ alt=\"\">\n    <img src=\"docs/public/image/icons/dark-icon.svg#gh-dark-mode-only\" width=50%/ alt=\"\">\n</p>\n<p align=\"center\">\n    <img title=\"docker build version\" src=\"https://img.shields.io/docker/v/estrellaxd/auto_bangumi\" alt=\"\">\n    <img title=\"release date\" src=\"https://img.shields.io/github/release-date/estrellaxd/auto_bangumi\" alt=\"\">\n    <img title=\"docker pull\" src=\"https://img.shields.io/docker/pulls/estrellaxd/auto_bangumi\" alt=\"\">\n    <img title=\"python version\" src=\"https://img.shields.io/badge/python-3.11-blue\" alt=\"\">\n</p>\n\n<p align=\"center\">\n  <a href=\"https://www.autobangumi.org\">官方网站</a> | <a href=\"https://www.autobangumi.org/deploy/quick-start.html\">快速开始</a> | <a href=\"https://www.autobangumi.org/changelog/3.2.html\">更新日志</a> | <a href=\"https://t.me/autobangumi_update\">更新推送</a> | <a href=\"https://t.me/autobangumi\">TG 群组</a>\n</p>\n\n# 项目说明\n\n<p align=\"center\">\n    <img title=\"AutoBangumi\" src=\"docs/public/image/feature/bangumi-list.png\" alt=\"\" width=75%>\n</p>\n\n本项目是基于 RSS 的全自动追番整理下载工具。只需要在 [Mikan Project][mikan] 等网站上订阅番剧，就可以全自动追番。\n并且整理完成的名称和目录可以直接被 [Plex][plex]、[Jellyfin][plex] 等媒体库软件识别，无需二次刮削。\n\n## AutoBangumi 功能说明\n\n### 核心功能\n\n- 简易单次配置就能持续使用\n- 无需介入的 `RSS` 解析器，解析番组信息并且自动生成下载规则\n- 首次运行设置向导，7 步引导完成配置\n- 番剧文件整理:\n\n    ```\n    Bangumi\n    ├── bangumi_A_title\n    │   ├── Season 1\n    │   │   ├── A S01E01.mp4\n    │   │   ├── A S01E02.mp4\n    │   │   ├── A S01E03.mp4\n    │   │   └── A S01E04.mp4\n    │   └── Season 2\n    │       ├── A S02E01.mp4\n    │       ├── A S02E02.mp4\n    │       ├── A S02E03.mp4\n    │       └── A S02E04.mp4\n    ├── bangumi_B_title\n    │   └─── Season 1\n    ```\n\n- 全自动重命名，重命名后 99% 以上的番剧可以直接被媒体库软件直接刮削\n\n    ```\n  [Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4 \n  >>\n   Kakkou no Iinazuke S01E07.mp4\n  ```\n\n- 自定义重命名，可以根据上级文件夹对所有子文件重命名。\n- 季中追番可以补全当季遗漏的所有剧集\n- 高度可自定义的功能选项，可以针对不同媒体库软件微调\n- 支持多种 RSS 站点，支持聚合 RSS 的解析\n- 无需维护完全无感使用\n- 内置 TMDB 解析器，可以直接生成完整的 TMDB 格式的文件以及番剧信息\n\n### 3.2 新功能\n\n- **日历视图**：按播出日期查看订阅番剧，集成 Bangumi.tv 放送时间表\n- **Passkey 无密码登录**：支持 WebAuthn 指纹/面容登录，支持无用户名登录\n- **季度/集数偏移自动检测**：自动识别「虚拟季度」并计算正确的集数偏移\n- **番剧归档**：手动或自动归档已完结番剧，保持列表整洁\n- **搜索源设置面板**：在 UI 中直接管理搜索源，无需编辑配置文件\n- **RSS 连接状态**：实时显示订阅源健康状态，快速定位问题\n- **iOS 风格通知徽章**：直观显示需要关注的订阅\n- **全新 UI 设计**：深色/浅色主题、移动端适配、毛玻璃登录页\n- **性能优化**：并发 RSS 刷新提速 10 倍、并发下载提速 5 倍\n\n## [Roadmap](https://github.com/users/EstrellaXD/projects/2)\n\n***已支持的下载器：***\n\n- qBittorrent\n\n## Star History\n\n[![Star History Chart](https://api.star-history.com/svg?repos=EstrellaXD/Auto_Bangumi&type=Date)](https://star-history.com/#EstrellaXD/Auto_Bangumi)\n\n## 贡献\n\n欢迎提供 ISSUE 或者 PR, 贡献代码前建议阅读 [CONTRIBUTING.md](CONTRIBUTING.md)。\n\n贡献者名单请见：\n\n<a href=\"https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors\"><img src=\"https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi\"></a>\n\n\n## Licence\n\n[MIT licence](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE)\n\n[mikan]: https://mikanani.me\n[plex]: https://plex.tv\n[jellyfin]: https://jellyfin.org\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy / 安全政策\n\n## Supported Versions / 支持的版本\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 3.x     | :white_check_mark: |\n| < 3.0   | :x:                |\n\n## Reporting a Vulnerability / 报告漏洞\n\n### English\n\nIf you discover a security vulnerability in AutoBangumi, please report it responsibly:\n\n1. **GitHub Private Vulnerability Reporting** (Recommended): Use [GitHub's private vulnerability reporting feature](https://github.com/EstrellaXD/Auto_Bangumi/security/advisories/new) to submit your report securely.\n\n2. **Email**: Contact the maintainer directly at the email associated with the GitHub account [@EstrellaXD](https://github.com/EstrellaXD).\n\n**Please do NOT:**\n- Open a public GitHub issue for security vulnerabilities\n- Disclose the vulnerability publicly before it has been addressed\n\n**What to include in your report:**\n- Description of the vulnerability\n- Steps to reproduce the issue\n- Potential impact\n- Any suggested fixes (optional)\n\nWe will acknowledge receipt of your report within 48 hours and work to address the issue promptly.\n\n---\n\n### 中文\n\n如果您在 AutoBangumi 中发现安全漏洞，请通过以下方式负责任地报告：\n\n1. **GitHub 私密漏洞报告**（推荐）：使用 [GitHub 的私密漏洞报告功能](https://github.com/EstrellaXD/Auto_Bangumi/security/advisories/new) 安全地提交您的报告。\n\n2. **邮件**：直接联系维护者，使用 GitHub 账户 [@EstrellaXD](https://github.com/EstrellaXD) 关联的邮箱。\n\n**请勿：**\n- 在公开的 GitHub Issue 中报告安全漏洞\n- 在漏洞被修复之前公开披露\n\n**报告中请包含：**\n- 漏洞描述\n- 复现步骤\n- 潜在影响\n- 修复建议（可选）\n\n我们将在 48 小时内确认收到您的报告，并尽快处理该问题。\n"
  },
  {
    "path": "backend/.pre-commit-config.yaml",
    "content": "repos:\n- repo: https://github.com/psf/black\n  rev: 22.10.0\n  hooks:\n    - id: black\n      language: python\n- repo: https://github.com/astral-sh/ruff-pre-commit\n  # Ruff version.\n  rev: v0.0.291\n  hooks:\n    - id: ruff\n"
  },
  {
    "path": "backend/.vscode/settings.json",
    "content": "{\n  \"python.formatting.provider\": \"none\",\n  \"python.formatting.blackPath\": \"black\",\n  \"editor.formatOnSave\": true,\n  \"[python]\": {\n    \"editor.defaultFormatter\": \"ms-python.black-formatter\"\n  }\n}\n"
  },
  {
    "path": "backend/dev.sh",
    "content": "#!/usr/bin/env bash\n\n# This script is used to run the development environment.\n\npython3 -m venv venv\n\n./venv/bin/python3 -m pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements-dev.txt\n\n# install git-hooks for pre-commit before committing.\n./venv/bin/pre-commit install\n\ncd src || exit\n\nCONFIG_DIR=\"config\"\n\nif [ ! -d \"$CONFIG_DIR\" ]; then\n\techo \"The directory '$CONFIG_DIR' is missing.\"\n\tmkdir config\nfi\n\nVERSION_FILE=\"module/__version__.py\"\n\nif [ ! -f \"$VERSION_FILE\" ]; then\n\techo \"The file '$VERSION_FILE' is missing.\"\n\techo \"VERSION='DEV_VERSION'\" >>\"$VERSION_FILE\"\nfi\n\n../venv/bin/uvicorn main:app --reload --port 7892\n"
  },
  {
    "path": "backend/pyproject.toml",
    "content": "[project]\nname = \"auto-bangumi\"\nversion = \"3.2.4\"\ndescription = \"AutoBangumi - Automated anime download manager\"\nrequires-python = \">=3.13\"\ndependencies = [\n    \"fastapi>=0.109.0\",\n    \"uvicorn>=0.27.0\",\n    \"httpx[socks]>=0.25.0\",\n    \"httpx-socks>=0.9.0\",\n    \"beautifulsoup4>=4.12.0\",\n    \"sqlmodel>=0.0.14\",\n    \"sqlalchemy[asyncio]>=2.0.0\",\n    \"aiosqlite>=0.19.0\",\n    \"pydantic>=2.0.0\",\n    \"python-jose>=3.3.0\",\n    \"passlib>=1.7.4\",\n    \"bcrypt>=4.0.1,<4.1\",\n    \"python-multipart>=0.0.6\",\n    \"python-dotenv>=1.0.0\",\n    \"Jinja2>=3.1.2\",\n    \"openai>=1.54.3\",\n    \"semver>=3.0.1\",\n    \"sse-starlette>=1.6.5\",\n    \"webauthn>=2.0.0\",\n    \"urllib3>=2.0.3\",\n    \"mcp[cli]>=1.8.0\",\n]\n\n[dependency-groups]\ndev = [\n    \"pytest>=8.0.0\",\n    \"pytest-asyncio>=0.23.0\",\n    \"pytest-mock>=3.12.0\",\n    \"ruff>=0.1.0\",\n    \"black>=24.0.0\",\n    \"pre-commit>=3.0.0\",\n]\n\n[tool.pytest.ini_options]\ntestpaths = [\"src/test\"]\npythonpath = [\"src\"]\nasyncio_mode = \"auto\"\nmarkers = [\n    \"e2e: End-to-end integration tests (require Docker)\",\n]\n\n[tool.ruff]\nline-length = 88\ntarget-version = \"py313\"\nexclude = [\".venv\", \"venv\", \"build\", \"dist\"]\n\n[tool.ruff.lint]\nselect = [\"E\", \"F\", \"I\"]\nignore = [\"E501\", \"F401\"]\nfixable = [\"ALL\"]\nunfixable = []\n\n[tool.ruff.lint.mccabe]\nmax-complexity = 10\n\n[tool.uv]\npackage = false\n\n[tool.black]\nline-length = 88\ntarget-version = ['py313']\n"
  },
  {
    "path": "backend/scripts/pip-lock-version.sh",
    "content": "#!/usr/bin/env bash\n\n#\n# Usage:\n#   `bash scripts/pip-lock-version.sh`\n#\n# ```prompt\n# Lock the library versions in `requirements.txt` to the current ones from `pip freeze` using shell script,\n# but don't change any order in `requirements.txt`\n# ```\n#\n\n\n# Create a temporary requirements file using pip freeze\npip freeze > pip_freeze.log\n\n# Read the existing requirements.txt line by line\nwhile IFS= read -r line\ndo\n    # Extract the library name without version\n    lib_name=$(echo $line | cut -d'=' -f1)\n\n    # Find the corresponding library in the temporary requirements file\n    lib_line=$(grep \"^$lib_name==\" pip_freeze.log)\n\n    # If the library is found, update the line\n    if [[ $lib_line ]]\n    then\n        echo $lib_line\n    else\n        echo $line\n    fi\n\n# Redirect the output to a new requirements file\ndone < requirements.txt > new_requirements.log\n\n# Remove the temporary requirements file\nrm pip_freeze.log\n\n# Replace the old requirements file with the new one\nmv new_requirements.log requirements.txt\n\n"
  },
  {
    "path": "backend/src/dev_server.py",
    "content": "\"\"\"Minimal dev server that skips downloader check for UI testing.\"\"\"\nimport uvicorn\nfrom fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi import APIRouter\n\nfrom module.database.combine import Database\nfrom module.database.engine import engine\n\n# Initialize DB + migrations + default user\nwith Database(engine) as db:\n    db.create_table()\n    db.user.add_default_user()\n\n# Build v1 router without program router (which blocks on downloader check)\nfrom module.api.auth import router as auth_router\nfrom module.api.bangumi import router as bangumi_router\nfrom module.api.config import router as config_router\nfrom module.api.log import router as log_router\nfrom module.api.rss import router as rss_router\nfrom module.api.search import router as search_router\n\nv1 = APIRouter(prefix=\"/v1\")\nv1.include_router(auth_router)\nv1.include_router(bangumi_router)\nv1.include_router(config_router)\nv1.include_router(log_router)\nv1.include_router(rss_router)\nv1.include_router(search_router)\n\n# Stub status endpoint (real one lives in program router which blocks on downloader)\n@v1.get(\"/status\")\nasync def stub_status():\n    return {\"status\": True, \"version\": \"dev\"}\n\napp = FastAPI()\napp.add_middleware(\n    CORSMiddleware,\n    allow_origins=[\"*\"],\n    allow_credentials=True,\n    allow_methods=[\"*\"],\n    allow_headers=[\"*\"],\n)\napp.include_router(v1, prefix=\"/api\")\n\nif __name__ == \"__main__\":\n    uvicorn.run(app, host=\"127.0.0.1\", port=7892)\n"
  },
  {
    "path": "backend/src/main.py",
    "content": "import logging\nimport os\nfrom contextlib import asynccontextmanager\nfrom pathlib import Path\n\nimport uvicorn\nfrom fastapi import FastAPI, Request\nfrom fastapi.middleware.cors import CORSMiddleware\nfrom fastapi.responses import FileResponse, HTMLResponse, RedirectResponse\nfrom fastapi.staticfiles import StaticFiles\nfrom fastapi.templating import Jinja2Templates\n\nfrom module.api import v1\nfrom module.api.program import program\nfrom module.conf import VERSION, settings, setup_logger\nfrom module.mcp import create_mcp_app\n\nsetup_logger(reset=True)\nlogger = logging.getLogger(__name__)\nuvicorn_logging_config = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"handlers\": logger.handlers,\n    \"loggers\": {\n        \"uvicorn\": {\n            \"level\": logger.level,\n        },\n        \"uvicorn.access\": {\n            \"level\": \"WARNING\",\n        },\n    },\n}\n\n\n@asynccontextmanager\nasync def lifespan(app: FastAPI):\n    import asyncio\n\n    # Startup\n    asyncio.create_task(program.startup())\n    yield\n    # Shutdown\n    await program.stop()\n\n\ndef create_app() -> FastAPI:\n    app = FastAPI(lifespan=lifespan)\n\n    app.add_middleware(\n        CORSMiddleware,\n        allow_origins=[],\n        allow_credentials=True,\n        allow_methods=[\"GET\", \"POST\", \"PUT\", \"PATCH\", \"DELETE\"],\n        allow_headers=[\"*\"],\n    )\n\n    # mount routers\n    app.include_router(v1, prefix=\"/api\")\n\n    # mount MCP server (SSE transport for LLM tool integration)\n    app.mount(\"/mcp\", create_mcp_app())\n\n    return app\n\n\napp = create_app()\n\n\n_POSTERS_BASE = Path(\"data/posters\").resolve()\n\n\n@app.get(\"/posters/{path:path}\", tags=[\"posters\"])\ndef posters(path: str):\n    resolved = (_POSTERS_BASE / path).resolve()\n    if not str(resolved).startswith(str(_POSTERS_BASE)):\n        return HTMLResponse(status_code=403)\n    return FileResponse(str(resolved))\n\n\nif VERSION != \"DEV_VERSION\":\n    app.mount(\"/assets\", StaticFiles(directory=\"dist/assets\"), name=\"assets\")\n    app.mount(\"/images\", StaticFiles(directory=\"dist/images\"), name=\"images\")\n    # app.mount(\"/icons\", StaticFiles(directory=\"dist/icons\"), name=\"icons\")\n    templates = Jinja2Templates(directory=\"dist\")\n\n    @app.get(\"/{path:path}\")\n    def html(request: Request, path: str):\n        files = os.listdir(\"dist\")\n        if path in files:\n            return FileResponse(f\"dist/{path}\")\n        else:\n            context = {\"request\": request}\n            return templates.TemplateResponse(\"index.html\", context)\n\nelse:\n\n    @app.get(\"/\", status_code=302, tags=[\"html\"])\n    def index():\n        return RedirectResponse(\"/docs\")\n\n\nif __name__ == \"__main__\":\n    if os.getenv(\"IPV6\"):\n        host = \"::\"\n    else:\n        host = os.getenv(\"HOST\", \"0.0.0.0\")\n    uvicorn.run(\n        app,\n        host=host,\n        port=settings.program.webui_port,\n        log_config=uvicorn_logging_config,\n    )\n"
  },
  {
    "path": "backend/src/module/__init__.py",
    "content": "\n"
  },
  {
    "path": "backend/src/module/ab_decorator/__init__.py",
    "content": "import asyncio\nimport functools\nimport logging\n\nimport httpx\n\nfrom .timeout import timeout\n\nlogger = logging.getLogger(__name__)\n_lock = asyncio.Lock()\n\n_RETRY_DELAYS = [5, 15, 45, 120, 300]\n\n\ndef qb_connect_failed_wait(func):\n    @functools.wraps(func)\n    async def wrapper(*args, **kwargs):\n        times = 0\n        while times < 5:\n            try:\n                return await func(*args, **kwargs)\n            except (\n                ConnectionError,\n                TimeoutError,\n                OSError,\n                httpx.ConnectError,\n                httpx.TimeoutException,\n                httpx.RequestError,\n            ) as e:\n                delay = _RETRY_DELAYS[times]\n                logger.debug(\"URL: %s\", args[0])\n                logger.warning(e)\n                logger.warning(\n                    \"Cannot connect to qBittorrent. Wait %ds and retry...\", delay\n                )\n                await asyncio.sleep(delay)\n                times += 1\n\n    return wrapper\n\n\ndef api_failed(func):\n    @functools.wraps(func)\n    async def wrapper(*args, **kwargs):\n        try:\n            return await func(*args, **kwargs)\n        except Exception as e:\n            logger.debug(\"URL: %s\", args[0])\n            logger.warning(\"Wrong API response.\")\n            logger.debug(e)\n\n    return wrapper\n\n\ndef locked(func):\n    @functools.wraps(func)\n    async def wrapper(*args, **kwargs):\n        async with _lock:\n            return await func(*args, **kwargs)\n\n    return wrapper\n"
  },
  {
    "path": "backend/src/module/ab_decorator/timeout.py",
    "content": "import signal\n\n\ndef timeout(seconds):\n    def decorator(func):\n        def handler(signum, frame):\n            raise TimeoutError(\"Function timed out.\")\n\n        def wrapper(*args, **kwargs):\n            # 设置信号处理程序，当超时时触发TimeoutError异常\n            signal.signal(signal.SIGALRM, handler)\n            signal.alarm(seconds)  # 设置alarm定时器\n\n            try:\n                result = func(*args, **kwargs)\n            finally:\n                signal.alarm(0)  # 取消alarm定时器\n\n            return result\n\n        return wrapper\n\n    return decorator\n"
  },
  {
    "path": "backend/src/module/api/__init__.py",
    "content": "from fastapi import APIRouter\n\nfrom .auth import router as auth_router\nfrom .bangumi import router as bangumi_router\nfrom .config import router as config_router\nfrom .downloader import router as downloader_router\nfrom .log import router as log_router\nfrom .passkey import router as passkey_router\nfrom .program import router as program_router\nfrom .rss import router as rss_router\nfrom .search import router as search_router\nfrom .setup import router as setup_router\nfrom .notification import router as notification_router\n\n__all__ = \"v1\"\n\n# API 1.0\nv1 = APIRouter(prefix=\"/v1\")\nv1.include_router(auth_router)\nv1.include_router(passkey_router)\nv1.include_router(log_router)\nv1.include_router(program_router)\nv1.include_router(bangumi_router)\nv1.include_router(config_router)\nv1.include_router(downloader_router)\nv1.include_router(rss_router)\nv1.include_router(search_router)\nv1.include_router(setup_router)\nv1.include_router(notification_router)\n"
  },
  {
    "path": "backend/src/module/api/auth.py",
    "content": "from datetime import datetime, timedelta\n\nfrom fastapi import APIRouter, Cookie, Depends, HTTPException, status\nfrom fastapi.responses import JSONResponse, Response\nfrom fastapi.security import OAuth2PasswordRequestForm\n\nfrom module.models import APIResponse\nfrom module.models.user import User, UserUpdate\nfrom module.security.api import (\n    active_user,\n    auth_user,\n    check_login_ip,\n    get_current_user,\n    update_user_info,\n)\nfrom module.security.jwt import create_access_token, decode_token\n\nfrom .response import u_response\n\nrouter = APIRouter(prefix=\"/auth\", tags=[\"auth\"])\n\n_TOKEN_EXPIRY_DAYS = 1\n_TOKEN_MAX_AGE = 86400\n\n\ndef _issue_token(username: str, response: Response) -> dict:\n    \"\"\"Create a JWT, set it as an HttpOnly cookie, and return the bearer payload.\"\"\"\n    token = create_access_token(\n        data={\"sub\": username}, expires_delta=timedelta(days=_TOKEN_EXPIRY_DAYS)\n    )\n    response.set_cookie(key=\"token\", value=token, httponly=True, max_age=_TOKEN_MAX_AGE)\n    return {\"access_token\": token, \"token_type\": \"bearer\"}\n\n\n@router.post(\"/login\", response_model=dict, dependencies=[Depends(check_login_ip)])\nasync def login(response: Response, form_data=Depends(OAuth2PasswordRequestForm)):\n    \"\"\"Authenticate with username/password and issue a session token.\"\"\"\n    user = User(username=form_data.username, password=form_data.password)\n    resp = auth_user(user)\n    if resp.status:\n        return _issue_token(user.username, response)\n    return u_response(resp)\n\n\n@router.get(\n    \"/refresh_token\", response_model=dict, dependencies=[Depends(get_current_user)]\n)\nasync def refresh(response: Response, token: str = Cookie(None)):\n    \"\"\"Refresh the current session token and update the active-user timestamp.\"\"\"\n    payload = decode_token(token)\n    username = payload.get(\"sub\") if payload else None\n    if not username:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Unauthorized\"\n        )\n    active_user[username] = datetime.now()\n    return _issue_token(username, response)\n\n\n@router.get(\n    \"/logout\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def logout(response: Response, token: str = Cookie(None)):\n    \"\"\"Invalidate the session and clear the token cookie.\"\"\"\n    payload = decode_token(token)\n    username = payload.get(\"sub\") if payload else None\n    if username:\n        active_user.pop(username, None)\n    response.delete_cookie(key=\"token\")\n    return JSONResponse(\n        status_code=200,\n        content={\"msg_en\": \"Logout successfully.\", \"msg_zh\": \"登出成功。\"},\n    )\n\n\n@router.post(\"/update\", response_model=dict, dependencies=[Depends(get_current_user)])\nasync def update_user(\n    user_data: UserUpdate, response: Response, token: str = Cookie(None)\n):\n    \"\"\"Update credentials for the current user and re-issue a fresh token.\"\"\"\n    payload = decode_token(token)\n    old_user = payload.get(\"sub\") if payload else None\n    if not old_user:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Unauthorized\"\n        )\n    if update_user_info(user_data, old_user):\n        return {**_issue_token(old_user, response), \"message\": \"update success\"}\n"
  },
  {
    "path": "backend/src/module/api/bangumi.py",
    "content": "from typing import Literal, Optional\n\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\nfrom pydantic import BaseModel\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.manager import TorrentManager\nfrom module.models import APIResponse, Bangumi, BangumiUpdate\nfrom module.parser.analyser.offset_detector import (\n    OffsetSuggestion as DetectorSuggestion,\n)\nfrom module.parser.analyser.offset_detector import detect_offset_mismatch\nfrom module.parser.analyser.tmdb_parser import tmdb_parser\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nfrom .response import u_response\n\n\nclass OffsetSuggestion(BaseModel):\n    \"\"\"Legacy offset suggestion model.\"\"\"\n    suggested_offset: int\n    reason: str\n\n\nclass TMDBSummary(BaseModel):\n    \"\"\"Summary of TMDB data for display.\"\"\"\n    title: str\n    total_seasons: int\n    season_episode_counts: dict[int, int]\n    status: Optional[str]\n    virtual_season_starts: Optional[dict[int, list[int]]] = None  # {1: [1, 29], ...}\n\n\nclass OffsetSuggestionDetail(BaseModel):\n    \"\"\"Detailed offset suggestion from detector.\"\"\"\n    season_offset: int\n    episode_offset: int\n    reason: str\n    confidence: Literal[\"high\", \"medium\", \"low\"]\n\n\nclass SetWeekdayRequest(BaseModel):\n    weekday: Optional[int] = None  # 0-6 for Mon-Sun, None to reset\n\n\nclass DetectOffsetRequest(BaseModel):\n    \"\"\"Request body for detect-offset endpoint.\"\"\"\n    title: str\n    parsed_season: int\n    parsed_episode: int\n\n\nclass DetectOffsetResponse(BaseModel):\n    \"\"\"Response for detect-offset endpoint.\"\"\"\n    has_mismatch: bool\n    suggestion: Optional[OffsetSuggestionDetail]\n    tmdb_info: Optional[TMDBSummary]\n\nrouter = APIRouter(prefix=\"/bangumi\", tags=[\"bangumi\"])\n\n\ndef str_to_list(data: Bangumi):\n    data.filter = data.filter.split(\",\")\n    data.rss_link = data.rss_link.split(\",\")\n    return data\n\n\n@router.get(\n    \"/get/all\", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]\n)\nasync def get_all_data():\n    with TorrentManager() as manager:\n        return manager.bangumi.search_all()\n\n\n@router.get(\n    \"/get/{bangumi_id}\",\n    response_model=Bangumi,\n    dependencies=[Depends(get_current_user)],\n)\nasync def get_data(bangumi_id: str):\n    with TorrentManager() as manager:\n        resp = manager.search_one(bangumi_id)\n    return resp\n\n\n@router.patch(\n    \"/update/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def update_rule(\n    bangumi_id: int,\n    data: BangumiUpdate,\n):\n    with TorrentManager() as manager:\n        resp = await manager.update_rule(bangumi_id, data)\n    return u_response(resp)\n\n\n@router.delete(\n    path=\"/delete/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def delete_rule(bangumi_id: str, file: bool = False):\n    with TorrentManager() as manager:\n        resp = await manager.delete_rule(bangumi_id, file)\n    return u_response(resp)\n\n\n@router.delete(\n    path=\"/delete/many/\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def delete_many_rule(bangumi_id: list, file: bool = False):\n    with TorrentManager() as manager:\n        for i in bangumi_id:\n            resp = await manager.delete_rule(i, file)\n    return u_response(resp)\n\n\n@router.delete(\n    path=\"/disable/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def disable_rule(bangumi_id: str, file: bool = False):\n    with TorrentManager() as manager:\n        resp = await manager.disable_rule(bangumi_id, file)\n    return u_response(resp)\n\n\n@router.delete(\n    path=\"/disable/many/\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def disable_many_rule(bangumi_id: list, file: bool = False):\n    with TorrentManager() as manager:\n        for i in bangumi_id:\n            resp = await manager.disable_rule(i, file)\n    return u_response(resp)\n\n\n@router.get(\n    path=\"/enable/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def enable_rule(bangumi_id: str):\n    with TorrentManager() as manager:\n        resp = manager.enable_rule(bangumi_id)\n    return u_response(resp)\n\n\n@router.get(\n    path=\"/refresh/poster/all\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_poster_all():\n    with TorrentManager() as manager:\n        resp = await manager.refresh_poster()\n    return u_response(resp)\n\n@router.get(\n    path=\"/refresh/poster/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_poster_one(bangumi_id: int):\n    with TorrentManager() as manager:\n        resp = await manager.refind_poster(bangumi_id)\n    return u_response(resp)\n\n\n@router.get(\n    path=\"/refresh/calendar\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_calendar():\n    with TorrentManager() as manager:\n        resp = await manager.refresh_calendar()\n    return u_response(resp)\n\n\n@router.get(\n    \"/reset/all\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def reset_all():\n    with TorrentManager() as manager:\n        manager.bangumi.delete_all()\n        return JSONResponse(\n            status_code=200,\n            content={\"msg_en\": \"Reset all rules successfully.\", \"msg_zh\": \"重置所有规则成功。\"},\n        )\n\n\n@router.patch(\n    path=\"/archive/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def archive_rule(bangumi_id: int):\n    \"\"\"Archive a bangumi.\"\"\"\n    with TorrentManager() as manager:\n        resp = manager.archive_rule(bangumi_id)\n    return u_response(resp)\n\n\n@router.patch(\n    path=\"/unarchive/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def unarchive_rule(bangumi_id: int):\n    \"\"\"Unarchive a bangumi.\"\"\"\n    with TorrentManager() as manager:\n        resp = manager.unarchive_rule(bangumi_id)\n    return u_response(resp)\n\n\n@router.get(\n    path=\"/refresh/metadata\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_metadata():\n    \"\"\"Refresh TMDB metadata and auto-archive ended series.\"\"\"\n    with TorrentManager() as manager:\n        resp = await manager.refresh_metadata()\n    return u_response(resp)\n\n\n@router.get(\n    path=\"/suggest-offset/{bangumi_id}\",\n    response_model=OffsetSuggestion,\n    dependencies=[Depends(get_current_user)],\n)\nasync def suggest_offset(bangumi_id: int):\n    \"\"\"Suggest offset based on TMDB episode counts.\"\"\"\n    with TorrentManager() as manager:\n        resp = await manager.suggest_offset(bangumi_id)\n    return resp\n\n\n@router.post(\n    path=\"/detect-offset\",\n    response_model=DetectOffsetResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def detect_offset(request: DetectOffsetRequest):\n    \"\"\"Detect season/episode mismatch with TMDB data.\n\n    Called by frontend before adding/subscribing to check if offsets are needed.\n    \"\"\"\n    language = settings.rss_parser.language\n    tmdb_info = await tmdb_parser(request.title, language)\n\n    if not tmdb_info:\n        return DetectOffsetResponse(\n            has_mismatch=False,\n            suggestion=None,\n            tmdb_info=None,\n        )\n\n    # Detect mismatch\n    suggestion = detect_offset_mismatch(\n        parsed_season=request.parsed_season,\n        parsed_episode=request.parsed_episode,\n        tmdb_info=tmdb_info,\n    )\n\n    # Build TMDB summary\n    tmdb_summary = TMDBSummary(\n        title=tmdb_info.title,\n        total_seasons=tmdb_info.last_season,\n        season_episode_counts=tmdb_info.season_episode_counts or {},\n        status=tmdb_info.series_status,\n        virtual_season_starts=tmdb_info.virtual_season_starts,\n    )\n\n    if suggestion:\n        return DetectOffsetResponse(\n            has_mismatch=True,\n            suggestion=OffsetSuggestionDetail(\n                season_offset=suggestion.season_offset,\n                episode_offset=suggestion.episode_offset,\n                reason=suggestion.reason,\n                confidence=suggestion.confidence,\n            ),\n            tmdb_info=tmdb_summary,\n        )\n\n    return DetectOffsetResponse(\n        has_mismatch=False,\n        suggestion=None,\n        tmdb_info=tmdb_summary,\n    )\n\n\n@router.post(\n    path=\"/dismiss-review/{bangumi_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def dismiss_review(bangumi_id: int):\n    \"\"\"Clear the needs_review flag for a bangumi after user reviews.\"\"\"\n    with Database() as db:\n        success = db.bangumi.clear_needs_review(bangumi_id)\n\n    if success:\n        return JSONResponse(\n            status_code=200,\n            content={\n                \"status\": True,\n                \"msg_en\": \"Review dismissed.\",\n                \"msg_zh\": \"已取消检查标记。\",\n            },\n        )\n    else:\n        return JSONResponse(\n            status_code=404,\n            content={\n                \"status\": False,\n                \"msg_en\": f\"Bangumi {bangumi_id} not found.\",\n                \"msg_zh\": f\"未找到番剧 {bangumi_id}。\",\n            },\n        )\n\n\n@router.get(\n    path=\"/needs-review\",\n    response_model=list[Bangumi],\n    dependencies=[Depends(get_current_user)],\n)\nasync def get_needs_review():\n    \"\"\"Get all bangumi that need review for offset mismatch.\"\"\"\n    with Database() as db:\n        return db.bangumi.get_needs_review()\n\n\n@router.patch(\n    path=\"/{bangumi_id}/weekday\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def set_weekday(bangumi_id: int, request: SetWeekdayRequest):\n    \"\"\"Manually set the broadcast weekday for a bangumi.\"\"\"\n    if request.weekday is not None and not (0 <= request.weekday <= 6):\n        return JSONResponse(\n            status_code=400,\n            content={\n                \"status\": False,\n                \"msg_en\": \"Weekday must be 0-6 (Mon-Sun) or null.\",\n                \"msg_zh\": \"星期必须是 0-6（周一至周日）或空。\",\n            },\n        )\n    with Database() as db:\n        success = db.bangumi.set_weekday(bangumi_id, request.weekday)\n    if success:\n        action = f\"weekday {request.weekday}\" if request.weekday is not None else \"unknown\"\n        return JSONResponse(\n            status_code=200,\n            content={\n                \"status\": True,\n                \"msg_en\": f\"Set bangumi to {action}.\",\n                \"msg_zh\": f\"已设置放送日为 {action}。\",\n            },\n        )\n    return JSONResponse(\n        status_code=404,\n        content={\n            \"status\": False,\n            \"msg_en\": f\"Bangumi {bangumi_id} not found.\",\n            \"msg_zh\": f\"未找到番剧 {bangumi_id}。\",\n        },\n    )\n"
  },
  {
    "path": "backend/src/module/api/config.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\n\nfrom module.conf import settings\nfrom module.models import APIResponse, Config\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nrouter = APIRouter(prefix=\"/config\", tags=[\"config\"])\nlogger = logging.getLogger(__name__)\n\n_SENSITIVE_KEYS = (\"password\", \"api_key\", \"token\", \"secret\")\n_MASK = \"********\"\n\n\ndef _is_sensitive(key: str) -> bool:\n    return any(s in key.lower() for s in _SENSITIVE_KEYS)\n\n\ndef _sanitize_dict(d: dict) -> dict:\n    \"\"\"Recursively mask string values whose keys contain sensitive keywords.\"\"\"\n    result = {}\n    for k, v in d.items():\n        if isinstance(v, dict):\n            result[k] = _sanitize_dict(v)\n        elif isinstance(v, list):\n            result[k] = [\n                _sanitize_dict(item) if isinstance(item, dict) else item for item in v\n            ]\n        elif isinstance(v, str) and _is_sensitive(k):\n            result[k] = _MASK\n        else:\n            result[k] = v\n    return result\n\n\ndef _restore_masked(incoming: dict, current: dict) -> dict:\n    \"\"\"Replace masked sentinel values with real values from current config.\"\"\"\n    for k, v in incoming.items():\n        if isinstance(v, dict) and isinstance(current.get(k), dict):\n            _restore_masked(v, current[k])\n        elif isinstance(v, list) and isinstance(current.get(k), list):\n            cur_list = current[k]\n            for i, item in enumerate(v):\n                if (\n                    isinstance(item, dict)\n                    and i < len(cur_list)\n                    and isinstance(cur_list[i], dict)\n                ):\n                    _restore_masked(item, cur_list[i])\n        elif v == _MASK and _is_sensitive(k):\n            incoming[k] = current.get(k, v)\n    return incoming\n\n\n@router.get(\"/get\", dependencies=[Depends(get_current_user)])\nasync def get_config():\n    \"\"\"Return the current configuration with sensitive fields masked.\"\"\"\n    return _sanitize_dict(settings.dict())\n\n\n@router.patch(\n    \"/update\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def update_config(config: Config):\n    \"\"\"Persist and reload configuration from the supplied payload.\"\"\"\n    try:\n        config_dict = _restore_masked(config.dict(), settings.dict())\n        settings.save(config_dict=config_dict)\n        settings.load()\n        # update_rss()\n        logger.info(\"Config updated\")\n        return JSONResponse(\n            status_code=200,\n            content={\n                \"msg_en\": \"Update config successfully.\",\n                \"msg_zh\": \"更新配置成功。\",\n            },\n        )\n    except Exception as e:\n        logger.warning(e)\n        return JSONResponse(\n            status_code=406,\n            content={\"msg_en\": \"Update config failed.\", \"msg_zh\": \"更新配置失败。\"},\n        )\n"
  },
  {
    "path": "backend/src/module/api/downloader.py",
    "content": "import logging\n\nfrom fastapi import APIRouter, Depends\nfrom pydantic import BaseModel\n\nfrom module.database import Database\nfrom module.downloader import DownloadClient\nfrom module.security.api import get_current_user\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/downloader\", tags=[\"downloader\"])\n\n\nclass TorrentHashesRequest(BaseModel):\n    hashes: list[str]\n\n\nclass TorrentDeleteRequest(BaseModel):\n    hashes: list[str]\n    delete_files: bool = False\n\n\nclass TorrentTagRequest(BaseModel):\n    \"\"\"Request to tag a torrent with a bangumi ID.\"\"\"\n    hash: str\n    bangumi_id: int\n\n\n@router.get(\"/torrents\", dependencies=[Depends(get_current_user)])\nasync def get_torrents():\n    async with DownloadClient() as client:\n        return await client.get_torrent_info(category=\"Bangumi\", status_filter=None)\n\n\n@router.post(\"/torrents/pause\", dependencies=[Depends(get_current_user)])\nasync def pause_torrents(req: TorrentHashesRequest):\n    hashes = \"|\".join(req.hashes)\n    async with DownloadClient() as client:\n        await client.pause_torrent(hashes)\n    return {\"msg_en\": \"Torrents paused\", \"msg_zh\": \"种子已暂停\"}\n\n\n@router.post(\"/torrents/resume\", dependencies=[Depends(get_current_user)])\nasync def resume_torrents(req: TorrentHashesRequest):\n    hashes = \"|\".join(req.hashes)\n    async with DownloadClient() as client:\n        await client.resume_torrent(hashes)\n    return {\"msg_en\": \"Torrents resumed\", \"msg_zh\": \"种子已恢复\"}\n\n\n@router.post(\"/torrents/delete\", dependencies=[Depends(get_current_user)])\nasync def delete_torrents(req: TorrentDeleteRequest):\n    hashes = \"|\".join(req.hashes)\n    async with DownloadClient() as client:\n        await client.delete_torrent(hashes, delete_files=req.delete_files)\n    return {\"msg_en\": \"Torrents deleted\", \"msg_zh\": \"种子已删除\"}\n\n\n@router.post(\"/torrents/tag\", dependencies=[Depends(get_current_user)])\nasync def tag_torrent(req: TorrentTagRequest):\n    \"\"\"Tag a torrent with a bangumi ID for accurate offset lookup.\n\n    This adds the 'ab:ID' tag to the torrent in qBittorrent, which allows\n    the renamer to look up the correct episode/season offset.\n    \"\"\"\n    # Verify bangumi exists\n    with Database() as db:\n        bangumi = db.bangumi.search_id(req.bangumi_id)\n        if not bangumi:\n            return {\n                \"status\": False,\n                \"msg_en\": f\"Bangumi {req.bangumi_id} not found\",\n                \"msg_zh\": f\"未找到番剧 {req.bangumi_id}\",\n            }\n\n    tag = f\"ab:{req.bangumi_id}\"\n    async with DownloadClient() as client:\n        await client.add_tag(req.hash, tag)\n\n    return {\n        \"status\": True,\n        \"msg_en\": f\"Tagged torrent with {tag}\",\n        \"msg_zh\": f\"已为种子添加标签 {tag}\",\n    }\n\n\n@router.post(\"/torrents/tag/auto\", dependencies=[Depends(get_current_user)])\nasync def auto_tag_torrents():\n    \"\"\"Auto-tag all untagged Bangumi torrents based on name/path matching.\n\n    This helps fix torrents that were added before tagging was implemented.\n    Returns the number of torrents tagged and any that couldn't be matched.\n    \"\"\"\n    tagged_count = 0\n    unmatched = []\n\n    async with DownloadClient() as client:\n        # Get all Bangumi torrents\n        torrents = await client.get_torrent_info(category=\"Bangumi\", status_filter=None)\n\n        with Database() as db:\n            for torrent in torrents:\n                torrent_hash = torrent[\"hash\"]\n                torrent_name = torrent[\"name\"]\n                save_path = torrent[\"save_path\"]\n                tags = torrent.get(\"tags\", \"\")\n\n                # Skip if already has ab: tag\n                if \"ab:\" in tags:\n                    continue\n\n                # Try to match bangumi\n                bangumi = None\n\n                # First try by torrent name\n                bangumi = db.bangumi.match_torrent(torrent_name)\n\n                # Then try by save_path\n                if not bangumi:\n                    bangumi = db.bangumi.match_by_save_path(save_path)\n\n                if bangumi and not bangumi.deleted:\n                    tag = f\"ab:{bangumi.id}\"\n                    await client.add_tag(torrent_hash, tag)\n                    tagged_count += 1\n                    logger.info(\n                        f\"[AutoTag] Tagged '{torrent_name[:50]}...' with {tag} \"\n                        f\"(matched: {bangumi.official_title})\"\n                    )\n                else:\n                    unmatched.append({\n                        \"hash\": torrent_hash,\n                        \"name\": torrent_name,\n                        \"save_path\": save_path,\n                    })\n\n    return {\n        \"status\": True,\n        \"tagged_count\": tagged_count,\n        \"unmatched_count\": len(unmatched),\n        \"unmatched\": unmatched[:10],  # Return first 10 unmatched for debugging\n        \"msg_en\": f\"Tagged {tagged_count} torrents, {len(unmatched)} could not be matched\",\n        \"msg_zh\": f\"已标记 {tagged_count} 个种子，{len(unmatched)} 个无法匹配\",\n    }\n"
  },
  {
    "path": "backend/src/module/api/log.py",
    "content": "from fastapi import APIRouter, Depends, HTTPException, Response, status\nfrom fastapi.responses import JSONResponse\n\nfrom module.conf import LOG_PATH\nfrom module.models import APIResponse\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nrouter = APIRouter(prefix=\"/log\", tags=[\"log\"])\n\n\n_TAIL_BYTES = 512 * 1024  # 512 KB\n\n\n@router.get(\"\", response_model=str, dependencies=[Depends(get_current_user)])\nasync def get_log():\n    if LOG_PATH.exists():\n        with open(LOG_PATH, \"rb\") as f:\n            f.seek(0, 2)\n            size = f.tell()\n            if size > _TAIL_BYTES:\n                f.seek(-_TAIL_BYTES, 2)\n                data = f.read()\n                # Drop first partial line\n                idx = data.find(b\"\\n\")\n                if idx != -1:\n                    data = data[idx + 1 :]\n            else:\n                f.seek(0)\n                data = f.read()\n            return Response(data, media_type=\"text/plain\")\n    else:\n        return Response(\"Log file not found\", status_code=404)\n\n\n@router.get(\n    \"/clear\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def clear_log():\n    if LOG_PATH.exists():\n        LOG_PATH.write_text(\"\")\n        return JSONResponse(\n            status_code=200,\n            content={\"msg_en\": \"Log cleared successfully.\", \"msg_zh\": \"日志清除成功。\"},\n        )\n    else:\n        return JSONResponse(\n            status_code=406,\n            content={\"msg_en\": \"Log file not found.\", \"msg_zh\": \"日志文件未找到。\"},\n        )\n"
  },
  {
    "path": "backend/src/module/api/notification.py",
    "content": "\"\"\"Notification API endpoints.\"\"\"\n\nimport logging\nfrom typing import Optional\n\nfrom fastapi import APIRouter, Depends\nfrom pydantic import BaseModel, Field\n\nfrom module.models.config import NotificationProvider as ProviderConfig\nfrom module.notification import NotificationManager\nfrom module.security.api import get_current_user\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter(prefix=\"/notification\", tags=[\"notification\"])\n\n\nclass TestProviderRequest(BaseModel):\n    \"\"\"Request body for testing a saved provider by index.\"\"\"\n\n    provider_index: int = Field(..., description=\"Index of the provider to test\")\n\n\nclass TestProviderConfigRequest(BaseModel):\n    \"\"\"Request body for testing an unsaved provider configuration.\"\"\"\n\n    type: str = Field(..., description=\"Provider type\")\n    enabled: bool = Field(True, description=\"Whether provider is enabled\")\n    token: Optional[str] = Field(None, description=\"Auth token\")\n    chat_id: Optional[str] = Field(None, description=\"Chat/channel ID\")\n    webhook_url: Optional[str] = Field(None, description=\"Webhook URL\")\n    server_url: Optional[str] = Field(None, description=\"Server URL\")\n    device_key: Optional[str] = Field(None, description=\"Device key\")\n    user_key: Optional[str] = Field(None, description=\"User key\")\n    api_token: Optional[str] = Field(None, description=\"API token\")\n    template: Optional[str] = Field(None, description=\"Custom template\")\n    url: Optional[str] = Field(None, description=\"URL for generic webhook\")\n\n\nclass TestResponse(BaseModel):\n    \"\"\"Response for test notification endpoints.\"\"\"\n\n    success: bool\n    message: str\n    message_zh: str = \"\"\n    message_en: str = \"\"\n\n\n@router.post(\n    \"/test\", response_model=TestResponse, dependencies=[Depends(get_current_user)]\n)\nasync def test_provider(request: TestProviderRequest):\n    \"\"\"Test a configured notification provider by its index.\n\n    Sends a test notification using the provider at the specified index\n    in the current configuration.\n    \"\"\"\n    try:\n        manager = NotificationManager()\n        if request.provider_index >= len(manager):\n            return TestResponse(\n                success=False,\n                message=f\"Invalid provider index: {request.provider_index}\",\n                message_zh=f\"无效的提供者索引: {request.provider_index}\",\n                message_en=f\"Invalid provider index: {request.provider_index}\",\n            )\n\n        success, message = await manager.test_provider(request.provider_index)\n        return TestResponse(\n            success=success,\n            message=message,\n            message_zh=\"测试成功\" if success else f\"测试失败: {message}\",\n            message_en=\"Test successful\" if success else f\"Test failed: {message}\",\n        )\n    except Exception as e:\n        logger.error(f\"Failed to test provider: {e}\")\n        return TestResponse(\n            success=False,\n            message=str(e),\n            message_zh=f\"测试失败: {e}\",\n            message_en=f\"Test failed: {e}\",\n        )\n\n\n@router.post(\n    \"/test-config\",\n    response_model=TestResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def test_provider_config(request: TestProviderConfigRequest):\n    \"\"\"Test an unsaved notification provider configuration.\n\n    Useful for testing a provider before saving it to the configuration.\n    \"\"\"\n    try:\n        # Convert request to ProviderConfig\n        config = ProviderConfig(\n            type=request.type,\n            enabled=request.enabled,\n            token=request.token or \"\",\n            chat_id=request.chat_id or \"\",\n            webhook_url=request.webhook_url or \"\",\n            server_url=request.server_url or \"\",\n            device_key=request.device_key or \"\",\n            user_key=request.user_key or \"\",\n            api_token=request.api_token or \"\",\n            template=request.template,\n            url=request.url or \"\",\n        )\n\n        success, message = await NotificationManager.test_provider_config(config)\n        return TestResponse(\n            success=success,\n            message=message,\n            message_zh=\"测试成功\" if success else f\"测试失败: {message}\",\n            message_en=\"Test successful\" if success else f\"Test failed: {message}\",\n        )\n    except Exception as e:\n        logger.error(f\"Failed to test provider config: {e}\")\n        return TestResponse(\n            success=False,\n            message=str(e),\n            message_zh=f\"测试失败: {e}\",\n            message_en=f\"Test failed: {e}\",\n        )\n"
  },
  {
    "path": "backend/src/module/api/passkey.py",
    "content": "\"\"\"\nPasskey 管理 API\n用于注册、列表、删除 Passkey 凭证\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timedelta\n\nfrom fastapi import APIRouter, Depends, HTTPException, Request\nfrom fastapi.responses import JSONResponse, Response\nfrom sqlmodel import select\n\nfrom module.database.engine import async_session_factory\nfrom module.database.passkey import PasskeyDatabase\nfrom module.models import APIResponse\nfrom module.models.passkey import (\n    PasskeyAuthFinish,\n    PasskeyAuthStart,\n    PasskeyCreate,\n    PasskeyDelete,\n    PasskeyList,\n)\nfrom module.models.user import User\nfrom module.security.api import active_user, get_current_user\nfrom module.security.auth_strategy import PasskeyAuthStrategy\nfrom module.security.jwt import create_access_token\nfrom module.security.webauthn import get_webauthn_service\n\nlogger = logging.getLogger(__name__)\nrouter = APIRouter(prefix=\"/passkey\", tags=[\"passkey\"])\n\n\ndef _get_webauthn_from_request(request: Request):\n    \"\"\"\n    从请求中构造 WebAuthnService\n    优先使用浏览器的 Origin header（与 clientDataJSON 中的 origin 一致）\n    \"\"\"\n    from urllib.parse import urlparse\n\n    origin = request.headers.get(\"origin\")\n    if not origin:\n        # Fallback: 从 Referer 或 Host 推断\n        referer = request.headers.get(\"referer\", \"\")\n        if referer:\n            parsed = urlparse(referer)\n            origin = f\"{parsed.scheme}://{parsed.netloc}\"\n        else:\n            host = request.headers.get(\"host\", \"localhost:7892\")\n            forwarded_proto = request.headers.get(\"x-forwarded-proto\")\n            scheme = forwarded_proto if forwarded_proto else request.url.scheme\n            origin = f\"{scheme}://{host}\"\n\n    parsed_origin = urlparse(origin)\n    rp_id = parsed_origin.hostname or \"localhost\"\n\n    return get_webauthn_service(rp_id, \"AutoBangumi\", origin)\n\n\n# ============ 注册流程 ============\n\n\n@router.post(\"/register/options\", response_model=dict)\nasync def get_registration_options(\n    request: Request,\n    username: str = Depends(get_current_user),\n):\n    \"\"\"\n    生成 Passkey 注册选项\n    前端调用 navigator.credentials.create() 时使用\n    \"\"\"\n    webauthn = _get_webauthn_from_request(request)\n\n    async with async_session_factory() as session:\n        try:\n            # Get user\n            result = await session.execute(\n                select(User).where(User.username == username)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                raise HTTPException(status_code=404, detail=\"User not found\")\n\n            # Get existing passkeys\n            passkey_db = PasskeyDatabase(session)\n            existing_passkeys = await passkey_db.get_passkeys_by_user_id(user.id)\n\n            options = webauthn.generate_registration_options(\n                username=username,\n                user_id=user.id,\n                existing_passkeys=existing_passkeys,\n            )\n\n            return options\n\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Failed to generate registration options: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n\n@router.post(\"/register/verify\", response_model=APIResponse)\nasync def verify_registration(\n    passkey_data: PasskeyCreate,\n    request: Request,\n    username: str = Depends(get_current_user),\n):\n    \"\"\"\n    验证 Passkey 注册响应并保存\n    \"\"\"\n    webauthn = _get_webauthn_from_request(request)\n\n    async with async_session_factory() as session:\n        try:\n            # Get user\n            result = await session.execute(\n                select(User).where(User.username == username)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                raise HTTPException(status_code=404, detail=\"User not found\")\n\n            # 验证 WebAuthn 响应\n            passkey = webauthn.verify_registration(\n                username=username,\n                credential=passkey_data.attestation_response,\n                device_name=passkey_data.name,\n            )\n\n            # 设置 user_id 并保存\n            passkey.user_id = user.id\n            passkey_db = PasskeyDatabase(session)\n            await passkey_db.create_passkey(passkey)\n\n            return JSONResponse(\n                status_code=200,\n                content={\n                    \"msg_en\": f\"Passkey '{passkey_data.name}' registered successfully\",\n                    \"msg_zh\": f\"Passkey '{passkey_data.name}' 注册成功\",\n                },\n            )\n\n        except ValueError as e:\n            logger.warning(f\"Registration verification failed for {username}: {e}\")\n            raise HTTPException(status_code=400, detail=str(e))\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Failed to register passkey: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n\n# ============ 认证流程 ============\n\n\n@router.post(\"/auth/options\", response_model=dict)\nasync def get_passkey_login_options(\n    auth_data: PasskeyAuthStart,\n    request: Request,\n):\n    \"\"\"\n    生成 Passkey 登录选项（challenge）\n    前端先调用此接口，再调用 navigator.credentials.get()\n\n    如果提供 username，返回该用户的 passkey 列表（allowCredentials）\n    如果不提供 username，返回可发现凭证选项（浏览器显示所有可用 passkey）\n    \"\"\"\n    webauthn = _get_webauthn_from_request(request)\n\n    # Discoverable credentials mode (no username)\n    if not auth_data.username:\n        try:\n            options = webauthn.generate_discoverable_authentication_options()\n            return options\n        except Exception as e:\n            logger.error(f\"Failed to generate discoverable login options: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n    # Username-based mode\n    async with async_session_factory() as session:\n        try:\n            # Get user\n            result = await session.execute(\n                select(User).where(User.username == auth_data.username)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                raise HTTPException(status_code=404, detail=\"User not found\")\n\n            passkey_db = PasskeyDatabase(session)\n            passkeys = await passkey_db.get_passkeys_by_user_id(user.id)\n\n            if not passkeys:\n                raise HTTPException(\n                    status_code=400, detail=\"No passkeys registered for this user\"\n                )\n\n            options = webauthn.generate_authentication_options(\n                auth_data.username, passkeys\n            )\n            return options\n\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Failed to generate login options: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n\n@router.post(\"/auth/verify\", response_model=dict)\nasync def login_with_passkey(\n    auth_data: PasskeyAuthFinish,\n    response: Response,\n    request: Request,\n):\n    \"\"\"\n    使用 Passkey 登录（替代密码登录）\n\n    如果提供 username，验证 passkey 属于该用户\n    如果不提供 username（可发现凭证模式），从 credential 中提取用户信息\n    \"\"\"\n    webauthn = _get_webauthn_from_request(request)\n\n    strategy = PasskeyAuthStrategy(webauthn)\n    resp = await strategy.authenticate(auth_data.username, auth_data.credential)\n\n    if resp.status:\n        # Get username from response (may be discovered from credential)\n        username = resp.data.get(\"username\") if resp.data else auth_data.username\n        if not username:\n            raise HTTPException(status_code=500, detail=\"Failed to determine username\")\n\n        token = create_access_token(\n            data={\"sub\": username}, expires_delta=timedelta(days=1)\n        )\n        response.set_cookie(key=\"token\", value=token, httponly=True, max_age=86400)\n        active_user[username] = datetime.now()\n        return {\"access_token\": token, \"token_type\": \"bearer\"}\n\n    raise HTTPException(status_code=resp.status_code, detail=resp.msg_en)\n\n\n# ============ Passkey 管理 ============\n\n\n@router.get(\"/list\", response_model=list[PasskeyList])\nasync def list_passkeys(username: str = Depends(get_current_user)):\n    \"\"\"获取用户的所有 Passkey\"\"\"\n    async with async_session_factory() as session:\n        try:\n            # Get user\n            result = await session.execute(\n                select(User).where(User.username == username)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                raise HTTPException(status_code=404, detail=\"User not found\")\n\n            passkey_db = PasskeyDatabase(session)\n            passkeys = await passkey_db.get_passkeys_by_user_id(user.id)\n\n            return [passkey_db.to_list_model(pk) for pk in passkeys]\n\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Failed to list passkeys: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n\n\n@router.post(\"/delete\", response_model=APIResponse)\nasync def delete_passkey(\n    delete_data: PasskeyDelete,\n    username: str = Depends(get_current_user),\n):\n    \"\"\"删除 Passkey\"\"\"\n    async with async_session_factory() as session:\n        try:\n            # Get user\n            result = await session.execute(\n                select(User).where(User.username == username)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                raise HTTPException(status_code=404, detail=\"User not found\")\n\n            passkey_db = PasskeyDatabase(session)\n            await passkey_db.delete_passkey(delete_data.passkey_id, user.id)\n\n            return JSONResponse(\n                status_code=200,\n                content={\n                    \"msg_en\": \"Passkey deleted successfully\",\n                    \"msg_zh\": \"Passkey 删除成功\",\n                },\n            )\n\n        except HTTPException:\n            raise\n        except Exception as e:\n            logger.error(f\"Failed to delete passkey: {e}\")\n            raise HTTPException(status_code=500, detail=str(e))\n"
  },
  {
    "path": "backend/src/module/api/program.py",
    "content": "import logging\nimport os\nimport signal\n\nfrom fastapi import APIRouter, Depends, HTTPException\nfrom fastapi.responses import JSONResponse\n\nfrom module.conf import VERSION\nfrom module.core import Program\nfrom module.models import APIResponse\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nfrom .response import u_response\n\nlogger = logging.getLogger(__name__)\nprogram = Program()\nrouter = APIRouter(tags=[\"program\"])\n\n\n# Note: Lifespan events (startup/shutdown) are now handled in main.py via lifespan context manager\n\n\n@router.get(\n    \"/restart\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def restart():\n    try:\n        resp = await program.restart()\n        return u_response(resp)\n    except Exception as e:\n        logger.debug(e)\n        logger.warning(\"Failed to restart program\")\n        raise HTTPException(\n            status_code=500,\n            detail={\n                \"msg_en\": \"Failed to restart program.\",\n                \"msg_zh\": \"重启程序失败。\",\n            },\n        )\n\n\n@router.get(\n    \"/start\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def start():\n    try:\n        resp = await program.start()\n        return u_response(resp)\n    except Exception as e:\n        logger.debug(e)\n        logger.warning(\"Failed to start program\")\n        raise HTTPException(\n            status_code=500,\n            detail={\n                \"msg_en\": \"Failed to start program.\",\n                \"msg_zh\": \"启动程序失败。\",\n            },\n        )\n\n\n@router.get(\n    \"/stop\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def stop():\n    resp = await program.stop()\n    return u_response(resp)\n\n\n@router.get(\"/status\", response_model=dict, dependencies=[Depends(get_current_user)])\nasync def program_status():\n    if not program.is_running:\n        return {\n            \"status\": False,\n            \"version\": VERSION,\n            \"first_run\": program.first_run,\n        }\n    else:\n        return {\n            \"status\": True,\n            \"version\": VERSION,\n            \"first_run\": program.first_run,\n        }\n\n\n@router.get(\n    \"/shutdown\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def shutdown_program():\n    await program.stop()\n    logger.info(\"Shutting down program...\")\n    os.kill(os.getpid(), signal.SIGINT)\n    return JSONResponse(\n        status_code=200,\n        content={\n            \"msg_en\": \"Shutdown program successfully.\",\n            \"msg_zh\": \"关闭程序成功。\",\n        },\n    )\n\n\n# Check status\n@router.get(\n    \"/check/downloader\",\n    tags=[\"check\"],\n    response_model=bool,\n    dependencies=[Depends(get_current_user)],\n)\nasync def check_downloader_status():\n    return await program.check_downloader()\n"
  },
  {
    "path": "backend/src/module/api/response.py",
    "content": "from fastapi.exceptions import HTTPException\nfrom fastapi.responses import JSONResponse\n\nfrom module.models.response import ResponseModel\n\n\ndef u_response(response_model: ResponseModel):\n    return JSONResponse(\n        status_code=response_model.status_code,\n        content={\n            \"msg_en\": response_model.msg_en,\n            \"msg_zh\": response_model.msg_zh,\n        },\n    )\n"
  },
  {
    "path": "backend/src/module/api/rss.py",
    "content": "from fastapi import APIRouter, Depends\nfrom fastapi.responses import JSONResponse\n\nfrom module.downloader import DownloadClient\nfrom module.manager import SeasonCollector\nfrom module.models import APIResponse, Bangumi, RSSItem, RSSUpdate, Torrent\nfrom module.rss import RSSAnalyser, RSSEngine\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nfrom .response import u_response\n\nrouter = APIRouter(prefix=\"/rss\", tags=[\"rss\"])\n\n\n@router.get(\n    path=\"\", response_model=list[RSSItem], dependencies=[Depends(get_current_user)]\n)\nasync def get_rss():\n    with RSSEngine() as engine:\n        return engine.rss.search_all()\n\n\n@router.post(\n    path=\"/add\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def add_rss(rss: RSSItem):\n    with RSSEngine() as engine:\n        result = await engine.add_rss(rss.url, rss.name, rss.aggregate, rss.parser)\n    return u_response(result)\n\n\n@router.post(\n    path=\"/enable/many\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def enable_many_rss(\n    rss_ids: list[int],\n):\n    with RSSEngine() as engine:\n        result = engine.enable_list(rss_ids)\n    return u_response(result)\n\n\n@router.delete(\n    path=\"/delete/{rss_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def delete_rss(rss_id: int):\n    with RSSEngine() as engine:\n        if engine.rss.delete(rss_id):\n            return JSONResponse(\n                status_code=200,\n                content={\"msg_en\": \"Delete RSS successfully.\", \"msg_zh\": \"删除 RSS 成功。\"},\n            )\n        else:\n            return JSONResponse(\n                status_code=406,\n                content={\"msg_en\": \"Delete RSS failed.\", \"msg_zh\": \"删除 RSS 失败。\"},\n            )\n\n\n@router.post(\n    path=\"/delete/many\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def delete_many_rss(\n    rss_ids: list[int],\n):\n    with RSSEngine() as engine:\n        result = engine.delete_list(rss_ids)\n    return u_response(result)\n\n\n@router.patch(\n    path=\"/disable/{rss_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def disable_rss(rss_id: int):\n    with RSSEngine() as engine:\n        if engine.rss.disable(rss_id):\n            return JSONResponse(\n                status_code=200,\n                content={\"msg_en\": \"Disable RSS successfully.\", \"msg_zh\": \"禁用 RSS 成功。\"},\n            )\n        else:\n            return JSONResponse(\n                status_code=406,\n                content={\"msg_en\": \"Disable RSS failed.\", \"msg_zh\": \"禁用 RSS 失败。\"},\n            )\n\n\n@router.post(\n    path=\"/disable/many\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def disable_many_rss(rss_ids: list[int]):\n    with RSSEngine() as engine:\n        result = engine.disable_list(rss_ids)\n    return u_response(result)\n\n\n@router.patch(\n    path=\"/update/{rss_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def update_rss(\n    rss_id: int, data: RSSUpdate, current_user=Depends(get_current_user)\n):\n    if not current_user:\n        raise UNAUTHORIZED\n    with RSSEngine() as engine:\n        if engine.rss.update(rss_id, data):\n            return JSONResponse(\n                status_code=200,\n                content={\"msg_en\": \"Update RSS successfully.\", \"msg_zh\": \"更新 RSS 成功。\"},\n            )\n        else:\n            return JSONResponse(\n                status_code=406,\n                content={\"msg_en\": \"Update RSS failed.\", \"msg_zh\": \"更新 RSS 失败。\"},\n            )\n\n\n@router.get(\n    path=\"/refresh/all\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_all():\n    async with DownloadClient() as client:\n        with RSSEngine() as engine:\n            await engine.refresh_rss(client)\n    return JSONResponse(\n        status_code=200,\n        content={\"msg_en\": \"Refresh all RSS successfully.\", \"msg_zh\": \"刷新 RSS 成功。\"},\n    )\n\n\n@router.get(\n    path=\"/refresh/{rss_id}\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def refresh_rss(rss_id: int):\n    async with DownloadClient() as client:\n        with RSSEngine() as engine:\n            await engine.refresh_rss(client, rss_id)\n    return JSONResponse(\n        status_code=200,\n        content={\"msg_en\": \"Refresh RSS successfully.\", \"msg_zh\": \"刷新 RSS 成功。\"},\n    )\n\n\n@router.get(\n    path=\"/torrent/{rss_id}\",\n    response_model=list[Torrent],\n    dependencies=[Depends(get_current_user)],\n)\nasync def get_torrent(\n    rss_id: int,\n):\n    with RSSEngine() as engine:\n        return engine.get_rss_torrents(rss_id)\n\n\n# Old API\nanalyser = RSSAnalyser()\n\n\n@router.post(\n    \"/analysis\", response_model=Bangumi, dependencies=[Depends(get_current_user)]\n)\nasync def analysis(rss: RSSItem):\n    data = await analyser.link_to_data(rss)\n    if isinstance(data, Bangumi):\n        return data\n    else:\n        return u_response(data)\n\n\n@router.post(\n    \"/collect\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def download_collection(data: Bangumi):\n    async with SeasonCollector() as collector:\n        resp = await collector.collect_season(data, data.rss_link)\n        return u_response(resp)\n\n\n@router.post(\n    \"/subscribe\", response_model=APIResponse, dependencies=[Depends(get_current_user)]\n)\nasync def subscribe(data: Bangumi, rss: RSSItem):\n    resp = await SeasonCollector.subscribe_season(data, parser=rss.parser)\n    return u_response(resp)\n"
  },
  {
    "path": "backend/src/module/api/search.py",
    "content": "from fastapi import APIRouter, Depends, Query\nfrom sse_starlette.sse import EventSourceResponse\n\nfrom module.conf.search_provider import get_provider, save_provider\nfrom module.models import Bangumi\nfrom module.searcher import SEARCH_CONFIG, SearchTorrent\nfrom module.security.api import UNAUTHORIZED, get_current_user\n\nrouter = APIRouter(prefix=\"/search\", tags=[\"search\"])\n\n\n@router.get(\n    \"/bangumi\", response_model=list[Bangumi], dependencies=[Depends(get_current_user)]\n)\nasync def search_torrents(site: str = \"mikan\", keywords: str = Query(None)):\n    \"\"\"\n    Server Send Event for per Bangumi item\n    \"\"\"\n    if not keywords:\n        return []\n    keywords = keywords.split(\" \")\n\n    async def event_generator():\n        async with SearchTorrent() as st:\n            async for item in st.analyse_keyword(keywords=keywords, site=site):\n                yield item\n\n    return EventSourceResponse(content=event_generator())\n\n\n@router.get(\n    \"/provider\", response_model=list[str], dependencies=[Depends(get_current_user)]\n)\nasync def search_provider():\n    return list(SEARCH_CONFIG.keys())\n\n\n@router.get(\n    \"/provider/config\",\n    response_model=dict[str, str],\n    dependencies=[Depends(get_current_user)],\n)\nasync def get_search_provider_config():\n    \"\"\"Get all search providers with their URL templates.\"\"\"\n    return get_provider()\n\n\n@router.put(\n    \"/provider/config\",\n    response_model=dict[str, str],\n    dependencies=[Depends(get_current_user)],\n)\nasync def update_search_provider_config(providers: dict[str, str]):\n    \"\"\"Update search providers configuration.\"\"\"\n    save_provider(providers)\n    return get_provider()\n"
  },
  {
    "path": "backend/src/module/api/setup.py",
    "content": "import ipaddress\nimport logging\nimport socket\nfrom pathlib import Path\nfrom urllib.parse import urlparse\n\nimport httpx\nfrom fastapi import APIRouter, HTTPException\nfrom pydantic import BaseModel, Field\n\nfrom module.conf import VERSION, settings\nfrom module.models import Config, ResponseModel\nfrom module.models.config import NotificationProvider as ProviderConfig\nfrom module.network import RequestContent\nfrom module.notification import PROVIDER_REGISTRY\nfrom module.security.jwt import get_password_hash\n\nlogger = logging.getLogger(__name__)\n\nrouter = APIRouter(prefix=\"/setup\", tags=[\"setup\"])\n\nSENTINEL_PATH = Path(\"config/.setup_complete\")\n\n\ndef _require_setup_needed():\n    \"\"\"Guard: raise 403 if setup is already completed.\"\"\"\n    if SENTINEL_PATH.exists():\n        raise HTTPException(status_code=403, detail=\"Setup already completed.\")\n    # Allow setup in dev mode even if settings differ\n    if VERSION != \"DEV_VERSION\" and settings.dict() != Config().dict():\n        raise HTTPException(status_code=403, detail=\"Setup already completed.\")\n\n\ndef _validate_url(url: str) -> None:\n    \"\"\"Reject non-HTTP schemes and private/reserved/loopback IPs.\"\"\"\n    parsed = urlparse(url)\n    if parsed.scheme not in (\"http\", \"https\"):\n        raise HTTPException(status_code=400, detail=\"Only http/https URLs are allowed.\")\n    hostname = parsed.hostname\n    if not hostname:\n        raise HTTPException(status_code=400, detail=\"Invalid URL: no hostname.\")\n    try:\n        addrs = socket.getaddrinfo(hostname, None)\n    except socket.gaierror:\n        raise HTTPException(status_code=400, detail=\"Cannot resolve hostname.\")\n    for family, _, _, _, sockaddr in addrs:\n        ip = ipaddress.ip_address(sockaddr[0])\n        if ip.is_private or ip.is_reserved or ip.is_loopback:\n            raise HTTPException(\n                status_code=400,\n                detail=\"URLs pointing to private/reserved IPs are not allowed.\",\n            )\n\n\n# --- Request/Response Models ---\n\n\nclass SetupStatusResponse(BaseModel):\n    need_setup: bool\n    version: str\n\n\nclass TestDownloaderRequest(BaseModel):\n    type: str = Field(\"qbittorrent\")\n    host: str\n    username: str\n    password: str\n    ssl: bool = False\n\n\nclass TestRSSRequest(BaseModel):\n    url: str\n\n\nclass TestNotificationRequest(BaseModel):\n    type: str\n    token: str\n    chat_id: str = \"\"\n\n\nclass TestResultResponse(BaseModel):\n    success: bool\n    message_en: str\n    message_zh: str\n    title: str | None = None\n    item_count: int | None = None\n\n\nclass SetupCompleteRequest(BaseModel):\n    username: str = Field(..., min_length=4, max_length=20)\n    password: str = Field(..., min_length=8)\n    downloader_type: str = Field(\"qbittorrent\")\n    downloader_host: str\n    downloader_username: str\n    downloader_password: str\n    downloader_path: str = Field(\"/downloads/Bangumi\")\n    downloader_ssl: bool = False\n    rss_url: str = \"\"\n    rss_name: str = \"\"\n    notification_enable: bool = False\n    notification_type: str = \"telegram\"\n    notification_token: str = \"\"\n    notification_chat_id: str = \"\"\n\n\n# --- Endpoints ---\n\n\n@router.get(\"/status\", response_model=SetupStatusResponse)\nasync def get_setup_status():\n    \"\"\"Check whether the setup wizard is needed.\"\"\"\n    # In dev mode, always allow setup wizard for testing\n    if VERSION == \"DEV_VERSION\":\n        need_setup = not SENTINEL_PATH.exists()\n    else:\n        need_setup = not SENTINEL_PATH.exists() and settings.dict() == Config().dict()\n    return SetupStatusResponse(need_setup=need_setup, version=VERSION)\n\n\n@router.post(\"/test-downloader\", response_model=TestResultResponse)\nasync def test_downloader(req: TestDownloaderRequest):\n    \"\"\"Test connection to the download client.\"\"\"\n    _require_setup_needed()\n\n    # Support mock mode for development\n    if req.type == \"mock\":\n        return TestResultResponse(\n            success=True,\n            message_en=\"Mock downloader enabled.\",\n            message_zh=\"已启用模拟下载器。\",\n        )\n\n    scheme = \"https\" if req.ssl else \"http\"\n    host = req.host if \"://\" in req.host else f\"{scheme}://{req.host}\"\n\n    try:\n        async with httpx.AsyncClient(timeout=5.0) as client:\n            # Check if host is reachable and is qBittorrent\n            resp = await client.get(host)\n            if (\n                \"qbittorrent\" not in resp.text.lower()\n                and \"vuetorrent\" not in resp.text.lower()\n            ):\n                return TestResultResponse(\n                    success=False,\n                    message_en=\"Host is reachable but does not appear to be qBittorrent.\",\n                    message_zh=\"主机可达但似乎不是 qBittorrent。\",\n                )\n\n            # Try to authenticate\n            login_url = f\"{host}/api/v2/auth/login\"\n            login_resp = await client.post(\n                login_url,\n                data={\"username\": req.username, \"password\": req.password},\n            )\n            if login_resp.status_code == 200 and \"ok\" in login_resp.text.lower():\n                return TestResultResponse(\n                    success=True,\n                    message_en=\"Connection successful.\",\n                    message_zh=\"连接成功。\",\n                )\n            elif login_resp.status_code == 403:\n                return TestResultResponse(\n                    success=False,\n                    message_en=\"Authentication failed: IP is banned by qBittorrent.\",\n                    message_zh=\"认证失败：IP 被 qBittorrent 封禁。\",\n                )\n            else:\n                return TestResultResponse(\n                    success=False,\n                    message_en=\"Authentication failed: incorrect username or password.\",\n                    message_zh=\"认证失败：用户名或密码错误。\",\n                )\n    except httpx.TimeoutException:\n        return TestResultResponse(\n            success=False,\n            message_en=\"Connection timed out.\",\n            message_zh=\"连接超时。\",\n        )\n    except httpx.ConnectError:\n        return TestResultResponse(\n            success=False,\n            message_en=\"Cannot connect to the host.\",\n            message_zh=\"无法连接到主机。\",\n        )\n    except Exception as e:\n        logger.error(f\"[Setup] Downloader test failed: {e}\")\n        return TestResultResponse(\n            success=False,\n            message_en=f\"Connection failed: {e}\",\n            message_zh=f\"连接失败：{e}\",\n        )\n\n\n@router.post(\"/test-rss\", response_model=TestResultResponse)\nasync def test_rss(req: TestRSSRequest):\n    \"\"\"Test an RSS feed URL.\"\"\"\n    _require_setup_needed()\n    _validate_url(req.url)\n\n    try:\n        async with RequestContent() as request:\n            soup = await request.get_xml(req.url)\n            if soup is None:\n                return TestResultResponse(\n                    success=False,\n                    message_en=\"Failed to fetch or parse the RSS feed.\",\n                    message_zh=\"无法获取或解析 RSS 源。\",\n                )\n            title = soup.find(\"./channel/title\")\n            title_text = title.text if title is not None else None\n            items = soup.findall(\"./channel/item\")\n            return TestResultResponse(\n                success=True,\n                message_en=\"RSS feed is valid.\",\n                message_zh=\"RSS 源有效。\",\n                title=title_text,\n                item_count=len(items),\n            )\n    except Exception as e:\n        logger.error(f\"[Setup] RSS test failed: {e}\")\n        return TestResultResponse(\n            success=False,\n            message_en=f\"Failed to fetch RSS feed: {e}\",\n            message_zh=f\"获取 RSS 源失败：{e}\",\n        )\n\n\n@router.post(\"/test-notification\", response_model=TestResultResponse)\nasync def test_notification(req: TestNotificationRequest):\n    \"\"\"Send a test notification.\"\"\"\n    _require_setup_needed()\n\n    provider_cls = PROVIDER_REGISTRY.get(req.type.lower())\n    if provider_cls is None:\n        return TestResultResponse(\n            success=False,\n            message_en=f\"Unknown notification type: {req.type}\",\n            message_zh=f\"未知的通知类型：{req.type}\",\n        )\n\n    try:\n        # Create provider config\n        config = ProviderConfig(\n            type=req.type,\n            enabled=True,\n            token=req.token,\n            chat_id=req.chat_id,\n        )\n        provider = provider_cls(config)\n        async with provider:\n            success, message = await provider.test()\n            if success:\n                return TestResultResponse(\n                    success=True,\n                    message_en=\"Test notification sent successfully.\",\n                    message_zh=\"测试通知发送成功。\",\n                )\n            else:\n                return TestResultResponse(\n                    success=False,\n                    message_en=f\"Failed to send test notification: {message}\",\n                    message_zh=f\"测试通知发送失败：{message}\",\n                )\n    except Exception as e:\n        logger.error(f\"[Setup] Notification test failed: {e}\")\n        return TestResultResponse(\n            success=False,\n            message_en=f\"Notification test failed: {e}\",\n            message_zh=f\"通知测试失败：{e}\",\n        )\n\n\n@router.post(\"/complete\", response_model=ResponseModel)\nasync def complete_setup(req: SetupCompleteRequest):\n    \"\"\"Save all wizard configuration and mark setup as complete.\"\"\"\n    _require_setup_needed()\n\n    try:\n        # 1. Update user credentials\n        from module.database import Database\n\n        with Database() as db:\n            from module.models.user import UserUpdate\n\n            db.user.update_user(\n                \"admin\",\n                UserUpdate(username=req.username, password=req.password),\n            )\n\n        # 2. Update configuration\n        config_dict = settings.dict()\n        config_dict[\"downloader\"] = {\n            \"type\": req.downloader_type,\n            \"host\": req.downloader_host,\n            \"username\": req.downloader_username,\n            \"password\": req.downloader_password,\n            \"path\": req.downloader_path,\n            \"ssl\": req.downloader_ssl,\n        }\n        if req.notification_enable:\n            config_dict[\"notification\"] = {\n                \"enable\": True,\n                \"providers\": [\n                    {\n                        \"type\": req.notification_type,\n                        \"enabled\": True,\n                        \"token\": req.notification_token,\n                        \"chat_id\": req.notification_chat_id,\n                    }\n                ],\n            }\n\n        settings.save(config_dict)\n        # Reload settings in-place\n        config_obj = Config.parse_obj(config_dict)\n        settings.__dict__.update(config_obj.__dict__)\n\n        # 3. Add RSS feed if provided\n        if req.rss_url:\n            from module.rss import RSSEngine\n\n            with RSSEngine() as rss_engine:\n                await rss_engine.add_rss(req.rss_url, name=req.rss_name or None)\n\n        # 4. Create sentinel file\n        SENTINEL_PATH.parent.mkdir(parents=True, exist_ok=True)\n        SENTINEL_PATH.touch()\n\n        return ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"Setup completed successfully.\",\n            msg_zh=\"设置完成。\",\n        )\n    except Exception as e:\n        logger.error(f\"[Setup] Complete failed: {e}\")\n        return ResponseModel(\n            status=False,\n            status_code=500,\n            msg_en=f\"Setup failed: {e}\",\n            msg_zh=f\"设置失败：{e}\",\n        )\n"
  },
  {
    "path": "backend/src/module/checker/__init__.py",
    "content": "from .checker import Checker\n"
  },
  {
    "path": "backend/src/module/checker/checker.py",
    "content": "import logging\nfrom pathlib import Path\n\nimport httpx\n\nfrom module.conf import VERSION, settings\nfrom module.models import Config\nfrom module.update import version_check\n\nlogger = logging.getLogger(__name__)\n\n\n_default_config_dict: dict | None = None\n\n\ndef _get_default_config_dict() -> dict:\n    global _default_config_dict\n    if _default_config_dict is None:\n        _default_config_dict = Config().dict()\n    return _default_config_dict\n\n\nclass Checker:\n    def __init__(self):\n        pass\n\n    @staticmethod\n    def check_renamer() -> bool:\n        if settings.bangumi_manage.enable:\n            return True\n        else:\n            return False\n\n    @staticmethod\n    def check_analyser() -> bool:\n        if settings.rss_parser.enable:\n            return True\n        else:\n            return False\n\n    @staticmethod\n    def check_first_run() -> bool:\n        if Path(\"config/.setup_complete\").exists():\n            return False\n        return settings.dict() == _get_default_config_dict()\n\n    @staticmethod\n    def check_version() -> tuple[bool, int | None]:\n        return version_check()\n\n    @staticmethod\n    def check_database() -> bool:\n        db_path = Path(\"data/data.db\")\n        if not db_path.exists():\n            return False\n        else:\n            return True\n\n    @staticmethod\n    async def check_downloader() -> bool:\n        from module.downloader import DownloadClient\n\n        # Mock downloader always succeeds\n        if settings.downloader.type == \"mock\":\n            logger.info(\"[Checker] Using MockDownloader - skipping connection check\")\n            return True\n\n        try:\n            url = (\n                f\"http://{settings.downloader.host}\"\n                if \"://\" not in settings.downloader.host\n                else f\"{settings.downloader.host}\"\n            )\n            async with httpx.AsyncClient(timeout=2.0) as client:\n                response = await client.get(url)\n            if \"qbittorrent\" in response.text.lower() or \"vuetorrent\" in response.text.lower():\n                async with DownloadClient() as dl_client:\n                    if dl_client.authed:\n                        return True\n                    else:\n                        return False\n            else:\n                return False\n        except httpx.TimeoutException:\n            logger.error(\"[Checker] Downloader connect timeout.\")\n            return False\n        except httpx.ConnectError:\n            logger.error(\"[Checker] Downloader connect failed.\")\n            return False\n        except Exception as e:\n            logger.error(f\"[Checker] Downloader connect failed: {e}\")\n            return False\n\n    @staticmethod\n    def check_img_cache() -> bool:\n        img_path = Path(\"data/posters\")\n        if img_path.exists():\n            return True\n        else:\n            img_path.mkdir()\n            return False\n"
  },
  {
    "path": "backend/src/module/conf/__init__.py",
    "content": "import sys\nfrom pathlib import Path\n\nfrom .config import VERSION, settings\nfrom .log import LOG_PATH, setup_logger\nfrom .search_provider import SEARCH_CONFIG\n\nTMDB_API = \"32b19d6a05b512190a056fa4e747cbbc\"\nDATA_PATH = \"sqlite:///data/data.db\"\nLEGACY_DATA_PATH = Path(\"data/data.json\")\nVERSION_PATH = Path(\"config/version.info\")\nPOSTERS_PATH = Path(\"data/posters\")\n\nPLATFORM = \"Windows\" if sys.platform == \"win32\" else \"Unix\"\n"
  },
  {
    "path": "backend/src/module/conf/config.py",
    "content": "import json\nimport logging\nimport os\nfrom pathlib import Path\n\nfrom dotenv import load_dotenv\n\nfrom module.models.config import Config\n\nfrom .const import DEFAULT_SETTINGS, ENV_TO_ATTR\n\nlogger = logging.getLogger(__name__)\nCONFIG_ROOT = Path(\"config\")\n\n\ntry:\n    from module.__version__ import VERSION\nexcept ImportError:\n    logger.info(\"Can't find version info, use DEV_VERSION instead\")\n    VERSION = \"DEV_VERSION\"\n\nCONFIG_PATH = (\n    CONFIG_ROOT / \"config_dev.json\"\n    if VERSION == \"DEV_VERSION\"\n    else CONFIG_ROOT / \"config.json\"\n).resolve()\n\n\nclass Settings(Config):\n    \"\"\"Runtime configuration singleton.\n\n    On construction, loads from ``CONFIG_PATH`` if the file exists (and\n    immediately re-saves to apply any migrations), otherwise bootstraps\n    defaults from environment variables via ``init()``.\n\n    Use ``settings`` module-level instance rather than instantiating directly.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        if CONFIG_PATH.exists():\n            self.load()\n            self.save()\n        else:\n            self.init()\n\n    def load(self):\n        \"\"\"Load and validate configuration from ``CONFIG_PATH``, applying migrations.\"\"\"\n        with open(CONFIG_PATH, \"r\", encoding=\"utf-8\") as f:\n            config = json.load(f)\n        config = self._migrate_old_config(config)\n        config_obj = Config.model_validate(config)\n        self.__dict__.update(config_obj.__dict__)\n        logger.info(\"Config loaded\")\n\n    @staticmethod\n    def _migrate_old_config(config: dict) -> dict:\n        \"\"\"Migrate old config field names (3.1.x) to current format (3.2.x).\"\"\"\n        program = config.get(\"program\", {})\n        # Rename sleep_time -> rss_time\n        if \"sleep_time\" in program and \"rss_time\" not in program:\n            program[\"rss_time\"] = program.pop(\"sleep_time\")\n        elif \"sleep_time\" in program:\n            program.pop(\"sleep_time\")\n        # Rename times -> rename_time\n        if \"times\" in program and \"rename_time\" not in program:\n            program[\"rename_time\"] = program.pop(\"times\")\n        elif \"times\" in program:\n            program.pop(\"times\")\n        # Remove deprecated data_version field\n        program.pop(\"data_version\", None)\n\n        # Remove deprecated rss_parser fields\n        rss_parser = config.get(\"rss_parser\", {})\n        for key in (\"type\", \"custom_url\", \"token\", \"enable_tmdb\"):\n            rss_parser.pop(key, None)\n\n        # Add security section if missing (preserves local-network MCP default)\n        if \"security\" not in config:\n            config[\"security\"] = DEFAULT_SETTINGS[\"security\"]\n\n        return config\n\n    def save(self, config_dict: dict | None = None):\n        \"\"\"Write configuration to ``CONFIG_PATH``. Uses current state when no dict supplied.\"\"\"\n        if not config_dict:\n            config_dict = self.model_dump()\n        with open(CONFIG_PATH, \"w\", encoding=\"utf-8\") as f:\n            json.dump(config_dict, f, indent=4, ensure_ascii=False)\n\n    def init(self):\n        \"\"\"Bootstrap a new config file from ``.env`` and environment variables.\"\"\"\n        load_dotenv(\".env\")\n        self.__load_from_env()\n        self.save()\n\n    def __load_from_env(self):\n        \"\"\"Apply ``ENV_TO_ATTR`` mappings from the process environment to the config dict.\"\"\"\n        config_dict = self.model_dump()\n        for key, section in ENV_TO_ATTR.items():\n            for env, attr in section.items():\n                if env in os.environ:\n                    if isinstance(attr, list):\n                        for _attr in attr:\n                            attr_name = _attr[0] if isinstance(_attr, tuple) else _attr\n                            config_dict[key][attr_name] = self.__val_from_env(\n                                env, _attr\n                            )\n                    else:\n                        attr_name = attr[0] if isinstance(attr, tuple) else attr\n                        config_dict[key][attr_name] = self.__val_from_env(env, attr)\n        config_obj = Config.model_validate(config_dict)\n        self.__dict__.update(config_obj.__dict__)\n        logger.info(\"Config loaded from env\")\n\n    @staticmethod\n    def __val_from_env(env: str, attr: tuple | str):\n        \"\"\"Return the environment variable value, applying the converter when attr is a tuple.\"\"\"\n        if isinstance(attr, tuple):\n            return attr[1](os.environ[env])\n        return os.environ[env]\n\n    @property\n    def group_rules(self):\n        return self.__dict__[\"group_rules\"]\n\n\nsettings = Settings()\n"
  },
  {
    "path": "backend/src/module/conf/const.py",
    "content": "# -*- encoding: utf-8 -*-\n# DEFAULT_SETTINGS: factory defaults written to config.json on first run.\n# ENV_TO_ATTR: maps AB_* environment variables to Config model attribute paths.\n#   Values are either a string attr name, a (attr_name, converter) tuple, or a\n#   list of such tuples when a single env var sets multiple attributes.\nDEFAULT_SETTINGS = {\n    \"program\": {\n        \"rss_time\": 900,\n        \"rename_time\": 60,\n        \"webui_port\": 7892,\n    },\n    \"downloader\": {\n        \"type\": \"qbittorrent\",\n        \"host\": \"172.17.0.1:8080\",\n        \"username\": \"admin\",\n        \"password\": \"adminadmin\",\n        \"path\": \"/downloads/Bangumi\",\n        \"ssl\": False,\n    },\n    \"rss_parser\": {\n        \"enable\": True,\n        \"filter\": [\"720\", \"\\\\d+-\\\\d+\"],\n        \"language\": \"zh\",\n    },\n    \"bangumi_manage\": {\n        \"enable\": True,\n        \"eps_complete\": False,\n        \"rename_method\": \"pn\",\n        \"group_tag\": False,\n        \"remove_bad_torrent\": False,\n    },\n    \"log\": {\n        \"debug_enable\": False,\n    },\n    \"proxy\": {\n        \"enable\": False,\n        \"type\": \"http\",\n        \"host\": \"\",\n        \"port\": 0,\n        \"username\": \"\",\n        \"password\": \"\",\n    },\n    \"notification\": {\"enable\": False, \"providers\": []},\n    \"experimental_openai\": {\n        \"enable\": False,\n        \"api_key\": \"\",\n        \"api_base\": \"https://api.openai.com/v1\",\n        \"api_type\": \"openai\",\n        \"api_version\": \"2023-05-15\",\n        \"model\": \"gpt-3.5-turbo\",\n        \"deployment_id\": \"\",\n    },\n    \"security\": {\n        \"login_whitelist\": [],\n        \"login_tokens\": [],\n        \"mcp_whitelist\": [\n            \"127.0.0.0/8\",\n            \"10.0.0.0/8\",\n            \"172.16.0.0/12\",\n            \"192.168.0.0/16\",\n            \"::1/128\",\n            \"fe80::/10\",\n            \"fc00::/7\",\n        ],\n        \"mcp_tokens\": [],\n    },\n}\n\n\nENV_TO_ATTR = {\n    \"program\": {\n        \"AB_INTERVAL_TIME\": (\"rss_time\", lambda e: int(e)),\n        \"AB_RENAME_FREQ\": (\"rename_time\", lambda e: int(e)),\n        \"AB_WEBUI_PORT\": (\"webui_port\", lambda e: int(e)),\n    },\n    \"downloader\": {\n        \"AB_DOWNLOADER_HOST\": \"host\",\n        \"AB_DOWNLOADER_USERNAME\": \"username\",\n        \"AB_DOWNLOADER_PASSWORD\": \"password\",\n        \"AB_DOWNLOAD_PATH\": \"path\",\n    },\n    \"rss_parser\": {\n        \"AB_RSS_COLLECTOR\": (\"enable\", lambda e: e.lower() in (\"true\", \"1\", \"t\")),\n        \"AB_NOT_CONTAIN\": (\"filter\", lambda e: e.split(\"|\")),\n        \"AB_LANGUAGE\": \"language\",\n    },\n    \"bangumi_manage\": {\n        \"AB_RENAME\": (\"enable\", lambda e: e.lower() in (\"true\", \"1\", \"t\")),\n        \"AB_METHOD\": (\"rename_method\", lambda e: e.lower()),\n        \"AB_GROUP_TAG\": (\"group_tag\", lambda e: e.lower() in (\"true\", \"1\", \"t\")),\n        \"AB_EP_COMPLETE\": (\"eps_complete\", lambda e: e.lower() in (\"true\", \"1\", \"t\")),\n        \"AB_REMOVE_BAD_BT\": (\n            \"remove_bad_torrent\",\n            lambda e: e.lower() in (\"true\", \"1\", \"t\"),\n        ),\n    },\n    \"log\": {\n        \"AB_DEBUG_MODE\": (\"debug_enable\", lambda e: e.lower() in (\"true\", \"1\", \"t\")),\n    },\n    \"proxy\": {\n        \"AB_HTTP_PROXY\": [\n            (\"enable\", lambda e: True),\n            (\"type\", lambda e: \"http\"),\n            (\"host\", lambda e: e.split(\":\")[0]),\n            (\"port\", lambda e: int(e.split(\":\")[1])),\n        ],\n        \"AB_SOCKS\": [\n            (\"enable\", lambda e: True),\n            (\"type\", lambda e: \"socks\"),\n            (\"host\", lambda e: e.split(\",\")[0]),\n            (\"port\", lambda e: int(e.split(\",\")[1])),\n            (\"username\", lambda e: e.split(\",\")[2]),\n            (\"password\", lambda e: e.split(\",\")[3]),\n        ],\n    },\n}\n\n\nclass BCOLORS:\n    \"\"\"ANSI colour helpers for terminal output.\"\"\"\n\n    @staticmethod\n    def _(color: str, *args: str) -> str:\n        \"\"\"Wrap *args* in the given ANSI colour code and reset at the end.\"\"\"\n        strings = [str(s) for s in args]\n        return f\"{color}{', '.join(strings)}{BCOLORS.ENDC}\"\n\n    HEADER = \"\\033[95m\"\n    OKBLUE = \"\\033[94m\"\n    OKCYAN = \"\\033[96m\"\n    OKGREEN = \"\\033[92m\"\n    WARNING = \"\\033[93m\"\n    FAIL = \"\\033[91m\"\n    ENDC = \"\\033[0m\"\n    BOLD = \"\\033[1m\"\n    UNDERLINE = \"\\033[4m\"\n"
  },
  {
    "path": "backend/src/module/conf/log.py",
    "content": "import atexit\nimport logging\nfrom logging.handlers import QueueHandler, QueueListener, RotatingFileHandler\nfrom pathlib import Path\nfrom queue import SimpleQueue\n\nfrom .config import settings\n\nLOG_ROOT = Path(\"data\")\nLOG_PATH = LOG_ROOT / \"log.txt\"\n\n_listener: QueueListener | None = None\n\n\ndef setup_logger(level: int = logging.INFO, reset: bool = False):\n    global _listener\n\n    level = logging.DEBUG if settings.log.debug_enable else level\n    LOG_ROOT.mkdir(exist_ok=True)\n\n    if reset and LOG_PATH.exists():\n        LOG_PATH.unlink(missing_ok=True)\n\n    logging.addLevelName(logging.DEBUG, \"DEBUG:\")\n    logging.addLevelName(logging.INFO, \"INFO:\")\n    logging.addLevelName(logging.WARNING, \"WARNING:\")\n    LOGGING_FORMAT = \"[%(asctime)s] %(levelname)-8s  %(message)s\"\n    TIME_FORMAT = \"%Y-%m-%d %H:%M:%S\"\n    formatter = logging.Formatter(LOGGING_FORMAT, datefmt=TIME_FORMAT)\n\n    file_handler = RotatingFileHandler(\n        LOG_PATH, maxBytes=5 * 1024 * 1024, backupCount=3, encoding=\"utf-8\"\n    )\n    file_handler.setFormatter(formatter)\n\n    stream_handler = logging.StreamHandler()\n    stream_handler.setFormatter(formatter)\n\n    log_queue: SimpleQueue = SimpleQueue()\n    queue_handler = QueueHandler(log_queue)\n\n    _listener = QueueListener(log_queue, file_handler, stream_handler, respect_handler_level=True)\n    _listener.start()\n    atexit.register(_listener.stop)\n\n    logging.basicConfig(\n        level=level,\n        handlers=[queue_handler],\n    )\n\n    # Suppress verbose HTTP request logs from httpx\n    logging.getLogger(\"httpx\").setLevel(logging.WARNING)\n"
  },
  {
    "path": "backend/src/module/conf/parse.py",
    "content": "import argparse\n\n\ndef parse():\n    parser = argparse.ArgumentParser(\n        prog=\"Auto Bangumi\",\n        description=\"\"\"\n        本项目是基于 Mikan Project、qBittorrent 的全自动追番整理下载工具。\n        只需要在 Mikan Project 上订阅番剧，就可以全自动追番。\n        并且整理完成的名称和目录可以直接被 Plex、Jellyfin 等媒体库软件识别，\n        无需二次刮削。\"\"\",\n    )\n\n    parser.add_argument(\"-d\", \"--debug\", action=\"store_true\", help=\"debug mode\")\n    return parser.parse_args()\n"
  },
  {
    "path": "backend/src/module/conf/search_provider.py",
    "content": "from pathlib import Path\n\nfrom module.utils import json_config\n\nDEFAULT_PROVIDER = {\n    \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n    \"nyaa\": \"https://nyaa.si/?page=rss&q=%s&c=0_0&f=0\",\n    \"dmhy\": \"http://dmhy.org/topics/rss/rss.xml?keyword=%s\",\n}\n\nPROVIDER_PATH = Path(\"config/search_provider.json\")\n\n\ndef load_provider():\n    if PROVIDER_PATH.exists():\n        return json_config.load(PROVIDER_PATH)\n    else:\n        json_config.save(PROVIDER_PATH, DEFAULT_PROVIDER)\n        return DEFAULT_PROVIDER\n\n\ndef save_provider(providers: dict[str, str]):\n    \"\"\"Save search providers to config file and update SEARCH_CONFIG.\"\"\"\n    global SEARCH_CONFIG\n    json_config.save(PROVIDER_PATH, providers)\n    SEARCH_CONFIG = providers\n\n\ndef get_provider():\n    \"\"\"Get current search providers config.\"\"\"\n    return SEARCH_CONFIG\n\n\nSEARCH_CONFIG = load_provider()\n"
  },
  {
    "path": "backend/src/module/conf/uvicorn_logging.py",
    "content": "from .log import LOG_PATH\n\nlogging_config = {\n    \"version\": 1,\n    \"disable_existing_loggers\": False,\n    \"formatters\": {\n        \"default\": {\n            \"format\": \"[%(asctime)s] %(levelname)-8s  %(message)s\",\n            \"datefmt\": \"%Y-%m-%d %H:%M:%S\",\n        }\n    },\n    \"handlers\": {\n        \"file\": {\n            \"class\": \"logging.handlers.RotatingFileHandler\",\n            \"filename\": LOG_PATH,\n            \"formatter\": \"default\",\n            \"encoding\": \"utf-8\",\n            \"maxBytes\": 5242880,\n            \"backupCount\": 3,\n        },\n        \"console\": {\n            \"class\": \"logging.StreamHandler\",\n            \"formatter\": \"default\",\n        },\n    },\n    \"loggers\": {\n        \"uvicorn\": {\n            \"handlers\": [\"console\"],\n            \"level\": \"INFO\",\n        },\n        \"uvicorn.error\": {\n            \"level\": \"WARNING\",\n        },\n        \"uvicorn.access\": {\n            \"handlers\": [\"console\"],\n            \"level\": \"DEBUG\",\n            \"propagate\": False,\n        },\n    },\n}\n"
  },
  {
    "path": "backend/src/module/core/__init__.py",
    "content": "from .program import Program\n"
  },
  {
    "path": "backend/src/module/core/offset_scanner.py",
    "content": "\"\"\"Background scanner for detecting season/episode offset mismatches.\"\"\"\n\nimport logging\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.models import Bangumi\nfrom module.parser.analyser.offset_detector import detect_offset_mismatch\nfrom module.parser.analyser.tmdb_parser import tmdb_parser\n\nlogger = logging.getLogger(__name__)\n\n\nclass OffsetScanner:\n    \"\"\"Periodically scan bangumi for season/episode mismatches with TMDB.\"\"\"\n\n    async def scan_all(self) -> int:\n        \"\"\"Scan all active bangumi for offset mismatches.\n\n        Returns:\n            Number of bangumi flagged for review.\n        \"\"\"\n        logger.info(\"[OffsetScanner] Starting offset scan...\")\n\n        with Database() as db:\n            bangumi_list = db.bangumi.get_active_for_scan()\n\n        if not bangumi_list:\n            logger.debug(\"[OffsetScanner] No active bangumi to scan.\")\n            return 0\n\n        flagged_count = 0\n        for bangumi in bangumi_list:\n            try:\n                if await self._check_bangumi(bangumi):\n                    flagged_count += 1\n            except Exception as e:\n                logger.warning(\n                    f\"[OffsetScanner] Error checking {bangumi.official_title}: {e}\"\n                )\n\n        logger.info(\n            f\"[OffsetScanner] Scan complete. Flagged {flagged_count} bangumi for review.\"\n        )\n        return flagged_count\n\n    async def _check_bangumi(self, bangumi: Bangumi) -> bool:\n        \"\"\"Check a single bangumi for offset mismatch.\n\n        Args:\n            bangumi: The bangumi to check.\n\n        Returns:\n            True if flagged for review, False otherwise.\n        \"\"\"\n        # Skip if already needs review\n        if bangumi.needs_review:\n            logger.debug(\n                f\"[OffsetScanner] Skipping {bangumi.official_title}: already needs review\"\n            )\n            return False\n\n        # Skip if user has already configured offsets\n        if bangumi.season_offset != 0 or bangumi.episode_offset != 0:\n            logger.debug(\n                f\"[OffsetScanner] Skipping {bangumi.official_title}: has configured offsets\"\n            )\n            return False\n\n        # Get TMDB info\n        language = settings.rss_parser.language\n        tmdb_info = await tmdb_parser(bangumi.official_title, language)\n\n        if not tmdb_info:\n            logger.debug(\n                f\"[OffsetScanner] Skipping {bangumi.official_title}: no TMDB info\"\n            )\n            return False\n\n        # Get latest episode for this bangumi (use season as proxy since we don't track episodes)\n        # For now, we'll check based on the bangumi's season\n        parsed_episode = 1  # Default to episode 1 for season-based detection\n\n        # Detect mismatch\n        suggestion = detect_offset_mismatch(\n            parsed_season=bangumi.season,\n            parsed_episode=parsed_episode,\n            tmdb_info=tmdb_info,\n        )\n\n        if suggestion and suggestion.confidence in (\"high\", \"medium\"):\n            with Database() as db:\n                db.bangumi.set_needs_review(\n                    bangumi.id,\n                    suggestion.reason,\n                    suggested_season_offset=suggestion.season_offset,\n                    suggested_episode_offset=suggestion.episode_offset,\n                )\n            logger.info(\n                f\"[OffsetScanner] Flagged {bangumi.official_title} for review: {suggestion.reason} \"\n                f\"(suggested: season={suggestion.season_offset}, episode={suggestion.episode_offset})\"\n            )\n            return True\n\n        return False\n\n    async def check_single(self, bangumi_id: int) -> bool:\n        \"\"\"Check a single bangumi by ID.\n\n        Args:\n            bangumi_id: The ID of the bangumi to check.\n\n        Returns:\n            True if flagged for review, False otherwise.\n        \"\"\"\n        with Database() as db:\n            bangumi = db.bangumi.search_id(bangumi_id)\n\n        if not bangumi:\n            logger.warning(f\"[OffsetScanner] Bangumi {bangumi_id} not found\")\n            return False\n\n        return await self._check_bangumi(bangumi)\n"
  },
  {
    "path": "backend/src/module/core/program.py",
    "content": "import asyncio\nimport logging\n\nfrom module.conf import VERSION, settings\nfrom module.models import ResponseModel\nfrom module.update import (\n    cache_image,\n    data_migration,\n    first_run,\n    from_30_to_31,\n    from_31_to_32,\n    run_migrations,\n    start_up,\n)\n\nfrom .sub_thread import CalendarRefreshThread, OffsetScanThread, RenameThread, RSSThread\n\nlogger = logging.getLogger(__name__)\n\nfiglet = r\"\"\"\n               _        ____                                    _\n    /\\        | |      |  _ \\                                  (_)\n   /  \\  _   _| |_ ___ | |_) | __ _ _ __   __ _ _   _ _ __ ___  _\n  / /\\ \\| | | | __/ _ \\|  _ < / _` | '_ \\ / _` | | | | '_ ` _ \\| |\n / ____ \\ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |\n/_/    \\_\\__,_|\\__\\___/|____/ \\__,_|_| |_|\\__, |\\__,_|_| |_| |_|_|\n                                           __/ |\n                                          |___/\n\"\"\"\n\n\nclass Program(RenameThread, RSSThread, OffsetScanThread, CalendarRefreshThread):\n    def __init__(self):\n        super().__init__()\n        self._startup_done = False\n\n    @staticmethod\n    def __start_info():\n        for line in figlet.splitlines():\n            logger.info(line.strip(\"\\n\"))\n        logger.info(\n            f\"Version {VERSION}  Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan\"\n        )\n        logger.info(\"GitHub: https://github.com/EstrellaXD/Auto_Bangumi/\")\n        logger.info(\"Starting AutoBangumi...\")\n\n    async def startup(self):\n        # Prevent duplicate startup due to nested router lifespan events\n        if self._startup_done:\n            return\n        self.__start_info()\n        if not self.database:\n            first_run()\n            logger.info(\"[Core] No db file exists, create database file.\")\n            return {\"status\": \"First run detected.\"}\n        if self.legacy_data:\n            logger.info(\n                \"[Core] Legacy data detected, starting data migration, please wait patiently.\"\n            )\n            data_migration()\n        else:\n            need_update, last_minor = self.version_update\n            if need_update:\n                if last_minor is not None and last_minor == 0:\n                    await from_30_to_31()\n                    logger.info(\"[Core] Database migrated from 3.0 to 3.1.\")\n                await from_31_to_32()\n                logger.info(\"[Core] Database updated.\")\n            else:\n                # Always check schema version and run pending migrations,\n                # in case a previous migration was interrupted or failed.\n                run_migrations()\n        if not self.img_cache:\n            logger.info(\"[Core] No image cache exists, create image cache.\")\n            await cache_image()\n        await self.start()\n        self._startup_done = True\n\n    async def start(self):\n        settings.load()\n        max_retries = 10\n        retry_count = 0\n        while not await self.check_downloader_status():\n            retry_count += 1\n            logger.warning(\n                f\"Downloader is not running. (attempt {retry_count}/{max_retries})\"\n            )\n            if retry_count >= max_retries:\n                logger.error(\n                    \"Failed to connect to downloader after maximum retries. \"\n                    \"Please check downloader settings and network/proxy configuration. \"\n                    \"Program will continue but download functions will not work.\"\n                )\n                break\n            logger.info(\"Waiting for downloader to start...\")\n            await asyncio.sleep(30)\n        if self.enable_renamer:\n            self.rename_start()\n        if self.enable_rss:\n            self.rss_start()\n        # Start offset scanner for background mismatch detection\n        self.scan_start()\n        # Start calendar refresh (every 24 hours)\n        self.calendar_start()\n        self._tasks_started = True\n        logger.info(\"Program running.\")\n        return ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"Program started.\",\n            msg_zh=\"程序启动成功。\",\n        )\n\n    async def stop(self):\n        if self.is_running:\n            await self.rename_stop()\n            await self.rss_stop()\n            await self.scan_stop()\n            await self.calendar_stop()\n            self._tasks_started = False\n            return ResponseModel(\n                status=True,\n                status_code=200,\n                msg_en=\"Program stopped.\",\n                msg_zh=\"程序停止成功。\",\n            )\n        else:\n            return ResponseModel(\n                status=False,\n                status_code=406,\n                msg_en=\"Program is not running.\",\n                msg_zh=\"程序未运行。\",\n            )\n\n    async def restart(self):\n        stop_ok = True\n        try:\n            await self.stop()\n        except Exception as e:\n            logger.warning(f\"[Core] Error during stop in restart: {e}\")\n            stop_ok = False\n        start_ok = True\n        try:\n            await self.start()\n        except Exception as e:\n            logger.error(f\"[Core] Error during start in restart: {e}\")\n            start_ok = False\n        if start_ok and stop_ok:\n            return ResponseModel(\n                status=True,\n                status_code=200,\n                msg_en=\"Program restarted.\",\n                msg_zh=\"程序重启成功。\",\n            )\n        elif start_ok:\n            return ResponseModel(\n                status=True,\n                status_code=200,\n                msg_en=\"Program restarted (stop had warnings).\",\n                msg_zh=\"程序重启成功（停止时有警告）。\",\n            )\n        else:\n            return ResponseModel(\n                status=False,\n                status_code=500,\n                msg_en=\"Program failed to restart.\",\n                msg_zh=\"程序重启失败。\",\n            )\n\n    def update_database(self):\n        need_update, _ = self.version_update\n        if not need_update:\n            return {\"status\": \"No update found.\"}\n        else:\n            start_up()\n            return {\"status\": \"Database updated.\"}\n"
  },
  {
    "path": "backend/src/module/core/status.py",
    "content": "import asyncio\nimport time\n\nfrom module.checker import Checker\nfrom module.conf import LEGACY_DATA_PATH\n\nDOWNLOADER_STATUS_TTL = 60\n\n\nclass ProgramStatus(Checker):\n    def __init__(self):\n        super().__init__()\n        self.stop_event = asyncio.Event()\n        self.lock = asyncio.Lock()\n        self._downloader_status = False\n        self._downloader_last_check: float = 0\n        self._torrents_status = False\n        self._tasks_started = False\n        self.event = asyncio.Event()\n\n    @property\n    def is_running(self):\n        if not self._tasks_started or self.check_first_run():\n            return False\n        else:\n            return True\n\n    @property\n    def is_stopped(self):\n        return not self._tasks_started\n\n    @property\n    def downloader_status(self):\n        return self._downloader_status\n\n    async def check_downloader_status(self) -> bool:\n        now = time.monotonic()\n        if (\n            not self._downloader_status\n            or (now - self._downloader_last_check) >= DOWNLOADER_STATUS_TTL\n        ):\n            self._downloader_status = await self.check_downloader()\n            self._downloader_last_check = now\n        return self._downloader_status\n\n    @property\n    def enable_rss(self):\n        return self.check_analyser()\n\n    @property\n    def enable_renamer(self):\n        return self.check_renamer()\n\n    @property\n    def first_run(self):\n        return self.check_first_run()\n\n    @property\n    def legacy_data(self):\n        return LEGACY_DATA_PATH.exists()\n\n    @property\n    def version_update(self) -> tuple[bool, int | None]:\n        is_same, last_minor = self.check_version()\n        return not is_same, last_minor\n\n    @property\n    def database(self):\n        return self.check_database()\n\n    @property\n    def img_cache(self):\n        return self.check_img_cache()\n"
  },
  {
    "path": "backend/src/module/core/sub_thread.py",
    "content": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.downloader import DownloadClient\nfrom module.manager import Renamer, TorrentManager, eps_complete\nfrom module.notification import NotificationManager\nfrom module.rss import RSSAnalyser, RSSEngine\n\nfrom .offset_scanner import OffsetScanner\nfrom .status import ProgramStatus\n\nlogger = logging.getLogger(__name__)\n\n# Calendar refresh interval in seconds (24 hours)\nCALENDAR_REFRESH_INTERVAL = 24 * 60 * 60\n\n\nclass RSSThread(ProgramStatus):\n    def __init__(self):\n        super().__init__()\n        self._rss_task: asyncio.Task | None = None\n        self._rss_stop_event = asyncio.Event()\n        self.analyser = RSSAnalyser()\n\n    async def rss_loop(self):\n        while not self._rss_stop_event.is_set():\n            try:\n                async with DownloadClient() as client:\n                    with RSSEngine() as engine:\n                        # Analyse RSS\n                        rss_list = engine.rss.search_aggregate()\n                        for rss in rss_list:\n                            await self.analyser.rss_to_data(rss, engine)\n                        # Run RSS Engine\n                        await engine.refresh_rss(client)\n                if settings.bangumi_manage.eps_complete:\n                    await eps_complete()\n            except Exception as e:\n                logger.error(f\"[RSSThread] Error during RSS loop: {e}\")\n            try:\n                await asyncio.wait_for(\n                    self._rss_stop_event.wait(),\n                    timeout=settings.program.rss_time,\n                )\n            except asyncio.TimeoutError:\n                pass\n\n    def rss_start(self):\n        self._rss_stop_event.clear()\n        self._rss_task = asyncio.create_task(self.rss_loop())\n\n    async def rss_stop(self):\n        if self._rss_task and not self._rss_task.done():\n            self._rss_stop_event.set()\n            self._rss_task.cancel()\n            try:\n                await self._rss_task\n            except asyncio.CancelledError:\n                pass\n            self._rss_task = None\n\n\nclass RenameThread(ProgramStatus):\n    def __init__(self):\n        super().__init__()\n        self._rename_task: asyncio.Task | None = None\n        self._rename_stop_event = asyncio.Event()\n\n    async def rename_loop(self):\n        while not self._rename_stop_event.is_set():\n            try:\n                async with Renamer() as renamer:\n                    renamed_info = await renamer.rename()\n                if settings.notification.enable and renamed_info:\n                    manager = NotificationManager()\n                    for info in renamed_info:\n                        await manager.send_all(info)\n            except Exception as e:\n                logger.error(f\"[RenameThread] Error during rename loop: {e}\")\n            try:\n                await asyncio.wait_for(\n                    self._rename_stop_event.wait(),\n                    timeout=settings.program.rename_time,\n                )\n            except asyncio.TimeoutError:\n                pass\n\n    def rename_start(self):\n        self._rename_stop_event.clear()\n        self._rename_task = asyncio.create_task(self.rename_loop())\n\n    async def rename_stop(self):\n        if self._rename_task and not self._rename_task.done():\n            self._rename_stop_event.set()\n            self._rename_task.cancel()\n            try:\n                await self._rename_task\n            except asyncio.CancelledError:\n                pass\n            self._rename_task = None\n\n\n# Offset scan interval in seconds (6 hours)\nOFFSET_SCAN_INTERVAL = 6 * 60 * 60\n\n\nclass OffsetScanThread(ProgramStatus):\n    \"\"\"Background thread for scanning bangumi offset mismatches.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self._scan_task: asyncio.Task | None = None\n        self._scan_stop_event = asyncio.Event()\n        self._scanner = OffsetScanner()\n\n    async def scan_loop(self):\n        # Initial delay to let the system stabilize\n        await asyncio.sleep(60)\n\n        while not self._scan_stop_event.is_set():\n            try:\n                flagged = await self._scanner.scan_all()\n                logger.info(\n                    f\"[OffsetScanThread] Scan complete, flagged {flagged} bangumi\"\n                )\n            except Exception as e:\n                logger.error(f\"[OffsetScanThread] Error during scan: {e}\")\n\n            try:\n                await asyncio.wait_for(\n                    self._scan_stop_event.wait(),\n                    timeout=OFFSET_SCAN_INTERVAL,\n                )\n            except asyncio.TimeoutError:\n                pass\n\n    def scan_start(self):\n        self._scan_stop_event.clear()\n        self._scan_task = asyncio.create_task(self.scan_loop())\n        logger.info(\"[OffsetScanThread] Started offset scanner\")\n\n    async def scan_stop(self):\n        if self._scan_task and not self._scan_task.done():\n            self._scan_stop_event.set()\n            self._scan_task.cancel()\n            try:\n                await self._scan_task\n            except asyncio.CancelledError:\n                pass\n            self._scan_task = None\n            logger.info(\"[OffsetScanThread] Stopped offset scanner\")\n\n\nclass CalendarRefreshThread(ProgramStatus):\n    \"\"\"Background thread for refreshing bangumi calendar data every 24 hours.\"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self._calendar_task: asyncio.Task | None = None\n        self._calendar_stop_event = asyncio.Event()\n\n    async def calendar_loop(self):\n        # Initial delay to let the system stabilize\n        await asyncio.sleep(120)\n\n        while not self._calendar_stop_event.is_set():\n            try:\n                with TorrentManager() as manager:\n                    resp = await manager.refresh_calendar()\n                    if resp.status:\n                        logger.info(\n                            \"[CalendarRefreshThread] Calendar refresh completed\"\n                        )\n                    else:\n                        logger.warning(\n                            f\"[CalendarRefreshThread] Calendar refresh failed: {resp.msg_en}\"\n                        )\n            except Exception as e:\n                logger.error(f\"[CalendarRefreshThread] Error during refresh: {e}\")\n\n            try:\n                await asyncio.wait_for(\n                    self._calendar_stop_event.wait(),\n                    timeout=CALENDAR_REFRESH_INTERVAL,\n                )\n            except asyncio.TimeoutError:\n                pass\n\n    def calendar_start(self):\n        self._calendar_stop_event.clear()\n        self._calendar_task = asyncio.create_task(self.calendar_loop())\n        logger.info(\"[CalendarRefreshThread] Started calendar refresh (every 24h)\")\n\n    async def calendar_stop(self):\n        if self._calendar_task and not self._calendar_task.done():\n            self._calendar_stop_event.set()\n            self._calendar_task.cancel()\n            try:\n                await self._calendar_task\n            except asyncio.CancelledError:\n                pass\n            self._calendar_task = None\n            logger.info(\"[CalendarRefreshThread] Stopped calendar refresh\")\n"
  },
  {
    "path": "backend/src/module/database/__init__.py",
    "content": "from .combine import Database\nfrom .engine import engine\n"
  },
  {
    "path": "backend/src/module/database/bangumi.py",
    "content": "import json\nimport logging\nimport re\nimport time\nfrom typing import Optional\n\nfrom sqlalchemy.sql import func\nfrom sqlmodel import Session, and_, delete, false, or_, select\n\nfrom module.models import Bangumi, BangumiUpdate\n\nlogger = logging.getLogger(__name__)\n\n\ndef _normalize_group_name(group: str | None) -> str:\n    \"\"\"Normalize group name for comparison by removing common separators.\"\"\"\n    if not group:\n        return \"\"\n    # Remove common separators (&, ×, _, -) and normalize to lowercase\n    return re.sub(r\"[&×_\\-]\", \"\", group).lower().strip()\n\n\ndef _groups_are_similar(group1: str | None, group2: str | None) -> bool:\n    \"\"\"\n    Check if two group names are similar enough to be considered the same group.\n\n    Handles cases like:\n    - \"LoliHouse\" vs \"LoliHouse&动漫国字幕组\"\n    - \"字幕组A\" vs \"字幕组A×字幕组B\"\n    \"\"\"\n    if not group1 or not group2:\n        return False\n\n    # Exact match or substring match (one contains the other)\n    if group1 == group2 or group1 in group2 or group2 in group1:\n        return True\n\n    # Normalized comparison - check if core group names overlap\n    norm1 = _normalize_group_name(group1)\n    norm2 = _normalize_group_name(group2)\n    return norm1 in norm2 or norm2 in norm1\n\n\ndef _get_aliases_list(bangumi: Bangumi) -> list[str]:\n    \"\"\"Get the list of title aliases from a bangumi's title_aliases JSON field.\"\"\"\n    if not bangumi.title_aliases:\n        return []\n    try:\n        aliases = json.loads(bangumi.title_aliases)\n        if not isinstance(aliases, list):\n            return []\n        return [a for a in aliases if a]\n    except (json.JSONDecodeError, TypeError):\n        return []\n\n\ndef _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None:\n    \"\"\"Set the title aliases JSON field from a list.\"\"\"\n    if not aliases:\n        bangumi.title_aliases = None\n    else:\n        # Remove duplicates while preserving order\n        unique_aliases = list(dict.fromkeys(aliases))\n        bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False)\n\n\n# Module-level TTL cache for search_all results\n_bangumi_cache: list[Bangumi] | None = None\n_bangumi_cache_time: float = 0\n_BANGUMI_CACHE_TTL: float = 300.0  # 5 minutes - extended from 60s to reduce DB queries\n\n\ndef _invalidate_bangumi_cache():\n    global _bangumi_cache, _bangumi_cache_time\n    _bangumi_cache = None\n    _bangumi_cache_time = 0\n\n\nclass BangumiDatabase:\n    def __init__(self, session: Session):\n        self.session = session\n\n    def find_semantic_duplicate(self, data: Bangumi) -> Optional[Bangumi]:\n        \"\"\"\n        Find existing bangumi that semantically matches the new one.\n\n        This handles cases where subtitle groups change naming mid-season.\n        A semantic match requires:\n        - Same official_title\n        - Same dpi (resolution)\n        - Same subtitle type\n        - Same source\n        - Similar group_name (one contains the other)\n\n        Returns the matching Bangumi if found, None otherwise.\n        \"\"\"\n        statement = select(Bangumi).where(\n            and_(\n                Bangumi.official_title == data.official_title,\n                Bangumi.deleted == false(),\n            )\n        )\n        candidates = self.session.execute(statement).scalars().all()\n\n        for candidate in candidates:\n            is_exact_duplicate = (\n                candidate.title_raw == data.title_raw\n                and candidate.group_name == data.group_name\n            )\n            if is_exact_duplicate:\n                continue\n\n            is_semantic_match = (\n                candidate.dpi == data.dpi\n                and candidate.subtitle == data.subtitle\n                and candidate.source == data.source\n                and _groups_are_similar(candidate.group_name, data.group_name)\n            )\n            if is_semantic_match:\n                logger.debug(\n                    \"[Database] Found semantic duplicate: '%s' matches \"\n                    \"existing '%s' (official: %s)\",\n                    data.title_raw,\n                    candidate.title_raw,\n                    data.official_title,\n                )\n                return candidate\n\n        return None\n\n    def add_title_alias(\n        self, bangumi_id: int, new_title_raw: str, auto_commit: bool = True\n    ) -> bool:\n        \"\"\"\n        Add a new title_raw alias to an existing bangumi.\n\n        This allows a single bangumi entry to match multiple naming patterns.\n        \"\"\"\n        bangumi = self.session.get(Bangumi, bangumi_id)\n        if not bangumi:\n            logger.warning(\n                f\"[Database] Cannot add alias: bangumi id {bangumi_id} not found\"\n            )\n            return False\n\n        # Don't add None or empty aliases\n        if not new_title_raw:\n            return False\n\n        # Don't add if it's the same as the main title_raw\n        if bangumi.title_raw == new_title_raw:\n            return False\n\n        # Get existing aliases and add the new one\n        aliases = _get_aliases_list(bangumi)\n        if new_title_raw in aliases:\n            return False  # Already exists\n\n        aliases.append(new_title_raw)\n        _set_aliases_list(bangumi, aliases)\n\n        self.session.add(bangumi)\n        if auto_commit:\n            self.session.commit()\n            _invalidate_bangumi_cache()\n        logger.info(\n            f\"[Database] Added alias '{new_title_raw}' to bangumi '{bangumi.official_title}' \"\n            f\"(id: {bangumi_id})\"\n        )\n        return True\n\n    def get_all_title_patterns(self, bangumi: Bangumi) -> list[str]:\n        \"\"\"Get all title patterns for matching (title_raw + all aliases).\"\"\"\n        patterns = []\n        if bangumi.title_raw:\n            patterns.append(bangumi.title_raw)\n        patterns.extend(_get_aliases_list(bangumi))\n        return patterns\n\n    def _is_duplicate(self, data: Bangumi) -> bool:\n        \"\"\"Check if a bangumi rule already exists based on title_raw and group_name.\"\"\"\n        statement = select(Bangumi).where(\n            and_(\n                Bangumi.title_raw == data.title_raw,\n                Bangumi.group_name == data.group_name,\n            )\n        )\n        result = self.session.execute(statement)\n        return result.scalar_one_or_none() is not None\n\n    def add(self, data: Bangumi) -> bool:\n        if self._is_duplicate(data):\n            logger.debug(\n                \"[Database] Skipping duplicate: %s (%s)\",\n                data.official_title,\n                data.group_name,\n            )\n            return False\n\n        # Check for semantic duplicate (same anime, different naming pattern)\n        semantic_match = self.find_semantic_duplicate(data)\n        if semantic_match:\n            # Add as alias instead of creating new entry\n            self.add_title_alias(semantic_match.id, data.title_raw)\n            logger.info(\n                f\"[Database] Merged '{data.title_raw}' as alias to existing \"\n                f\"'{semantic_match.title_raw}' (official: {data.official_title})\"\n            )\n            return False  # Return False since we didn't add a new entry\n\n        self.session.add(data)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Insert %s into database.\", data.official_title)\n        return True\n\n    def add_all(self, datas: list[Bangumi]) -> int:\n        \"\"\"Add multiple bangumi, skipping duplicates. Returns count of added items.\"\"\"\n        if not datas:\n            return 0\n\n        # Batch query: get all existing (title_raw, group_name) combinations in one query\n        # This replaces N individual _is_duplicate() calls with a single SELECT\n        keys_to_check = [(d.title_raw, d.group_name) for d in datas]\n        conditions = [\n            and_(Bangumi.title_raw == tr, Bangumi.group_name == gn)\n            for tr, gn in keys_to_check\n        ]\n        if conditions:\n            statement = select(Bangumi.title_raw, Bangumi.group_name).where(\n                or_(*conditions)\n            )\n            result = self.session.execute(statement)\n            existing = set(result.all())\n        else:\n            existing = set()\n\n        # Filter out exact duplicates\n        to_add = [d for d in datas if (d.title_raw, d.group_name) not in existing]\n\n        # Check for semantic duplicates and add as aliases\n        semantic_merged = 0\n        really_to_add = []\n        for d in to_add:\n            semantic_match = self.find_semantic_duplicate(d)\n            if semantic_match:\n                # Add as alias instead of creating new entry (defer commit)\n                self.add_title_alias(semantic_match.id, d.title_raw, auto_commit=False)\n                semantic_merged += 1\n                logger.info(\n                    f\"[Database] Merged '{d.title_raw}' as alias to existing \"\n                    f\"'{semantic_match.title_raw}' (official: {d.official_title})\"\n                )\n            else:\n                really_to_add.append(d)\n\n        # Also deduplicate within the batch itself\n        seen = set()\n        unique_to_add = []\n        for d in really_to_add:\n            key = (d.title_raw, d.group_name)\n            if key not in seen:\n                seen.add(key)\n                unique_to_add.append(d)\n\n        if not unique_to_add:\n            if semantic_merged > 0:\n                self.session.commit()\n                _invalidate_bangumi_cache()\n                logger.debug(\n                    \"[Database] %s bangumi merged as aliases, \" \"rest were duplicates.\",\n                    semantic_merged,\n                )\n            else:\n                logger.debug(\n                    \"[Database] All %s bangumi already exist, skipping.\",\n                    len(datas),\n                )\n            return 0\n\n        self.session.add_all(unique_to_add)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        skipped = len(datas) - len(unique_to_add) - semantic_merged\n        if skipped > 0 or semantic_merged > 0:\n            logger.debug(\n                \"[Database] Insert %s bangumi, \"\n                \"skipped %s duplicates, merged %s as aliases.\",\n                len(unique_to_add),\n                skipped,\n                semantic_merged,\n            )\n        else:\n            logger.debug(\n                \"[Database] Insert %s bangumi into database.\",\n                len(unique_to_add),\n            )\n        return len(unique_to_add)\n\n    def update(self, data: Bangumi | BangumiUpdate, _id: int = None) -> bool:\n        if _id and isinstance(data, BangumiUpdate):\n            db_data = self.session.get(Bangumi, _id)\n        elif isinstance(data, Bangumi):\n            db_data = self.session.get(Bangumi, data.id)\n        else:\n            return False\n        if not db_data:\n            return False\n        bangumi_data = data.model_dump(exclude_unset=True)\n        for key, value in bangumi_data.items():\n            setattr(db_data, key, value)\n        self.session.add(db_data)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Update %s\", data.official_title)\n        return True\n\n    def update_all(self, datas: list[Bangumi]):\n        self.session.add_all(datas)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Update %s bangumi.\", len(datas))\n\n    def update_rss(self, title_raw: str, rss_set: str):\n        statement = select(Bangumi).where(Bangumi.title_raw == title_raw)\n        result = self.session.execute(statement)\n        bangumi = result.scalar_one_or_none()\n        if bangumi:\n            bangumi.rss_link = rss_set\n            bangumi.added = False\n            self.session.add(bangumi)\n            self.session.commit()\n            _invalidate_bangumi_cache()\n            logger.debug(\"[Database] Update %s rss_link to %s.\", title_raw, rss_set)\n\n    def update_poster(self, title_raw: str, poster_link: str):\n        statement = select(Bangumi).where(Bangumi.title_raw == title_raw)\n        result = self.session.execute(statement)\n        bangumi = result.scalar_one_or_none()\n        if bangumi:\n            bangumi.poster_link = poster_link\n            self.session.add(bangumi)\n            self.session.commit()\n            _invalidate_bangumi_cache()\n            logger.debug(\n                \"[Database] Update %s poster_link to %s.\", title_raw, poster_link\n            )\n\n    def delete_one(self, _id: int):\n        statement = select(Bangumi).where(Bangumi.id == _id)\n        result = self.session.execute(statement)\n        bangumi = result.scalar_one_or_none()\n        if bangumi:\n            self.session.delete(bangumi)\n            self.session.commit()\n            _invalidate_bangumi_cache()\n            logger.debug(\"[Database] Delete bangumi id: %s.\", _id)\n\n    def delete_all(self):\n        statement = delete(Bangumi)\n        self.session.execute(statement)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n\n    def search_all(self) -> list[Bangumi]:\n        global _bangumi_cache, _bangumi_cache_time\n        now = time.time()\n        if (\n            _bangumi_cache is not None\n            and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL\n        ):\n            return _bangumi_cache\n        statement = select(Bangumi)\n        result = self.session.execute(statement)\n        bangumis = list(result.scalars().all())\n        # Expunge objects from session to prevent DetachedInstanceError when\n        # cached objects are accessed from a different session/request context\n        for b in bangumis:\n            self.session.expunge(b)\n        _bangumi_cache = bangumis\n        _bangumi_cache_time = now\n        return _bangumi_cache\n\n    def search_id(self, _id: int) -> Optional[Bangumi]:\n        statement = select(Bangumi).where(Bangumi.id == _id)\n        bangumi = self.session.execute(statement).scalar_one_or_none()\n        if bangumi is None:\n            logger.warning(f\"[Database] Cannot find bangumi id: {_id}.\")\n            return None\n        logger.debug(\"[Database] Find bangumi id: %s.\", _id)\n        return bangumi\n\n    def search_official_title(self, official_title: str) -> Optional[Bangumi]:\n        statement = select(Bangumi).where(Bangumi.official_title == official_title)\n        return self.session.execute(statement).scalar_one_or_none()\n\n    def search_ids(self, ids: list[int]) -> list[Bangumi]:\n        \"\"\"Batch lookup multiple bangumi by their IDs.\"\"\"\n        if not ids:\n            return []\n        statement = select(Bangumi).where(Bangumi.id.in_(ids))\n        result = self.session.execute(statement)\n        return list(result.scalars().all())\n\n    def match_poster(self, bangumi_name: str) -> str:\n        statement = select(Bangumi).where(\n            func.instr(bangumi_name, Bangumi.official_title) > 0\n        )\n        data = self.session.execute(statement).scalar_one_or_none()\n        return data.poster_link if data else \"\"\n\n    def match_list(self, torrent_list: list, rss_link: str) -> list:\n        match_datas = self.search_all()\n        if not match_datas:\n            return torrent_list\n\n        # Build index for O(1) lookup after regex match\n        # Include both title_raw and all aliases\n        title_index: dict[str, Bangumi] = {}\n        for m in match_datas:\n            # Add main title_raw (skip if None to avoid TypeError in sorted())\n            if m.title_raw:\n                title_index[m.title_raw] = m\n            # Add all aliases\n            for alias in _get_aliases_list(m):\n                if alias:\n                    title_index[alias] = m\n\n        # Build compiled regex pattern for fast substring matching\n        # Sort by length descending so longer (more specific) matches are found first\n        sorted_titles = sorted(title_index.keys(), key=len, reverse=True)\n        # Escape special regex characters and join with alternation\n        pattern = \"|\".join(re.escape(title) for title in sorted_titles)\n        title_regex = re.compile(pattern)\n\n        unmatched = []\n        rss_updated = set()\n        for torrent in torrent_list:\n            match = title_regex.search(torrent.name)\n            if match:\n                matched_title = match.group(0)\n                match_data = title_index[matched_title]\n                # Use the bangumi's main title_raw for rss_updated tracking\n                if (\n                    rss_link not in match_data.rss_link\n                    and match_data.title_raw not in rss_updated\n                ):\n                    match_data = self.session.merge(match_data)\n                    match_data.rss_link += f\",{rss_link}\"\n                    match_data.added = False\n                    rss_updated.add(match_data.title_raw)\n            else:\n                unmatched.append(torrent)\n        # Batch commit all rss_link updates\n        if rss_updated:\n            self.session.commit()\n            _invalidate_bangumi_cache()\n            logger.debug(\n                \"[Database] Batch updated rss_link for %s bangumi.\",\n                len(rss_updated),\n            )\n        return unmatched\n\n    def match_torrent(self, torrent_name: str) -> Optional[Bangumi]:\n        \"\"\"\n        Match torrent name to a bangumi, checking both title_raw and title_aliases.\n\n        Returns the bangumi with the longest matching pattern for specificity.\n        \"\"\"\n        match_datas = self.search_all()\n        if not match_datas:\n            return None\n\n        best_match: Optional[Bangumi] = None\n        best_match_len = 0\n\n        for bangumi in match_datas:\n            if bangumi.deleted:\n                continue\n\n            # Check all patterns (title_raw + aliases)\n            patterns = self.get_all_title_patterns(bangumi)\n            for pattern in patterns:\n                if pattern in torrent_name:\n                    # Prefer longer matches (more specific)\n                    if len(pattern) > best_match_len:\n                        best_match = bangumi\n                        best_match_len = len(pattern)\n\n        return best_match\n\n    def not_complete(self) -> list[Bangumi]:\n        condition = select(Bangumi).where(\n            and_(Bangumi.eps_collect == false(), Bangumi.deleted == false())\n        )\n        result = self.session.execute(condition)\n        return list(result.scalars().all())\n\n    def not_added(self) -> list[Bangumi]:\n        conditions = select(Bangumi).where(\n            or_(\n                Bangumi.added == 0,\n                Bangumi.rule_name.is_(None),\n                Bangumi.save_path.is_(None),\n            )\n        )\n        result = self.session.execute(conditions)\n        return list(result.scalars().all())\n\n    def disable_rule(self, _id: int):\n        statement = select(Bangumi).where(Bangumi.id == _id)\n        result = self.session.execute(statement)\n        bangumi = result.scalar_one_or_none()\n        if bangumi:\n            bangumi.deleted = True\n            self.session.add(bangumi)\n            self.session.commit()\n            _invalidate_bangumi_cache()\n            logger.debug(\"[Database] Disable rule %s.\", bangumi.title_raw)\n\n    def search_rss(self, rss_link: str) -> list[Bangumi]:\n        statement = select(Bangumi).where(func.instr(rss_link, Bangumi.rss_link) > 0)\n        result = self.session.execute(statement)\n        return list(result.scalars().all())\n\n    def archive_one(self, _id: int) -> bool:\n        \"\"\"Set archived=True for the given bangumi.\"\"\"\n        bangumi = self.session.get(Bangumi, _id)\n        if not bangumi:\n            logger.warning(f\"[Database] Cannot archive bangumi id: {_id}, not found.\")\n            return False\n        bangumi.archived = True\n        self.session.add(bangumi)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Archived bangumi id: %s.\", _id)\n        return True\n\n    def unarchive_one(self, _id: int) -> bool:\n        \"\"\"Set archived=False for the given bangumi.\"\"\"\n        bangumi = self.session.get(Bangumi, _id)\n        if not bangumi:\n            logger.warning(f\"[Database] Cannot unarchive bangumi id: {_id}, not found.\")\n            return False\n        bangumi.archived = False\n        self.session.add(bangumi)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Unarchived bangumi id: %s.\", _id)\n        return True\n\n    def match_by_save_path(self, save_path: str) -> Optional[Bangumi]:\n        \"\"\"Find bangumi by save_path to get offset.\n\n        Tries exact match first, then falls back to matching with/without trailing slashes\n        and different path separators.\n\n        Note: When multiple subscriptions share the same save_path (e.g., different RSS\n        sources for the same anime), this returns the first match. Use match_torrent()\n        for more accurate matching when torrent_name is available.\n        \"\"\"\n        if not save_path:\n            return None\n\n        # Try exact match first\n        statement = select(Bangumi).where(\n            and_(Bangumi.save_path == save_path, Bangumi.deleted == false())\n        )\n        result = self.session.execute(statement)\n        bangumi = result.scalars().first()\n        if bangumi:\n            return bangumi\n\n        # Normalize the input path and try variations\n        normalized = save_path.replace(\"\\\\\", \"/\").rstrip(\"/\")\n        variations = [\n            normalized,\n            normalized + \"/\",\n            save_path.rstrip(\"/\"),\n            save_path.rstrip(\"\\\\\"),\n        ]\n        # Remove duplicates while preserving order\n        seen = {save_path}\n        unique_variations = []\n        for v in variations:\n            if v not in seen:\n                seen.add(v)\n                unique_variations.append(v)\n\n        for variant in unique_variations:\n            statement = select(Bangumi).where(\n                and_(Bangumi.save_path == variant, Bangumi.deleted == false())\n            )\n            result = self.session.execute(statement)\n            bangumi = result.scalars().first()\n            if bangumi:\n                return bangumi\n\n        return None\n\n    def get_needs_review(self) -> list[Bangumi]:\n        \"\"\"Get all bangumi that need review for offset mismatch.\"\"\"\n        statement = select(Bangumi).where(\n            and_(\n                Bangumi.needs_review == True,  # noqa: E712\n                Bangumi.deleted == false(),\n            )\n        )\n        result = self.session.execute(statement)\n        return list(result.scalars().all())\n\n    def get_active_for_scan(self) -> list[Bangumi]:\n        \"\"\"Get all active (non-deleted, non-archived) bangumi for offset scanning.\"\"\"\n        statement = select(Bangumi).where(\n            and_(\n                Bangumi.deleted == false(),\n                Bangumi.archived == false(),\n            )\n        )\n        result = self.session.execute(statement)\n        return list(result.scalars().all())\n\n    def set_needs_review(\n        self,\n        _id: int,\n        reason: str,\n        suggested_season_offset: int | None = None,\n        suggested_episode_offset: int | None = None,\n    ) -> bool:\n        \"\"\"Mark a bangumi as needing review with suggested offsets.\n\n        Args:\n            _id: The bangumi ID\n            reason: Human-readable reason for the review\n            suggested_season_offset: Suggested season offset value\n            suggested_episode_offset: Suggested episode offset value\n        \"\"\"\n        bangumi = self.session.get(Bangumi, _id)\n        if not bangumi:\n            return False\n        bangumi.needs_review = True\n        bangumi.needs_review_reason = reason\n        bangumi.suggested_season_offset = suggested_season_offset\n        bangumi.suggested_episode_offset = suggested_episode_offset\n        self.session.add(bangumi)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\n            \"[Database] Marked bangumi id %s as needs_review: %s \"\n            \"(suggested: season=%s, episode=%s)\",\n            _id,\n            reason,\n            suggested_season_offset,\n            suggested_episode_offset,\n        )\n        return True\n\n    def clear_needs_review(self, _id: int) -> bool:\n        \"\"\"Clear the needs_review flag and suggested offsets for a bangumi.\"\"\"\n        bangumi = self.session.get(Bangumi, _id)\n        if not bangumi:\n            return False\n        bangumi.needs_review = False\n        bangumi.needs_review_reason = None\n        bangumi.suggested_season_offset = None\n        bangumi.suggested_episode_offset = None\n        self.session.add(bangumi)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\"[Database] Cleared needs_review for bangumi id %s\", _id)\n        return True\n\n    def set_weekday(self, _id: int, weekday: int | None) -> bool:\n        \"\"\"Set air_weekday and weekday_locked for manual calendar assignment.\"\"\"\n        bangumi = self.session.get(Bangumi, _id)\n        if not bangumi:\n            return False\n        if weekday is not None:\n            bangumi.air_weekday = weekday\n            bangumi.weekday_locked = True\n        else:\n            bangumi.air_weekday = None\n            bangumi.weekday_locked = False\n        self.session.add(bangumi)\n        self.session.commit()\n        _invalidate_bangumi_cache()\n        logger.debug(\n            \"[Database] Set weekday=%s, locked=%s for bangumi id %s\",\n            weekday,\n            bangumi.weekday_locked,\n            _id,\n        )\n        return True\n"
  },
  {
    "path": "backend/src/module/database/combine.py",
    "content": "import logging\nfrom typing import Any, get_args, get_origin\n\nfrom pydantic.fields import FieldInfo\nfrom pydantic_core import PydanticUndefined\nfrom sqlalchemy import inspect, text\nfrom sqlmodel import Session, SQLModel\n\nfrom module.models import Bangumi, User\nfrom module.models.passkey import Passkey\nfrom module.models.rss import RSSItem\nfrom module.models.torrent import Torrent\n\nfrom .bangumi import BangumiDatabase\nfrom .engine import engine as e\nfrom .rss import RSSDatabase\nfrom .torrent import TorrentDatabase\nfrom .user import UserDatabase\n\nlogger = logging.getLogger(__name__)\n\n# 所有需要进行空值填充的表模型\nTABLE_MODELS: list[type[SQLModel]] = [Bangumi, RSSItem, Torrent, User, Passkey]\n\n# Increment this when adding new migrations to MIGRATIONS list.\nCURRENT_SCHEMA_VERSION = 9\n\n# Each migration is a tuple of (version, description, list of SQL statements).\n# Migrations are applied in order. A migration at index i brings the schema\n# from version i to version i+1.\nMIGRATIONS = [\n    (\n        1,\n        \"add air_weekday column to bangumi\",\n        [\"ALTER TABLE bangumi ADD COLUMN air_weekday INTEGER\"],\n    ),\n    (\n        2,\n        \"add connection status columns to rssitem\",\n        [\n            \"ALTER TABLE rssitem ADD COLUMN connection_status TEXT\",\n            \"ALTER TABLE rssitem ADD COLUMN last_checked_at TEXT\",\n            \"ALTER TABLE rssitem ADD COLUMN last_error TEXT\",\n        ],\n    ),\n    (\n        3,\n        \"create passkey table for WebAuthn support\",\n        [\n            \"\"\"CREATE TABLE IF NOT EXISTS passkey (\n                id INTEGER PRIMARY KEY,\n                user_id INTEGER NOT NULL REFERENCES user(id),\n                name VARCHAR(64) NOT NULL,\n                credential_id VARCHAR NOT NULL UNIQUE,\n                public_key VARCHAR NOT NULL,\n                sign_count INTEGER DEFAULT 0,\n                aaguid VARCHAR,\n                transports VARCHAR,\n                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,\n                last_used_at TIMESTAMP,\n                backup_eligible BOOLEAN DEFAULT 0,\n                backup_state BOOLEAN DEFAULT 0\n            )\"\"\",\n            \"CREATE INDEX IF NOT EXISTS ix_passkey_user_id ON passkey(user_id)\",\n            \"CREATE UNIQUE INDEX IF NOT EXISTS ix_passkey_credential_id ON passkey(credential_id)\",\n        ],\n    ),\n    (\n        4,\n        \"add archived column to bangumi\",\n        [\"ALTER TABLE bangumi ADD COLUMN archived BOOLEAN DEFAULT 0\"],\n    ),\n    (\n        5,\n        \"rename offset to episode_offset, add season_offset and review fields\",\n        [\n            \"ALTER TABLE bangumi RENAME COLUMN offset TO episode_offset\",\n            \"ALTER TABLE bangumi ADD COLUMN season_offset INTEGER DEFAULT 0\",\n            \"ALTER TABLE bangumi ADD COLUMN needs_review INTEGER DEFAULT 0\",\n            \"ALTER TABLE bangumi ADD COLUMN needs_review_reason TEXT DEFAULT NULL\",\n        ],\n    ),\n    (\n        6,\n        \"add qb_hash column to torrent for downloader tracking\",\n        [\n            \"ALTER TABLE torrent ADD COLUMN qb_hash TEXT\",\n            \"CREATE INDEX IF NOT EXISTS ix_torrent_qb_hash ON torrent(qb_hash)\",\n        ],\n    ),\n    (\n        7,\n        \"add suggested offset columns for offset review\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN suggested_season_offset INTEGER DEFAULT NULL\",\n            \"ALTER TABLE bangumi ADD COLUMN suggested_episode_offset INTEGER DEFAULT NULL\",\n        ],\n    ),\n    (\n        8,\n        \"add title_aliases for mid-season naming changes\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN title_aliases TEXT DEFAULT NULL\",\n        ],\n    ),\n    (\n        9,\n        \"add weekday_locked column to bangumi\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN weekday_locked BOOLEAN DEFAULT 0\",\n        ],\n    ),\n]\n\n\nclass Database(Session):\n    def __init__(self, engine=e):\n        self.engine = engine\n        super().__init__(engine)\n        self.rss = RSSDatabase(self)\n        self.torrent = TorrentDatabase(self)\n        self.bangumi = BangumiDatabase(self)\n        self.user = UserDatabase(self)\n\n    def create_table(self):\n        SQLModel.metadata.create_all(self.engine)\n        self._ensure_schema_version_table()\n\n    def _ensure_schema_version_table(self):\n        \"\"\"Create the schema_version table if it doesn't exist.\"\"\"\n        with self.engine.connect() as conn:\n            conn.execute(\n                text(\n                    \"CREATE TABLE IF NOT EXISTS schema_version (\"\n                    \"  id INTEGER PRIMARY KEY,\"\n                    \"  version INTEGER NOT NULL\"\n                    \")\"\n                )\n            )\n            conn.commit()\n\n    def _get_schema_version(self) -> int:\n        \"\"\"Get the current schema version from the database.\"\"\"\n        inspector = inspect(self.engine)\n        if \"schema_version\" not in inspector.get_table_names():\n            return 0\n        with self.engine.connect() as conn:\n            result = conn.execute(\n                text(\"SELECT version FROM schema_version WHERE id = 1\")\n            )\n            row = result.fetchone()\n            return row[0] if row else 0\n\n    def _set_schema_version(self, version: int):\n        \"\"\"Update the schema version in the database.\"\"\"\n        with self.engine.connect() as conn:\n            conn.execute(\n                text(\n                    \"INSERT OR REPLACE INTO schema_version (id, version) VALUES (1, :version)\"\n                ),\n                {\"version\": version},\n            )\n            conn.commit()\n\n    def run_migrations(self):\n        \"\"\"Run pending schema migrations based on the stored schema version.\"\"\"\n        self._ensure_schema_version_table()\n        current = self._get_schema_version()\n        if current >= CURRENT_SCHEMA_VERSION:\n            return\n        inspector = inspect(self.engine)\n        tables = inspector.get_table_names()\n        for version, description, statements in MIGRATIONS:\n            if version <= current:\n                continue\n            # Check if migration is actually needed (column may already exist)\n            needs_run = True\n            if \"bangumi\" in tables and version == 1:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"air_weekday\" in columns:\n                    needs_run = False\n            if \"rssitem\" in tables and version == 2:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"rssitem\")]\n                if \"connection_status\" in columns:\n                    needs_run = False\n            if version == 3 and \"passkey\" in tables:\n                needs_run = False\n            if \"bangumi\" in tables and version == 4:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"archived\" in columns:\n                    needs_run = False\n            if \"bangumi\" in tables and version == 5:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"episode_offset\" in columns:\n                    needs_run = False\n            if \"torrent\" in tables and version == 6:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"torrent\")]\n                if \"qb_hash\" in columns:\n                    needs_run = False\n            if \"bangumi\" in tables and version == 7:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"suggested_season_offset\" in columns:\n                    needs_run = False\n            if \"bangumi\" in tables and version == 8:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"title_aliases\" in columns:\n                    needs_run = False\n            if \"bangumi\" in tables and version == 9:\n                columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n                if \"weekday_locked\" in columns:\n                    needs_run = False\n            if needs_run:\n                try:\n                    with self.engine.connect() as conn:\n                        for stmt in statements:\n                            conn.execute(text(stmt))\n                        conn.commit()\n                    logger.info(f\"[Database] Migration v{version}: {description}\")\n                except Exception as e:\n                    logger.error(f\"[Database] Migration v{version} failed: {e}\")\n                    break\n            else:\n                logger.debug(\n                    f\"[Database] Migration v{version} skipped (already applied): {description}\"\n                )\n            self._set_schema_version(version)\n        logger.info(f\"[Database] Schema version is now {self._get_schema_version()}.\")\n        self._fill_null_with_defaults()\n\n    def _get_field_default(self, field_info: FieldInfo) -> tuple[bool, Any]:\n        \"\"\"\n        获取字段的默认值。\n\n        返回:\n            (has_default, default_value) - 是否有可用的默认值，以及默认值\n        \"\"\"\n        # 跳过 default_factory（如 datetime.utcnow），不适合批量填充\n        if field_info.default_factory is not None:\n            return False, None\n        # 跳过没有默认值的字段（PydanticUndefined）\n        if field_info.default is PydanticUndefined:\n            return False, None\n        return True, field_info.default\n\n    def _is_optional_field(self, model: type[SQLModel], field_name: str) -> bool:\n        \"\"\"检查字段是否为 Optional 类型\"\"\"\n        hints = model.__annotations__.get(field_name)\n        if hints is None:\n            return False\n        origin = get_origin(hints)\n        # Optional[X] 等同于 Union[X, None]\n        if origin is not None:\n            args = get_args(hints)\n            return type(None) in args\n        return False\n\n    def _fill_null_with_defaults(self):\n        \"\"\"\n        根据模型定义的默认值，自动填充数据库中的 NULL 值。\n\n        规则：\n        - 跳过主键字段\n        - 跳过 Optional 字段且默认值为 None 的情况\n        - 跳过使用 default_factory 的字段\n        - 只填充有明确非 None 默认值的字段\n        \"\"\"\n        inspector = inspect(self.engine)\n        tables = inspector.get_table_names()\n\n        for model in TABLE_MODELS:\n            table_name = model.__tablename__\n            if table_name not in tables:\n                continue\n\n            db_columns = {col[\"name\"] for col in inspector.get_columns(table_name)}\n            fields_to_fill: list[tuple[str, Any]] = []\n\n            for field_name, field_info in model.model_fields.items():\n                # 跳过主键\n                if field_info.is_required() and field_name == \"id\":\n                    continue\n                # 跳过数据库中不存在的列\n                if field_name not in db_columns:\n                    continue\n\n                has_default, default_value = self._get_field_default(field_info)\n                if not has_default:\n                    continue\n                # 如果是 Optional 且默认值为 None，跳过\n                if default_value is None and self._is_optional_field(model, field_name):\n                    continue\n\n                fields_to_fill.append((field_name, default_value))\n\n            if not fields_to_fill:\n                continue\n\n            with self.engine.connect() as conn:\n                for field_name, default_value in fields_to_fill:\n                    # 转换 Python 值为 SQL 值\n                    if isinstance(default_value, bool):\n                        sql_value = 1 if default_value else 0\n                    else:\n                        sql_value = default_value\n\n                    result = conn.execute(\n                        text(\n                            f'UPDATE \"{table_name}\" SET \"{field_name}\" = :val '\n                            f'WHERE \"{field_name}\" IS NULL'\n                        ),\n                        {\"val\": sql_value},\n                    )\n                    if result.rowcount > 0:\n                        logger.info(\n                            f\"[Database] Filled {result.rowcount} NULL values \"\n                            f\"in {table_name}.{field_name} with default: {default_value}\"\n                        )\n                conn.commit()\n\n    def drop_table(self):\n        SQLModel.metadata.drop_all(self.engine)\n\n    def migrate(self):\n        # Run migration online\n        bangumi_data = self.bangumi.search_all()\n        user_data = self.exec(\"SELECT * FROM user\").all()\n        if not user_data:\n            logger.warning(\"[Database] No user data found, skipping migration.\")\n            return\n        readd_bangumi = []\n        for bangumi in bangumi_data:\n            dict_data = bangumi.dict()\n            del dict_data[\"id\"]\n            readd_bangumi.append(Bangumi(**dict_data))\n        self.drop_table()\n        self.create_table()\n        self.commit()\n        try:\n            self.bangumi.add_all(readd_bangumi)\n            self.add(User(**user_data[0]))\n            self.commit()\n        except Exception:\n            self.rollback()\n            raise\n"
  },
  {
    "path": "backend/src/module/database/engine.py",
    "content": "from sqlalchemy import event\nfrom sqlalchemy.ext.asyncio import AsyncSession, create_async_engine\nfrom sqlalchemy.orm import sessionmaker\nfrom sqlmodel import create_engine\n\nfrom module.conf import DATA_PATH\n\n# Sync engine (used by Database which extends Session)\nengine = create_engine(DATA_PATH)\n\n# Async engine (for passkey operations)\nASYNC_DATA_PATH = DATA_PATH.replace(\"sqlite:///\", \"sqlite+aiosqlite:///\")\nasync_engine = create_async_engine(ASYNC_DATA_PATH)\nasync_session_factory = sessionmaker(\n    async_engine, class_=AsyncSession, expire_on_commit=False\n)\n\n\n@event.listens_for(engine, \"connect\")\ndef _set_sqlite_fk_sync(dbapi_conn, connection_record):\n    cursor = dbapi_conn.cursor()\n    cursor.execute(\"PRAGMA foreign_keys=ON\")\n    cursor.close()\n\n\n@event.listens_for(async_engine.sync_engine, \"connect\")\ndef _set_sqlite_fk_async(dbapi_conn, connection_record):\n    cursor = dbapi_conn.cursor()\n    cursor.execute(\"PRAGMA foreign_keys=ON\")\n    cursor.close()\n"
  },
  {
    "path": "backend/src/module/database/passkey.py",
    "content": "\"\"\"\nPasskey 数据库操作层\n\"\"\"\n\nimport logging\nfrom datetime import datetime, timezone\nfrom typing import List, Optional\n\nfrom fastapi import HTTPException\nfrom sqlalchemy.ext.asyncio import AsyncSession\nfrom sqlmodel import select\n\nfrom module.models.passkey import Passkey, PasskeyList\n\nlogger = logging.getLogger(__name__)\n\n\nclass PasskeyDatabase:\n    def __init__(self, session: AsyncSession):\n        self.session = session\n\n    async def create_passkey(self, passkey: Passkey) -> Passkey:\n        \"\"\"创建新的 Passkey 凭证\"\"\"\n        self.session.add(passkey)\n        await self.session.commit()\n        await self.session.refresh(passkey)\n        logger.info(f\"Created passkey '{passkey.name}' for user_id={passkey.user_id}\")\n        return passkey\n\n    async def get_passkey_by_credential_id(\n        self, credential_id: str\n    ) -> Optional[Passkey]:\n        \"\"\"通过 credential_id 查找 Passkey（用于认证）\"\"\"\n        statement = select(Passkey).where(Passkey.credential_id == credential_id)\n        result = await self.session.execute(statement)\n        return result.scalar_one_or_none()\n\n    async def get_passkeys_by_user_id(self, user_id: int) -> List[Passkey]:\n        \"\"\"获取用户的所有 Passkey\"\"\"\n        statement = select(Passkey).where(Passkey.user_id == user_id)\n        result = await self.session.execute(statement)\n        return list(result.scalars().all())\n\n    async def get_passkey_by_id(self, passkey_id: int, user_id: int) -> Passkey:\n        \"\"\"获取特定 Passkey（带权限检查）\"\"\"\n        statement = select(Passkey).where(\n            Passkey.id == passkey_id, Passkey.user_id == user_id\n        )\n        result = await self.session.execute(statement)\n        passkey = result.scalar_one_or_none()\n        if not passkey:\n            raise HTTPException(status_code=404, detail=\"Passkey not found\")\n        return passkey\n\n    async def update_passkey_usage(self, passkey: Passkey, new_sign_count: int):\n        \"\"\"更新 Passkey 使用记录（签名计数器 + 最后使用时间）\"\"\"\n        passkey.sign_count = new_sign_count\n        passkey.last_used_at = datetime.now(timezone.utc)\n        self.session.add(passkey)\n        await self.session.commit()\n\n    async def delete_passkey(self, passkey_id: int, user_id: int) -> bool:\n        \"\"\"删除 Passkey\"\"\"\n        passkey = await self.get_passkey_by_id(passkey_id, user_id)\n        await self.session.delete(passkey)\n        await self.session.commit()\n        logger.info(f\"Deleted passkey id={passkey_id} for user_id={user_id}\")\n        return True\n\n    def to_list_model(self, passkey: Passkey) -> PasskeyList:\n        \"\"\"转换为安全的列表展示模型\"\"\"\n        return PasskeyList(\n            id=passkey.id,\n            name=passkey.name,\n            created_at=passkey.created_at,\n            last_used_at=passkey.last_used_at,\n            backup_eligible=passkey.backup_eligible,\n            aaguid=passkey.aaguid,\n        )\n"
  },
  {
    "path": "backend/src/module/database/rss.py",
    "content": "import logging\n\nfrom sqlmodel import Session, and_, delete, select\n\nfrom module.models import RSSItem, RSSUpdate\n\nlogger = logging.getLogger(__name__)\n\n\nclass RSSDatabase:\n    def __init__(self, session: Session):\n        self.session = session\n\n    def add(self, data: RSSItem) -> bool:\n        statement = select(RSSItem).where(RSSItem.url == data.url)\n        result = self.session.execute(statement)\n        db_data = result.scalar_one_or_none()\n        if db_data:\n            logger.debug(\"RSS Item %s already exists.\", data.url)\n            return False\n        else:\n            logger.debug(\"RSS Item %s not exists, adding...\", data.url)\n            self.session.add(data)\n            self.session.commit()\n            self.session.refresh(data)\n            return True\n\n    def add_all(self, data: list[RSSItem]):\n        if not data:\n            return\n        urls = [item.url for item in data]\n        statement = select(RSSItem.url).where(RSSItem.url.in_(urls))\n        result = self.session.execute(statement)\n        existing_urls = set(result.scalars().all())\n        new_items = [item for item in data if item.url not in existing_urls]\n        if new_items:\n            self.session.add_all(new_items)\n            self.session.commit()\n            logger.debug(\"Batch inserted %s RSS items.\", len(new_items))\n\n    def update(self, _id: int, data: RSSUpdate) -> bool:\n        statement = select(RSSItem).where(RSSItem.id == _id)\n        result = self.session.execute(statement)\n        db_data = result.scalar_one_or_none()\n        if not db_data:\n            return False\n        dict_data = data.dict(exclude_unset=True)\n        for key, value in dict_data.items():\n            setattr(db_data, key, value)\n        self.session.add(db_data)\n        self.session.commit()\n        return True\n\n    def enable(self, _id: int) -> bool:\n        statement = select(RSSItem).where(RSSItem.id == _id)\n        result = self.session.execute(statement)\n        db_data = result.scalar_one_or_none()\n        if not db_data:\n            return False\n        db_data.enabled = True\n        self.session.add(db_data)\n        self.session.commit()\n        return True\n\n    def enable_batch(self, ids: list[int]):\n        statement = select(RSSItem).where(RSSItem.id.in_(ids))\n        result = self.session.execute(statement)\n        for item in result.scalars().all():\n            item.enabled = True\n        self.session.commit()\n\n    def disable(self, _id: int) -> bool:\n        statement = select(RSSItem).where(RSSItem.id == _id)\n        result = self.session.execute(statement)\n        db_data = result.scalar_one_or_none()\n        if not db_data:\n            return False\n        db_data.enabled = False\n        self.session.add(db_data)\n        self.session.commit()\n        return True\n\n    def disable_batch(self, ids: list[int]):\n        statement = select(RSSItem).where(RSSItem.id.in_(ids))\n        result = self.session.execute(statement)\n        for item in result.scalars().all():\n            item.enabled = False\n        self.session.commit()\n\n    def search_id(self, _id: int) -> RSSItem | None:\n        return self.session.get(RSSItem, _id)\n\n    def search_all(self) -> list[RSSItem]:\n        result = self.session.execute(select(RSSItem))\n        return list(result.scalars().all())\n\n    def search_active(self) -> list[RSSItem]:\n        result = self.session.execute(\n            select(RSSItem).where(RSSItem.enabled)\n        )\n        return list(result.scalars().all())\n\n    def search_aggregate(self) -> list[RSSItem]:\n        result = self.session.execute(\n            select(RSSItem).where(and_(RSSItem.aggregate, RSSItem.enabled))\n        )\n        return list(result.scalars().all())\n\n    def delete(self, _id: int) -> bool:\n        condition = delete(RSSItem).where(RSSItem.id == _id)\n        try:\n            self.session.execute(condition)\n            self.session.commit()\n            return True\n        except Exception as e:\n            logger.error(f\"Delete RSS Item failed. Because: {e}\")\n            return False\n\n    def delete_all(self):\n        condition = delete(RSSItem)\n        self.session.execute(condition)\n        self.session.commit()\n"
  },
  {
    "path": "backend/src/module/database/torrent.py",
    "content": "import logging\n\nfrom sqlmodel import Session, select\n\nfrom module.models import Torrent\n\nlogger = logging.getLogger(__name__)\n\n\nclass TorrentDatabase:\n    def __init__(self, session: Session):\n        self.session = session\n\n    def add(self, data: Torrent):\n        self.session.add(data)\n        self.session.commit()\n        logger.debug(\"Insert %s in database.\", data.name)\n\n    def add_all(self, datas: list[Torrent]):\n        self.session.add_all(datas)\n        self.session.commit()\n        logger.debug(\"Insert %s torrents in database.\", len(datas))\n\n    def update(self, data: Torrent):\n        self.session.add(data)\n        self.session.commit()\n        logger.debug(\"Update %s in database.\", data.name)\n\n    def update_all(self, datas: list[Torrent]):\n        self.session.add_all(datas)\n        self.session.commit()\n\n    def update_one_user(self, data: Torrent):\n        self.session.add(data)\n        self.session.commit()\n        logger.debug(\"Update %s in database.\", data.name)\n\n    def search(self, _id: int) -> Torrent | None:\n        result = self.session.execute(select(Torrent).where(Torrent.id == _id))\n        return result.scalar_one_or_none()\n\n    def search_all(self) -> list[Torrent]:\n        result = self.session.execute(select(Torrent))\n        return list(result.scalars().all())\n\n    def search_rss(self, rss_id: int) -> list[Torrent]:\n        result = self.session.execute(select(Torrent).where(Torrent.rss_id == rss_id))\n        return list(result.scalars().all())\n\n    def check_new(self, torrents_list: list[Torrent]) -> list[Torrent]:\n        if not torrents_list:\n            return []\n        urls = [t.url for t in torrents_list]\n        statement = select(Torrent.url).where(Torrent.url.in_(urls))\n        result = self.session.execute(statement)\n        existing_urls = set(result.scalars().all())\n        return [t for t in torrents_list if t.url not in existing_urls]\n\n    def search_by_qb_hash(self, qb_hash: str) -> Torrent | None:\n        \"\"\"Find torrent by qBittorrent hash.\"\"\"\n        result = self.session.execute(select(Torrent).where(Torrent.qb_hash == qb_hash))\n        return result.scalar_one_or_none()\n\n    def search_by_qb_hashes(self, qb_hashes: list[str]) -> list[Torrent]:\n        \"\"\"Find torrents by multiple qBittorrent hashes (batch query).\"\"\"\n        if not qb_hashes:\n            return []\n        result = self.session.execute(\n            select(Torrent).where(Torrent.qb_hash.in_(qb_hashes))\n        )\n        return list(result.scalars().all())\n\n    def delete_by_bangumi_id(self, bangumi_id: int) -> int:\n        \"\"\"Delete all torrent records associated with a bangumi.\n\n        Returns the number of deleted records.\n        \"\"\"\n        statement = select(Torrent).where(Torrent.bangumi_id == bangumi_id)\n        result = self.session.execute(statement)\n        torrents = list(result.scalars().all())\n        count = len(torrents)\n        for t in torrents:\n            self.session.delete(t)\n        if count > 0:\n            self.session.commit()\n            logger.debug(\"Deleted %s torrent records for bangumi_id %s.\", count, bangumi_id)\n        return count\n\n    def search_by_url(self, url: str) -> Torrent | None:\n        \"\"\"Find torrent by URL.\"\"\"\n        result = self.session.execute(select(Torrent).where(Torrent.url == url))\n        return result.scalar_one_or_none()\n\n    def update_qb_hash(self, torrent_id: int, qb_hash: str) -> bool:\n        \"\"\"Update the qb_hash for a torrent.\"\"\"\n        torrent = self.search(torrent_id)\n        if torrent:\n            torrent.qb_hash = qb_hash\n            self.session.add(torrent)\n            self.session.commit()\n            logger.debug(\"Updated qb_hash for torrent %s: %s\", torrent_id, qb_hash)\n            return True\n        return False\n"
  },
  {
    "path": "backend/src/module/database/user.py",
    "content": "import logging\n\nfrom fastapi import HTTPException\nfrom sqlmodel import Session, select\n\nfrom module.models import ResponseModel\nfrom module.models.user import User, UserUpdate\nfrom module.security.jwt import get_password_hash, verify_password\n\nlogger = logging.getLogger(__name__)\n\n\nclass UserDatabase:\n    def __init__(self, session: Session):\n        self.session = session\n\n    def get_user(self, username: str) -> User:\n        statement = select(User).where(User.username == username)\n        result = self.session.exec(statement)\n        user = result.first()\n        if not user:\n            raise HTTPException(status_code=404, detail=\"User not found\")\n        return user\n\n    def auth_user(self, user: User) -> ResponseModel:\n        statement = select(User).where(User.username == user.username)\n        result = self.session.exec(statement)\n        db_user = result.first()\n        if not user.password:\n            return ResponseModel(\n                status_code=401,\n                status=False,\n                msg_en=\"Incorrect password format\",\n                msg_zh=\"密码格式不正确\",\n            )\n        if not db_user:\n            return ResponseModel(\n                status_code=401,\n                status=False,\n                msg_en=\"User not found\",\n                msg_zh=\"用户不存在\",\n            )\n        if not verify_password(user.password, db_user.password):\n            return ResponseModel(\n                status_code=401,\n                status=False,\n                msg_en=\"Incorrect password\",\n                msg_zh=\"密码错误\",\n            )\n        return ResponseModel(\n            status_code=200,\n            status=True,\n            msg_en=\"Login successfully\",\n            msg_zh=\"登录成功\",\n        )\n\n    def update_user(self, username: str, update_user: UserUpdate) -> User:\n        statement = select(User).where(User.username == username)\n        result = self.session.exec(statement)\n        db_user = result.first()\n        if not db_user:\n            raise HTTPException(status_code=404, detail=\"User not found\")\n        if update_user.username:\n            db_user.username = update_user.username\n        if update_user.password:\n            db_user.password = get_password_hash(update_user.password)\n        self.session.add(db_user)\n        self.session.commit()\n        return db_user\n\n    def add_default_user(self):\n        statement = select(User)\n        try:\n            result = self.session.exec(statement)\n            users = list(result.all())\n        except Exception as e:\n            # Table may not exist yet during initial setup\n            logger.debug(\n                f\"[Database] Could not query users table (may not exist yet): {e}\"\n            )\n            users = []\n        if len(users) != 0:\n            return\n        user = User(username=\"admin\", password=get_password_hash(\"adminadmin\"))\n        self.session.add(user)\n        self.session.commit()\n        logger.info(\"[Database] Created default admin user\")\n"
  },
  {
    "path": "backend/src/module/downloader/__init__.py",
    "content": "from .download_client import DownloadClient\n"
  },
  {
    "path": "backend/src/module/downloader/client/__init__.py",
    "content": ""
  },
  {
    "path": "backend/src/module/downloader/client/aria2_downloader.py",
    "content": "import asyncio\nimport logging\n\nimport httpx\n\nfrom module.conf import settings\n\nlogger = logging.getLogger(__name__)\n\n\nclass Aria2Downloader:\n    def __init__(self, host: str, username: str, password: str):\n        self.host = host\n        self.secret = password\n        self._client: httpx.AsyncClient | None = None\n        self._rpc_url = f\"{host}/jsonrpc\"\n        self._id = 0\n\n    async def _call(self, method: str, params: list = None):\n        self._id += 1\n        if params is None:\n            params = []\n        # Prepend token\n        full_params = [f\"token:{self.secret}\"] + params\n        payload = {\n            \"jsonrpc\": \"2.0\",\n            \"id\": self._id,\n            \"method\": f\"aria2.{method}\",\n            \"params\": full_params,\n        }\n        resp = await self._client.post(self._rpc_url, json=payload)\n        result = resp.json()\n        if \"error\" in result:\n            raise Exception(f\"Aria2 RPC error: {result['error']}\")\n        return result.get(\"result\")\n\n    async def auth(self, retry=3):\n        self._client = httpx.AsyncClient(\n            timeout=httpx.Timeout(connect=3.1, read=10.0, write=10.0, pool=10.0)\n        )\n        times = 0\n        while times < retry:\n            try:\n                await self._call(\"getVersion\")\n                return True\n            except Exception as e:\n                logger.warning(\n                    f\"Can't login Aria2 Server {self.host}, retry in 5 seconds. Error: {e}\"\n                )\n                await asyncio.sleep(5)\n                times += 1\n        return False\n\n    async def logout(self):\n        if self._client:\n            await self._client.aclose()\n            self._client = None\n\n    async def torrents_files(self, torrent_hash: str):\n        return []\n\n    async def add_torrents(\n        self, torrent_urls, torrent_files, save_path, category, tags=None\n    ):\n        import base64\n\n        options = {\"dir\": save_path}\n        if torrent_urls:\n            urls = torrent_urls if isinstance(torrent_urls, list) else [torrent_urls]\n            for url in urls:\n                await self._call(\"addUri\", [[url], options])\n        if torrent_files:\n            files = (\n                torrent_files if isinstance(torrent_files, list) else [torrent_files]\n            )\n            for f in files:\n                b64 = base64.b64encode(f).decode()\n                await self._call(\"addTorrent\", [b64, [], options])\n        return True\n\n    async def check_host(self):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def prefs_init(self, prefs):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def get_app_prefs(self):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def add_category(self, category):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def torrents_info(self, status_filter, category, tag=None):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def get_torrents_by_tag(self, tag: str) -> list[dict]:\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def torrents_delete(self, hash, delete_files: bool = True):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def torrents_pause(self, hashes: str):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def torrents_resume(self, hashes: str):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def torrents_rename_file(\n        self, torrent_hash, old_path, new_path, verify: bool = True\n    ) -> bool:\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def rss_add_feed(self, url, item_path):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def rss_remove_item(self, item_path):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def rss_get_feeds(self):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def rss_set_rule(self, rule_name, rule_def):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def move_torrent(self, hashes, new_location):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def get_download_rule(self):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def get_torrent_path(self, _hash):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def set_category(self, _hash, category):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def check_connection(self):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def remove_rule(self, rule_name):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n\n    async def add_tag(self, _hash, tag):\n        raise NotImplementedError(\"Aria2 does not support this operation\")\n"
  },
  {
    "path": "backend/src/module/downloader/client/mock_downloader.py",
    "content": "\"\"\"\nMock Downloader for local development and testing.\n\nThis downloader simulates qBittorrent behavior without requiring an actual\nqBittorrent instance. All operations return success and log their actions.\n\"\"\"\n\nimport logging\nfrom typing import Any\n\nlogger = logging.getLogger(__name__)\n\n\nclass MockDownloader:\n    \"\"\"\n    A mock downloader that simulates qBittorrent API responses.\n    All methods return success values and log their operations.\n    \"\"\"\n\n    def __init__(self):\n        self._torrents: dict[str, dict] = {}\n        self._rules: dict[str, dict] = {}\n        self._feeds: dict[str, dict] = {}\n        self._categories: set[str] = {\"Bangumi\", \"BangumiCollection\"}\n        self._prefs = {\n            \"save_path\": \"/tmp/mock-downloads\",\n            \"rss_auto_downloading_enabled\": True,\n            \"rss_max_articles_per_feed\": 500,\n            \"rss_processing_enabled\": True,\n            \"rss_refresh_interval\": 30,\n        }\n        logger.debug(\"[MockDownloader] Initialized\")\n\n    async def auth(self, retry=3) -> bool:\n        logger.debug(\"[MockDownloader] Auth successful (mocked)\")\n        return True\n\n    async def logout(self):\n        logger.debug(\"[MockDownloader] Logout (mocked)\")\n\n    async def check_host(self) -> bool:\n        logger.debug(\"[MockDownloader] check_host -> True\")\n        return True\n\n    async def prefs_init(self, prefs: dict):\n        self._prefs.update(prefs)\n        logger.debug(\"[MockDownloader] prefs_init: %s\", prefs)\n\n    async def get_app_prefs(self) -> dict:\n        logger.debug(\"[MockDownloader] get_app_prefs\")\n        return self._prefs\n\n    async def add_category(self, category: str):\n        self._categories.add(category)\n        logger.debug(\"[MockDownloader] add_category: %s\", category)\n\n    async def torrents_info(\n        self, status_filter: str | None, category: str | None, tag: str | None = None\n    ) -> list[dict]:\n        \"\"\"Return list of torrents matching the filter.\"\"\"\n        logger.debug(\n            \"[MockDownloader] torrents_info(filter=%s, category=%s, tag=%s)\",\n            status_filter,\n            category,\n            tag,\n        )\n        result = []\n        for hash_, torrent in self._torrents.items():\n            if category and torrent.get(\"category\") != category:\n                continue\n            if tag and tag not in torrent.get(\"tags\", []):\n                continue\n            result.append(torrent)\n        return result\n\n    async def torrents_files(self, torrent_hash: str) -> list[dict]:\n        \"\"\"Return files for a torrent.\"\"\"\n        logger.debug(\"[MockDownloader] torrents_files(%s)\", torrent_hash)\n        torrent = self._torrents.get(torrent_hash, {})\n        return torrent.get(\"files\", [])\n\n    async def add_torrents(\n        self,\n        torrent_urls: str | list | None,\n        torrent_files: bytes | list | None,\n        save_path: str,\n        category: str,\n        tags: str | None = None,\n    ) -> bool:\n        \"\"\"Add a torrent. Returns True for success.\"\"\"\n        import hashlib\n        import time\n\n        # Generate a mock hash\n        content = str(torrent_urls or torrent_files or time.time())\n        mock_hash = hashlib.sha1(content.encode()).hexdigest()\n\n        self._torrents[mock_hash] = {\n            \"hash\": mock_hash,\n            \"name\": f\"mock_torrent_{mock_hash[:8]}\",\n            \"save_path\": save_path,\n            \"category\": category,\n            \"state\": \"downloading\",\n            \"progress\": 0.0,\n            \"files\": [],\n            \"tags\": tags or \"\",\n        }\n        logger.info(\n            f\"[MockDownloader] add_torrents -> hash={mock_hash[:16]}... save_path={save_path}\"\n        )\n        return True\n\n    async def torrents_delete(self, hash: str, delete_files: bool = True):\n        hashes = hash.split(\"|\") if \"|\" in hash else [hash]\n        for h in hashes:\n            self._torrents.pop(h, None)\n        logger.debug(\n            \"[MockDownloader] torrents_delete(%s, delete_files=%s)\", hash, delete_files\n        )\n\n    async def torrents_pause(self, hashes: str):\n        for h in hashes.split(\"|\"):\n            if h in self._torrents:\n                self._torrents[h][\"state\"] = \"paused\"\n        logger.debug(\"[MockDownloader] torrents_pause(%s)\", hashes)\n\n    async def torrents_resume(self, hashes: str):\n        for h in hashes.split(\"|\"):\n            if h in self._torrents:\n                self._torrents[h][\"state\"] = \"downloading\"\n        logger.debug(\"[MockDownloader] torrents_resume(%s)\", hashes)\n\n    async def torrents_rename_file(\n        self, torrent_hash: str, old_path: str, new_path: str, verify: bool = True\n    ) -> bool:\n        logger.info(f\"[MockDownloader] rename: {old_path} -> {new_path}\")\n        return True\n\n    async def rss_add_feed(self, url: str, item_path: str):\n        self._feeds[item_path] = {\"url\": url, \"path\": item_path}\n        logger.debug(\"[MockDownloader] rss_add_feed(%s, %s)\", url, item_path)\n\n    async def rss_remove_item(self, item_path: str):\n        self._feeds.pop(item_path, None)\n        logger.debug(\"[MockDownloader] rss_remove_item(%s)\", item_path)\n\n    async def rss_get_feeds(self) -> dict:\n        logger.debug(\"[MockDownloader] rss_get_feeds\")\n        return self._feeds\n\n    async def rss_set_rule(self, rule_name: str, rule_def: dict):\n        self._rules[rule_name] = rule_def\n        logger.info(f\"[MockDownloader] rss_set_rule({rule_name})\")\n\n    async def move_torrent(self, hashes: str, new_location: str):\n        for h in hashes.split(\"|\"):\n            if h in self._torrents:\n                self._torrents[h][\"save_path\"] = new_location\n        logger.debug(\"[MockDownloader] move_torrent(%s, %s)\", hashes, new_location)\n\n    async def get_download_rule(self) -> dict:\n        logger.debug(\"[MockDownloader] get_download_rule\")\n        return self._rules\n\n    async def get_torrent_path(self, _hash: str) -> str:\n        torrent = self._torrents.get(_hash, {})\n        path = torrent.get(\"save_path\", \"/tmp/mock-downloads\")\n        logger.debug(\"[MockDownloader] get_torrent_path(%s) -> %s\", _hash, path)\n        return path\n\n    async def set_category(self, _hash: str, category: str):\n        if _hash in self._torrents:\n            self._torrents[_hash][\"category\"] = category\n        logger.debug(\"[MockDownloader] set_category(%s, %s)\", _hash, category)\n\n    async def remove_rule(self, rule_name: str):\n        self._rules.pop(rule_name, None)\n        logger.debug(\"[MockDownloader] remove_rule(%s)\", rule_name)\n\n    async def add_tag(self, _hash: str, tag: str):\n        if _hash in self._torrents:\n            tags = self._torrents[_hash].setdefault(\"tags\", [])\n            if tag not in tags:\n                tags.append(tag)\n        logger.debug(\"[MockDownloader] add_tag(%s, %s)\", _hash, tag)\n\n    async def check_connection(self) -> str:\n        return \"v4.6.0 (mock)\"\n\n    # Helper methods for testing\n\n    def add_mock_torrent(\n        self,\n        name: str,\n        hash: str | None = None,\n        category: str = \"Bangumi\",\n        state: str = \"completed\",\n        save_path: str = \"/tmp/mock-downloads\",\n        files: list[dict] | None = None,\n    ) -> str:\n        \"\"\"Add a mock torrent for testing purposes.\"\"\"\n        import hashlib\n\n        if hash is None:\n            hash = hashlib.sha1(name.encode()).hexdigest()\n\n        self._torrents[hash] = {\n            \"hash\": hash,\n            \"name\": name,\n            \"save_path\": save_path,\n            \"category\": category,\n            \"state\": state,\n            \"progress\": 1.0 if state == \"completed\" else 0.5,\n            \"files\": files or [{\"name\": f\"{name}.mkv\", \"size\": 1024 * 1024 * 500}],\n            \"tags\": [],\n        }\n        logger.debug(\"[MockDownloader] Added mock torrent: %s\", name)\n        return hash\n\n    def get_state(self) -> dict[str, Any]:\n        \"\"\"Get the current mock state for debugging.\"\"\"\n        return {\n            \"torrents\": self._torrents,\n            \"rules\": self._rules,\n            \"feeds\": self._feeds,\n            \"categories\": list(self._categories),\n        }\n"
  },
  {
    "path": "backend/src/module/downloader/client/qb_downloader.py",
    "content": "import asyncio\nimport json\nimport logging\n\nimport httpx\n\nfrom module.ab_decorator import qb_connect_failed_wait\n\nlogger = logging.getLogger(__name__)\n\n\nclass QbDownloader:\n    def __init__(self, host: str, username: str, password: str, ssl: bool):\n        if \"://\" not in host:\n            scheme = \"https\" if ssl else \"http\"\n            self.host = f\"{scheme}://{host}\"\n        else:\n            self.host = host\n        self.username = username\n        self.password = password\n        self.ssl = ssl\n        self._client: httpx.AsyncClient | None = None\n\n    def _url(self, endpoint: str) -> str:\n        return f\"{self.host}/api/v2/{endpoint}\"\n\n    async def auth(self, retry=3):\n        times = 0\n        use_https = self.host.startswith(\"https://\")\n        timeout = httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=10.0)\n        # Never verify certificates - self-signed certs are the norm for\n        # home-server / NAS / Docker qBittorrent setups.\n        self._client = httpx.AsyncClient(timeout=timeout, verify=False)\n        while times < retry:\n            try:\n                resp = await self._client.post(\n                    self._url(\"auth/login\"),\n                    data={\"username\": self.username, \"password\": self.password},\n                )\n                if resp.status_code == 200 and resp.text == \"Ok.\":\n                    return True\n                elif resp.status_code == 403:\n                    logger.error(\"Login refused by qBittorrent Server\")\n                    logger.info(\"Please release the IP in qBittorrent Server\")\n                    break\n                else:\n                    logger.error(\n                        f\"Can't login qBittorrent Server {self.host} by {self.username}, retry in 5 seconds.\"\n                    )\n                    await asyncio.sleep(5)\n                    times += 1\n            except httpx.ConnectError as e:\n                if use_https:\n                    logger.error(\n                        \"Cannot connect to qBittorrent Server via HTTPS. \"\n                        \"If your qBittorrent uses plain HTTP, disable SSL in download settings.\"\n                    )\n                else:\n                    logger.error(\"Cannot connect to qBittorrent Server\")\n                logger.info(\"Please check the IP and port in WebUI settings\")\n                logger.debug(\"Connection error detail: %s\", e)\n                await asyncio.sleep(10)\n                times += 1\n            except Exception as e:\n                if use_https and \"ssl\" in str(e).lower():\n                    logger.error(\n                        \"TLS/SSL error connecting to qBittorrent. \"\n                        \"If your qBittorrent uses plain HTTP, disable SSL in download settings.\"\n                    )\n                else:\n                    logger.error(f\"Unknown error: {e}\")\n                break\n        return False\n\n    async def logout(self):\n        if self._client:\n            try:\n                await self._client.post(self._url(\"auth/logout\"))\n            except (\n                httpx.ConnectError,\n                httpx.RequestError,\n                httpx.TimeoutException,\n            ) as e:\n                logger.debug(\"[Downloader] Logout request failed (non-critical): %s\", e)\n            await self._client.aclose()\n            self._client = None\n\n    async def check_host(self):\n        try:\n            resp = await self._client.get(self._url(\"app/version\"))\n            return resp.status_code == 200\n        except (httpx.ConnectError, httpx.RequestError):\n            return False\n\n    def check_rss(self, rss_link: str):\n        pass\n\n    @qb_connect_failed_wait\n    async def prefs_init(self, prefs):\n        resp = await self._client.post(\n            self._url(\"app/setPreferences\"),\n            data={\"json\": json.dumps(prefs)},\n        )\n        return resp\n\n    @qb_connect_failed_wait\n    async def get_app_prefs(self):\n        resp = await self._client.get(self._url(\"app/preferences\"))\n        return resp.json()\n\n    async def add_category(self, category):\n        await self._client.post(\n            self._url(\"torrents/createCategory\"),\n            data={\"category\": category, \"savePath\": \"\"},\n        )\n\n    @qb_connect_failed_wait\n    async def torrents_info(self, status_filter, category, tag=None):\n        params = {}\n        if status_filter:\n            params[\"filter\"] = status_filter\n        if category:\n            params[\"category\"] = category\n        if tag:\n            params[\"tag\"] = tag\n        resp = await self._client.get(self._url(\"torrents/info\"), params=params)\n        return resp.json()\n\n    @qb_connect_failed_wait\n    async def torrents_files(self, torrent_hash: str):\n        resp = await self._client.get(\n            self._url(\"torrents/files\"), params={\"hash\": torrent_hash}\n        )\n        return resp.json()\n\n    async def add_torrents(\n        self, torrent_urls, torrent_files, save_path, category, tags=None\n    ):\n        data = {\n            \"savepath\": save_path,\n            \"category\": category,\n            \"paused\": \"false\",\n            \"autoTMM\": \"false\",\n            \"contentLayout\": \"NoSubfolder\",\n        }\n        if tags:\n            data[\"tags\"] = tags\n        files = {}\n        if torrent_urls:\n            if isinstance(torrent_urls, list):\n                data[\"urls\"] = \"\\n\".join(torrent_urls)\n            else:\n                data[\"urls\"] = torrent_urls\n        if torrent_files:\n            if isinstance(torrent_files, list):\n                for i, f in enumerate(torrent_files):\n                    files[f\"torrents_{i}\"] = (\n                        f\"torrent_{i}.torrent\",\n                        f,\n                        \"application/x-bittorrent\",\n                    )\n            else:\n                files[\"torrents\"] = (\n                    \"torrent.torrent\",\n                    torrent_files,\n                    \"application/x-bittorrent\",\n                )\n\n        max_retries = 3\n        for attempt in range(max_retries):\n            try:\n                resp = await self._client.post(\n                    self._url(\"torrents/add\"),\n                    data=data,\n                    files=files if files else None,\n                )\n                return resp.text == \"Ok.\"\n            except (httpx.ReadError, httpx.ConnectError, httpx.RequestError) as e:\n                if attempt < max_retries - 1:\n                    logger.warning(\n                        f\"[Downloader] Network error adding torrent (attempt {attempt + 1}/{max_retries}): {e}\"\n                    )\n                    await asyncio.sleep(2)\n                else:\n                    logger.error(\n                        f\"[Downloader] Failed to add torrent after {max_retries} attempts: {e}\"\n                    )\n                    raise\n\n    async def get_torrents_by_tag(self, tag: str) -> list[dict]:\n        resp = await self._client.get(self._url(\"torrents/info\"), params={\"tag\": tag})\n        return resp.json()\n\n    async def torrents_delete(self, hash, delete_files: bool = True):\n        await self._client.post(\n            self._url(\"torrents/delete\"),\n            data={\"hashes\": hash, \"deleteFiles\": str(delete_files).lower()},\n        )\n\n    async def torrents_pause(self, hashes: str):\n        await self._client.post(\n            self._url(\"torrents/pause\"),\n            data={\"hashes\": hashes},\n        )\n\n    async def torrents_resume(self, hashes: str):\n        await self._client.post(\n            self._url(\"torrents/resume\"),\n            data={\"hashes\": hashes},\n        )\n\n    async def torrents_rename_file(\n        self, torrent_hash, old_path, new_path, verify: bool = True\n    ) -> bool:\n        try:\n            resp = await self._client.post(\n                self._url(\"torrents/renameFile\"),\n                data={\"hash\": torrent_hash, \"oldPath\": old_path, \"newPath\": new_path},\n            )\n            if resp.status_code == 409:\n                logger.debug(\"Conflict409Error: %s >> %s\", old_path, new_path)\n                return False\n            if resp.status_code != 200:\n                return False\n\n            if not verify:\n                return True\n\n            # Verify the rename actually happened by checking file list\n            # qBittorrent can return 200 but delay the actual rename (e.g., while seeding)\n            # Use exponential backoff: 0.1s, 0.2s, 0.4s (max 3 attempts)\n            for attempt in range(3):\n                delay = 0.1 * (2**attempt)\n                await asyncio.sleep(delay)\n                files = await self.torrents_files(torrent_hash)\n                for f in files:\n                    if f.get(\"name\") == new_path:\n                        return True\n                    if f.get(\"name\") == old_path:\n                        # File still has old name - break inner loop and retry\n                        if attempt < 2:\n                            break\n                        # Final attempt failed\n                        logger.debug(\n                            \"[Downloader] Rename API returned 200 but file unchanged: %s\", old_path\n                        )\n                        return False\n                # new_path found or old_path not found\n                return True\n            return True\n        except (httpx.ConnectError, httpx.RequestError, httpx.TimeoutException) as e:\n            logger.warning(f\"[Downloader] Failed to rename file {old_path}: {e}\")\n            return False\n\n    async def rss_add_feed(self, url, item_path):\n        resp = await self._client.post(\n            self._url(\"rss/addFeed\"),\n            data={\"url\": url, \"path\": item_path},\n        )\n        if resp.status_code == 409:\n            logger.warning(f\"[Downloader] RSS feed {url} already exists\")\n\n    async def rss_remove_item(self, item_path):\n        resp = await self._client.post(\n            self._url(\"rss/removeItem\"),\n            data={\"path\": item_path},\n        )\n        if resp.status_code == 409:\n            logger.warning(f\"[Downloader] RSS item {item_path} does not exist\")\n\n    async def rss_get_feeds(self):\n        resp = await self._client.get(self._url(\"rss/items\"))\n        return resp.json()\n\n    async def rss_set_rule(self, rule_name, rule_def):\n        await self._client.post(\n            self._url(\"rss/setRule\"),\n            data={\"ruleName\": rule_name, \"ruleDef\": json.dumps(rule_def)},\n        )\n\n    async def move_torrent(self, hashes, new_location):\n        await self._client.post(\n            self._url(\"torrents/setLocation\"),\n            data={\"hashes\": hashes, \"location\": new_location},\n        )\n\n    async def get_download_rule(self):\n        resp = await self._client.get(self._url(\"rss/rules\"))\n        return resp.json()\n\n    async def get_torrent_path(self, _hash):\n        resp = await self._client.get(\n            self._url(\"torrents/info\"), params={\"hashes\": _hash}\n        )\n        torrents = resp.json()\n        if torrents:\n            return torrents[0].get(\"save_path\", \"\")\n        return \"\"\n\n    async def set_category(self, _hash, category):\n        resp = await self._client.post(\n            self._url(\"torrents/setCategory\"),\n            data={\"hashes\": _hash, \"category\": category},\n        )\n        if resp.status_code == 409:\n            logger.warning(f\"[Downloader] Category {category} does not exist\")\n            await self.add_category(category)\n            await self._client.post(\n                self._url(\"torrents/setCategory\"),\n                data={\"hashes\": _hash, \"category\": category},\n            )\n\n    async def check_connection(self):\n        resp = await self._client.get(self._url(\"app/version\"))\n        return resp.text\n\n    async def remove_rule(self, rule_name):\n        await self._client.post(\n            self._url(\"rss/removeRule\"),\n            data={\"ruleName\": rule_name},\n        )\n\n    async def add_tag(self, _hash, tag):\n        await self._client.post(\n            self._url(\"torrents/addTags\"),\n            data={\"hashes\": _hash, \"tags\": tag},\n        )\n"
  },
  {
    "path": "backend/src/module/downloader/client/tr_downloader.py",
    "content": ""
  },
  {
    "path": "backend/src/module/downloader/download_client.py",
    "content": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.models import Bangumi, Torrent\nfrom module.network import RequestContent\n\nfrom .path import TorrentPath\n\nlogger = logging.getLogger(__name__)\n\n\nclass DownloadClient(TorrentPath):\n    \"\"\"Unified async download client.\n\n    Wraps qBittorrent, Aria2, or MockDownloader behind a common interface.\n    Intended to be used as an async context manager; authentication is\n    performed on ``__aenter__`` and the session is closed on ``__aexit__``.\n    \"\"\"\n\n    def __init__(self):\n        super().__init__()\n        self.client = self.__getClient()\n        self.authed = False\n\n    @staticmethod\n    def __getClient():\n        \"\"\"Instantiate the configured downloader client (qbittorrent | aria2 | mock).\"\"\"\n        downloader_type = settings.downloader.type\n        host = settings.downloader.host\n        username = settings.downloader.username\n        password = settings.downloader.password\n        ssl = settings.downloader.ssl\n        if downloader_type == \"qbittorrent\":\n            from .client.qb_downloader import QbDownloader\n\n            return QbDownloader(host, username, password, ssl)\n        elif downloader_type == \"aria2\":\n            from .client.aria2_downloader import Aria2Downloader\n\n            return Aria2Downloader(host, username, password)\n        elif downloader_type == \"mock\":\n            from .client.mock_downloader import MockDownloader\n\n            logger.debug(\"[Downloader] Using MockDownloader for local development\")\n            return MockDownloader()\n        else:\n            logger.error(\"[Downloader] Unsupported downloader type: %s\", downloader_type)\n            raise Exception(f\"Unsupported downloader type: {downloader_type}\")\n\n    async def __aenter__(self):\n        if not self.authed:\n            await self.auth()\n            if not self.authed:\n                raise ConnectionError(\"Download client authentication failed\")\n        else:\n            logger.error(\"[Downloader] Already authed.\")\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        if self.authed:\n            await self.client.logout()\n            self.authed = False\n\n    async def auth(self):\n        self.authed = await self.client.auth()\n        if self.authed:\n            logger.debug(\"[Downloader] Authed.\")\n        else:\n            logger.error(\"[Downloader] Auth failed.\")\n\n    async def check_host(self):\n        return await self.client.check_host()\n\n    async def init_downloader(self):\n        \"\"\"Apply required qBittorrent RSS preferences and create the Bangumi category.\"\"\"\n        prefs = {\n            \"rss_auto_downloading_enabled\": True,\n            \"rss_max_articles_per_feed\": 500,\n            \"rss_processing_enabled\": True,\n            \"rss_refresh_interval\": 30,\n        }\n        await self.client.prefs_init(prefs=prefs)\n        # Category creation may fail if it already exists (HTTP 409) or network issues\n        try:\n            await self.client.add_category(\"BangumiCollection\")\n        except Exception as e:\n            logger.debug(\n                \"[Downloader] Could not add category (may already exist): %s\", e\n            )\n        if settings.downloader.path == \"\":\n            prefs = await self.client.get_app_prefs()\n            settings.downloader.path = self._join_path(prefs[\"save_path\"], \"Bangumi\")\n\n    async def set_rule(self, data: Bangumi):\n        \"\"\"Create or update a qBittorrent RSS auto-download rule for one bangumi entry.\"\"\"\n        data.rule_name = self._rule_name(data)\n        data.save_path = self._gen_save_path(data)\n        rule = {\n            \"enable\": True,\n            \"mustContain\": data.title_raw,\n            \"mustNotContain\": \"|\".join(data.filter),\n            \"useRegex\": True,\n            \"episodeFilter\": \"\",\n            \"smartFilter\": False,\n            \"previouslyMatchedEpisodes\": [],\n            \"affectedFeeds\": data.rss_link,\n            \"ignoreDays\": 0,\n            \"lastMatch\": \"\",\n            \"addPaused\": False,\n            \"assignedCategory\": \"Bangumi\",\n            \"savePath\": data.save_path,\n        }\n        await self.client.rss_set_rule(rule_name=data.rule_name, rule_def=rule)\n        data.added = True\n        logger.info(\n            f\"[Downloader] Add {data.official_title} Season {data.season} to auto download rules.\"\n        )\n\n    async def set_rules(self, bangumi_info: list[Bangumi]):\n        logger.debug(\"[Downloader] Start adding rules.\")\n        await asyncio.gather(*[self.set_rule(info) for info in bangumi_info])\n        logger.debug(\"[Downloader] Finished.\")\n\n    async def get_torrent_info(\n        self, category=\"Bangumi\", status_filter=\"completed\", tag=None\n    ):\n        return await self.client.torrents_info(\n            status_filter=status_filter, category=category, tag=tag\n        )\n\n    async def get_torrent_files(self, torrent_hash: str):\n        return await self.client.torrents_files(torrent_hash=torrent_hash)\n\n    async def rename_torrent_file(\n        self, _hash, old_path, new_path, verify: bool = True\n    ) -> bool:\n        result = await self.client.torrents_rename_file(\n            torrent_hash=_hash, old_path=old_path, new_path=new_path, verify=verify\n        )\n        if result:\n            logger.info(f\"{old_path} >> {new_path}\")\n        else:\n            logger.debug(\"[Downloader] Rename failed: %s >> %s\", old_path, new_path)\n        return result\n\n    async def delete_torrent(self, hashes, delete_files: bool = True):\n        await self.client.torrents_delete(hashes, delete_files=delete_files)\n        logger.info(\"[Downloader] Remove torrents.\")\n\n    async def pause_torrent(self, hashes: str):\n        await self.client.torrents_pause(hashes)\n\n    async def resume_torrent(self, hashes: str):\n        await self.client.torrents_resume(hashes)\n\n    async def add_torrent(self, torrent: Torrent | list, bangumi: Bangumi) -> bool:\n        \"\"\"Download a torrent (or list of torrents) for the given bangumi entry.\n\n        Handles both magnet links and .torrent file URLs, fetching file bytes\n        when necessary. Tags each torrent with ``ab:<bangumi_id>`` for later\n        episode-offset lookup during rename.\n        \"\"\"\n        if not bangumi.save_path:\n            bangumi.save_path = self._gen_save_path(bangumi)\n        async with RequestContent() as req:\n            if isinstance(torrent, list):\n                if len(torrent) == 0:\n                    logger.debug(\n                        \"[Downloader] No torrent found: %s\", bangumi.official_title\n                    )\n                    return False\n                if \"magnet\" in torrent[0].url:\n                    torrent_url = [t.url for t in torrent]\n                    torrent_file = None\n                else:\n                    torrent_file = await asyncio.gather(\n                        *[req.get_content(t.url) for t in torrent]\n                    )\n                    # Filter out None values (failed fetches)\n                    torrent_file = [f for f in torrent_file if f is not None]\n                    if not torrent_file:\n                        logger.warning(\n                            f\"[Downloader] Failed to fetch torrent files for: {bangumi.official_title}\"\n                        )\n                        return False\n                    torrent_url = None\n            else:\n                if \"magnet\" in torrent.url:\n                    torrent_url = torrent.url\n                    torrent_file = None\n                else:\n                    torrent_file = await req.get_content(torrent.url)\n                    if torrent_file is None:\n                        logger.warning(\n                            f\"[Downloader] Failed to fetch torrent file for: {bangumi.official_title}\"\n                        )\n                        return False\n                    torrent_url = None\n        # Create tag with bangumi_id for offset lookup during rename\n        tags = f\"ab:{bangumi.id}\" if bangumi.id else None\n        try:\n            if await self.client.add_torrents(\n                torrent_urls=torrent_url,\n                torrent_files=torrent_file,\n                save_path=bangumi.save_path,\n                category=\"Bangumi\",\n                tags=tags,\n            ):\n                logger.debug(\"[Downloader] Add torrent: %s\", bangumi.official_title)\n                return True\n            else:\n                logger.debug(\n                    \"[Downloader] Torrent added before: %s\", bangumi.official_title\n                )\n                return False\n        except Exception as e:\n            logger.error(\n                f\"[Downloader] Failed to add torrent for {bangumi.official_title}: {e}\"\n            )\n            return False\n\n    async def move_torrent(self, hashes, location):\n        await self.client.move_torrent(hashes=hashes, new_location=location)\n\n    # RSS Parts\n    async def add_rss_feed(self, rss_link, item_path=\"Mikan_RSS\"):\n        await self.client.rss_add_feed(url=rss_link, item_path=item_path)\n\n    async def remove_rss_feed(self, item_path):\n        await self.client.rss_remove_item(item_path=item_path)\n\n    async def get_rss_feed(self):\n        return await self.client.rss_get_feeds()\n\n    async def get_download_rules(self):\n        return await self.client.get_download_rule()\n\n    async def get_torrent_path(self, hashes):\n        return await self.client.get_torrent_path(hashes)\n\n    async def set_category(self, hashes, category):\n        await self.client.set_category(hashes, category)\n\n    async def remove_rule(self, rule_name):\n        await self.client.remove_rule(rule_name)\n        logger.info(f\"[Downloader] Delete rule: {rule_name}\")\n\n    async def get_torrents_by_tag(self, tag: str) -> list[dict]:\n        \"\"\"Get all torrents with a specific tag.\"\"\"\n        if hasattr(self.client, \"get_torrents_by_tag\"):\n            return await self.client.get_torrents_by_tag(tag)\n        return []\n\n    async def add_tag(self, torrent_hash: str, tag: str):\n        \"\"\"Add a tag to a torrent.\"\"\"\n        await self.client.add_tag(torrent_hash, tag)\n        logger.debug(\n            \"[Downloader] Added tag '%s' to torrent %s...\", tag, torrent_hash[:8]\n        )\n"
  },
  {
    "path": "backend/src/module/downloader/exceptions.py",
    "content": "class ConflictError(Exception):\n    pass\n"
  },
  {
    "path": "backend/src/module/downloader/path.py",
    "content": "import logging\nimport re\nfrom os import PathLike\n\nfrom module.conf import PLATFORM, settings\nfrom module.models import Bangumi, BangumiUpdate\n\nlogger = logging.getLogger(__name__)\n\nif PLATFORM == \"Windows\":\n    from pathlib import PureWindowsPath as Path\nelse:\n    from pathlib import Path\n\n\n_MEDIA_SUFFIXES = frozenset({\".mp4\", \".mkv\"})\n_SUBTITLE_SUFFIXES = frozenset({\".ass\", \".srt\"})\n\n\nclass TorrentPath:\n    def __init__(self):\n        pass\n\n    @staticmethod\n    def check_files(files: list[dict]):\n        media_list = []\n        subtitle_list = []\n        for f in files:\n            file_name = f[\"name\"]\n            suffix = Path(file_name).suffix.lower()\n            if suffix in _MEDIA_SUFFIXES:\n                media_list.append(file_name)\n            elif suffix in _SUBTITLE_SUFFIXES:\n                subtitle_list.append(file_name)\n        return media_list, subtitle_list\n\n    @staticmethod\n    def _path_to_bangumi(save_path: PathLike[str] | str, torrent_name: str = \"\"):\n        # Split save path and download path\n        save_parts = Path(save_path).parts\n        download_parts = Path(settings.downloader.path).parts\n        # Get bangumi name and season\n        bangumi_name = \"\"\n        season = 1\n        for part in save_parts:\n            if re.match(r\"S\\d+|[Ss]eason \\d+\", part):\n                season = int(re.findall(r\"\\d+\", part)[0])\n            elif part not in download_parts:\n                bangumi_name = part\n        if not bangumi_name:\n            bangumi_name = torrent_name\n        return bangumi_name, season\n\n    @staticmethod\n    def _file_depth(file_path: PathLike[str] | str):\n        return len(Path(file_path).parts)\n\n    def is_ep(self, file_path: PathLike[str] | str):\n        return self._file_depth(file_path) <= 2\n\n    @staticmethod\n    def _gen_save_path(data: Bangumi | BangumiUpdate):\n        \"\"\"Generate save path for a bangumi.\n\n        The save path uses the adjusted season number (season + season_offset)\n        so files are saved directly to the correct season folder.\n        \"\"\"\n        folder = (\n            f\"{data.official_title} ({data.year})\" if data.year else data.official_title\n        )\n        # Apply season_offset to get the adjusted season number for the folder\n        adjusted_season = data.season + getattr(data, \"season_offset\", 0)\n        if adjusted_season < 1:\n            adjusted_season = data.season  # Safety: don't go below 1\n            logger.warning(\n                f\"[Path] Season offset would result in invalid season for {data.official_title}, using original season\"\n            )\n        save_path = (\n            Path(settings.downloader.path) / folder / f\"Season {adjusted_season}\"\n        )\n        return str(save_path)\n\n    @staticmethod\n    def _rule_name(data: Bangumi):\n        rule_name = (\n            f\"[{data.group_name}] {data.official_title} S{data.season}\"\n            if settings.bangumi_manage.group_tag\n            else f\"{data.official_title} S{data.season}\"\n        )\n        return rule_name\n\n    @staticmethod\n    def _join_path(*args):\n        return str(Path(*args))\n"
  },
  {
    "path": "backend/src/module/manager/__init__.py",
    "content": "from .collector import SeasonCollector, eps_complete\nfrom .renamer import Renamer\nfrom .torrent import TorrentManager\n"
  },
  {
    "path": "backend/src/module/manager/collector.py",
    "content": "import logging\n\nfrom module.downloader import DownloadClient\nfrom module.models import Bangumi, ResponseModel\nfrom module.rss import RSSEngine\nfrom module.searcher import SearchTorrent\n\nlogger = logging.getLogger(__name__)\n\n\nclass SeasonCollector(DownloadClient):\n    async def collect_season(self, bangumi: Bangumi, link: str = None):\n        logger.info(\n            f\"Start collecting {bangumi.official_title} Season {bangumi.season}...\"\n        )\n        async with SearchTorrent() as st:\n            if not link:\n                torrents = await st.search_season(bangumi)\n            else:\n                torrents = await st.get_torrents(link, bangumi.filter.replace(\",\", \"|\"))\n        with RSSEngine() as engine:\n            if await self.add_torrent(torrents, bangumi):\n                logger.info(\n                    f\"Collections of {bangumi.official_title} Season {bangumi.season} completed.\"\n                )\n                for torrent in torrents:\n                    torrent.downloaded = True\n                bangumi.eps_collect = True\n                if engine.bangumi.update(bangumi):\n                    engine.bangumi.add(bangumi)\n                engine.torrent.add_all(torrents)\n                return ResponseModel(\n                    status=True,\n                    status_code=200,\n                    msg_en=f\"Collections of {bangumi.official_title} Season {bangumi.season} completed.\",\n                    msg_zh=f\"收集 {bangumi.official_title} 第 {bangumi.season} 季完成。\",\n                )\n            else:\n                logger.warning(\n                    f\"Already collected {bangumi.official_title} Season {bangumi.season}.\"\n                )\n                return ResponseModel(\n                    status=False,\n                    status_code=406,\n                    msg_en=f\"Collection of {bangumi.official_title} Season {bangumi.season} failed.\",\n                    msg_zh=f\"收集 {bangumi.official_title} 第 {bangumi.season} 季失败, 种子已经添加。\",\n                )\n\n    @staticmethod\n    async def subscribe_season(data: Bangumi, parser: str = \"mikan\"):\n        with RSSEngine() as engine:\n            data.added = True\n            data.eps_collect = True\n            await engine.add_rss(\n                rss_link=data.rss_link,\n                name=data.official_title,\n                aggregate=False,\n                parser=parser,\n            )\n            result = await engine.download_bangumi(data)\n            engine.bangumi.add(data)\n            return result\n\n\nasync def eps_complete():\n    with RSSEngine() as engine:\n        datas = engine.bangumi.not_complete()\n        if datas:\n            logger.info(\"Start collecting full season...\")\n            async with SeasonCollector() as collector:\n                for data in datas:\n                    if not data.eps_collect:\n                        await collector.collect_season(data)\n                    data.eps_collect = True\n            engine.bangumi.update_all(datas)\n"
  },
  {
    "path": "backend/src/module/manager/renamer.py",
    "content": "import asyncio\nimport logging\nimport time\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.downloader import DownloadClient\nfrom module.models import EpisodeFile, Notification, SubtitleFile\nfrom module.parser import TitleParser\n\nlogger = logging.getLogger(__name__)\n\n# Module-level cache to track pending renames that qBittorrent hasn't processed yet\n# Key: (torrent_hash, old_path, new_path), Value: timestamp of last attempt\n# This prevents spamming the same rename when qBittorrent returns 200 but doesn't actually rename\n_pending_renames: dict[tuple[str, str, str], float] = {}\n_PENDING_RENAME_COOLDOWN = 300  # 5 minutes cooldown before retrying same rename\n_CLEANUP_INTERVAL = 60  # Clean up pending cache at most once per minute\n_last_cleanup_time: float = 0\n\n\nclass Renamer(DownloadClient):\n    def __init__(self):\n        super().__init__()\n        self._parser = TitleParser()\n        self._offset_cache: dict[str, tuple[int, int]] = {}\n\n    @staticmethod\n    def _cleanup_pending_cache():\n        \"\"\"Clean up expired entries from pending renames cache (throttled).\"\"\"\n        global _last_cleanup_time\n        current_time = time.time()\n        if current_time - _last_cleanup_time < _CLEANUP_INTERVAL:\n            return\n        _last_cleanup_time = current_time\n        expired_keys = [\n            k\n            for k, v in _pending_renames.items()\n            if current_time - v > _PENDING_RENAME_COOLDOWN * 2\n        ]\n        for k in expired_keys:\n            _pending_renames.pop(k, None)\n\n    @staticmethod\n    def print_result(torrent_count, rename_count):\n        if rename_count != 0:\n            logger.info(\n                f\"Finished checking {torrent_count} files' name, renamed {rename_count} files.\"\n            )\n        logger.debug(\"Checked %s files\", torrent_count)\n\n    @staticmethod\n    def gen_path(\n        file_info: EpisodeFile | SubtitleFile,\n        bangumi_name: str,\n        method: str,\n        episode_offset: int = 0,\n        season_offset: int = 0,  # Kept for API compatibility, but no longer used\n    ) -> str:\n        # Season comes from the folder name which already includes the offset\n        # (folder is now \"Season {season + season_offset}\")\n        # So we use file_info.season directly without applying offset again\n        season_num = file_info.season\n        season = f\"0{season_num}\" if season_num < 10 else season_num\n        # Apply episode offset\n        original_episode = int(file_info.episode)\n        if original_episode == 0 and episode_offset != 0:\n            # Episode 0 is a special/OVA — never apply offset to avoid\n            # overwriting regular episodes (see issue #977)\n            adjusted_episode = 0\n        else:\n            adjusted_episode = original_episode + episode_offset\n        # An offset producing a non-positive result (e.g., EP5 + offset -10)\n        # is almost always a misconfiguration, so revert to original.\n        if adjusted_episode < 0 or (adjusted_episode == 0 and original_episode > 0):\n            adjusted_episode = original_episode\n            logger.warning(\n                f\"[Renamer] Episode offset {episode_offset} would make episode {original_episode} non-positive, ignoring offset\"\n            )\n        episode = f\"0{adjusted_episode}\" if adjusted_episode < 10 else adjusted_episode\n        if method == \"none\" or method == \"subtitle_none\":\n            return file_info.media_path\n        elif method == \"pn\":\n            return f\"{file_info.title} S{season}E{episode}{file_info.suffix}\"\n        elif method == \"advance\":\n            return f\"{bangumi_name} S{season}E{episode}{file_info.suffix}\"\n        elif method == \"normal\":\n            logger.warning(\"[Renamer] Normal rename method is deprecated.\")\n            return file_info.media_path\n        elif method == \"subtitle_pn\":\n            return f\"{file_info.title} S{season}E{episode}.{file_info.language}{file_info.suffix}\"\n        elif method == \"subtitle_advance\":\n            return f\"{bangumi_name} S{season}E{episode}.{file_info.language}{file_info.suffix}\"\n        else:\n            logger.error(f\"[Renamer] Unknown rename method: {method}\")\n            return file_info.media_path\n\n    async def rename_file(\n        self,\n        torrent_name: str,\n        media_path: str,\n        bangumi_name: str,\n        method: str,\n        season: int,\n        _hash: str,\n        episode_offset: int = 0,\n        season_offset: int = 0,\n        **kwargs,\n    ):\n        ep = self._parser.torrent_parser(\n            torrent_name=torrent_name,\n            torrent_path=media_path,\n            season=season,\n        )\n        if ep:\n            new_path = self.gen_path(\n                ep,\n                bangumi_name,\n                method=method,\n                episode_offset=episode_offset,\n                season_offset=season_offset,\n            )\n            if media_path != new_path:\n                # Check if this rename was recently attempted but didn't take effect\n                # (qBittorrent can return 200 but delay actual rename while seeding)\n                pending_key = (_hash, media_path, new_path)\n                last_attempt = _pending_renames.get(pending_key)\n                if (\n                    last_attempt\n                    and (time.time() - last_attempt) < _PENDING_RENAME_COOLDOWN\n                ):\n                    logger.debug(\n                        \"[Renamer] Skipping rename (pending cooldown): %s\", media_path\n                    )\n                    return None\n\n                if await self.rename_torrent_file(\n                    _hash=_hash, old_path=media_path, new_path=new_path\n                ):\n                    # Rename verified successful, remove from pending cache\n                    _pending_renames.pop(pending_key, None)\n                    # Season comes from folder which already has offset applied\n                    # Only apply episode offset\n                    original_ep = int(ep.episode)\n                    if original_ep == 0 and episode_offset != 0:\n                        adjusted_episode = 0\n                    else:\n                        adjusted_episode = original_ep + episode_offset\n                    if adjusted_episode < 0 or (\n                        adjusted_episode == 0 and original_ep > 0\n                    ):\n                        adjusted_episode = original_ep\n                    return Notification(\n                        official_title=bangumi_name,\n                        season=ep.season,\n                        episode=adjusted_episode,\n                    )\n                else:\n                    # Rename API returned success but file wasn't actually renamed\n                    # Add to pending cache to avoid spamming\n                    _pending_renames[pending_key] = time.time()\n                    # Periodic cleanup of expired entries (at most once per minute)\n                    self._cleanup_pending_cache()\n        else:\n            logger.warning(f\"[Renamer] {media_path} parse failed\")\n            if settings.bangumi_manage.remove_bad_torrent:\n                await self.delete_torrent(hashes=_hash)\n        return None\n\n    async def rename_collection(\n        self,\n        media_list: list[str],\n        bangumi_name: str,\n        season: int,\n        method: str,\n        _hash: str,\n        episode_offset: int = 0,\n        season_offset: int = 0,\n        **kwargs,\n    ):\n        for media_path in media_list:\n            if self.is_ep(media_path):\n                ep = self._parser.torrent_parser(\n                    torrent_path=media_path,\n                    season=season,\n                )\n                if ep:\n                    new_path = self.gen_path(\n                        ep,\n                        bangumi_name,\n                        method=method,\n                        episode_offset=episode_offset,\n                        season_offset=season_offset,\n                    )\n                    if media_path != new_path:\n                        renamed = await self.rename_torrent_file(\n                            _hash=_hash, old_path=media_path, new_path=new_path\n                        )\n                        if not renamed:\n                            logger.warning(f\"[Renamer] {media_path} rename failed\")\n                            # Delete bad torrent.\n                            if settings.bangumi_manage.remove_bad_torrent:\n                                await self.delete_torrent(_hash)\n                                break\n\n    async def rename_subtitles(\n        self,\n        subtitle_list: list[str],\n        torrent_name: str,\n        bangumi_name: str,\n        season: int,\n        method: str,\n        _hash,\n        episode_offset: int = 0,\n        season_offset: int = 0,\n        **kwargs,\n    ):\n        method = \"subtitle_\" + method\n        for subtitle_path in subtitle_list:\n            sub = self._parser.torrent_parser(\n                torrent_path=subtitle_path,\n                torrent_name=torrent_name,\n                season=season,\n                file_type=\"subtitle\",\n            )\n            if sub:\n                new_path = self.gen_path(\n                    sub,\n                    bangumi_name,\n                    method=method,\n                    episode_offset=episode_offset,\n                    season_offset=season_offset,\n                )\n                if subtitle_path != new_path:\n                    # Skip verification for subtitles to reduce latency\n                    renamed = await self.rename_torrent_file(\n                        _hash=_hash,\n                        old_path=subtitle_path,\n                        new_path=new_path,\n                        verify=False,\n                    )\n                    if not renamed:\n                        logger.warning(f\"[Renamer] {subtitle_path} rename failed\")\n\n    @staticmethod\n    def _parse_bangumi_id_from_tags(tags: str) -> int | None:\n        \"\"\"Extract bangumi_id from torrent tags.\n\n        Tags are comma-separated, and we look for 'ab:ID' format.\n        \"\"\"\n        if not tags:\n            return None\n        for tag in tags.split(\",\"):\n            tag = tag.strip()\n            if tag.startswith(\"ab:\"):\n                try:\n                    return int(tag[3:])\n                except ValueError:\n                    pass\n        return None\n\n    @staticmethod\n    def _normalize_path(path: str) -> str:\n        \"\"\"Normalize path by removing trailing slashes and standardizing separators.\"\"\"\n        if not path:\n            return path\n        # Replace backslashes with forward slashes for consistency\n        normalized = path.replace(\"\\\\\", \"/\")\n        # Remove trailing slashes\n        return normalized.rstrip(\"/\")\n\n    def _batch_lookup_offsets(\n        self, torrents_info: list[dict]\n    ) -> dict[str, tuple[int, int]]:\n        \"\"\"Batch lookup offsets for all torrents in a single database session.\n\n        Returns a dict mapping torrent_hash to (episode_offset, season_offset).\n        \"\"\"\n        result: dict[str, tuple[int, int]] = {}\n        if not torrents_info:\n            return result\n\n        try:\n            with Database() as db:\n                # Collect all hashes for batch query\n                hashes = [info[\"hash\"] for info in torrents_info]\n                torrent_records = db.torrent.search_by_qb_hashes(hashes)\n                hash_to_bangumi_id = {\n                    r.qb_hash: r.bangumi_id for r in torrent_records if r.bangumi_id\n                }\n\n                # Collect unique bangumi IDs to fetch\n                bangumi_ids_to_fetch = set(hash_to_bangumi_id.values())\n\n                # Also collect bangumi IDs from tags\n                tag_bangumi_ids = {}\n                for info in torrents_info:\n                    tags = info.get(\"tags\", \"\")\n                    bangumi_id = self._parse_bangumi_id_from_tags(tags)\n                    if bangumi_id:\n                        tag_bangumi_ids[info[\"hash\"]] = bangumi_id\n                        bangumi_ids_to_fetch.add(bangumi_id)\n\n                # Batch fetch all bangumi records\n                bangumi_map = {}\n                if bangumi_ids_to_fetch:\n                    bangumi_records = db.bangumi.search_ids(list(bangumi_ids_to_fetch))\n                    bangumi_map = {\n                        b.id: b for b in bangumi_records if b and not b.deleted\n                    }\n\n                # Now resolve offsets for each torrent\n                for info in torrents_info:\n                    torrent_hash = info[\"hash\"]\n                    torrent_name = info[\"name\"]\n                    save_path = info[\"save_path\"]\n\n                    # 1. Try by qb_hash\n                    bangumi_id = hash_to_bangumi_id.get(torrent_hash)\n                    if bangumi_id and bangumi_id in bangumi_map:\n                        b = bangumi_map[bangumi_id]\n                        result[torrent_hash] = (b.episode_offset, b.season_offset)\n                        continue\n\n                    # 2. Try by tag\n                    bangumi_id = tag_bangumi_ids.get(torrent_hash)\n                    if bangumi_id and bangumi_id in bangumi_map:\n                        b = bangumi_map[bangumi_id]\n                        result[torrent_hash] = (b.episode_offset, b.season_offset)\n                        continue\n\n                    # 3. Try by torrent name (individual query, but less common path)\n                    bangumi = db.bangumi.match_torrent(torrent_name)\n                    if bangumi:\n                        result[torrent_hash] = (\n                            bangumi.episode_offset,\n                            bangumi.season_offset,\n                        )\n                        continue\n\n                    # 4. Try by save_path (individual query, fallback)\n                    normalized_save_path = self._normalize_path(save_path)\n                    bangumi = db.bangumi.match_by_save_path(save_path)\n                    if not bangumi:\n                        bangumi = db.bangumi.match_by_save_path(normalized_save_path)\n                    if bangumi:\n                        result[torrent_hash] = (\n                            bangumi.episode_offset,\n                            bangumi.season_offset,\n                        )\n                        continue\n\n                    # Default: no offset\n                    result[torrent_hash] = (0, 0)\n\n        except Exception as e:\n            logger.debug(\"[Renamer] Batch offset lookup failed: %s\", e)\n            # Fall back to individual lookups on error\n            for info in torrents_info:\n                if info[\"hash\"] not in result:\n                    result[info[\"hash\"]] = (0, 0)\n\n        return result\n\n    def _lookup_offsets(\n        self, torrent_hash: str, torrent_name: str, save_path: str, tags: str = \"\"\n    ) -> tuple[int, int]:\n        \"\"\"Look up episode and season offsets for a bangumi.\n\n        Lookup order (most to least reliable):\n        1. By qb_hash in Torrent table (links directly to bangumi via torrent record)\n        2. By bangumi_id extracted from tags (handles multiple subscriptions perfectly)\n        3. By torrent_name matching (handles most cases)\n        4. By save_path matching (legacy fallback, may fail with multiple subscriptions)\n\n        Args:\n            torrent_hash: The qBittorrent hash to lookup in Torrent table\n            torrent_name: The torrent name to match against bangumi.title_raw\n            save_path: The save path to match against bangumi.save_path\n            tags: Comma-separated torrent tags, may contain 'ab:ID' for bangumi_id\n\n        Returns:\n            tuple[int, int]: (episode_offset, season_offset)\n        \"\"\"\n        try:\n            with Database() as db:\n                # First try by qb_hash in Torrent table (most reliable for existing torrents)\n                torrent_record = db.torrent.search_by_qb_hash(torrent_hash)\n                if torrent_record and torrent_record.bangumi_id:\n                    bangumi = db.bangumi.search_id(torrent_record.bangumi_id)\n                    if bangumi and not bangumi.deleted:\n                        logger.debug(\n                            \"[Renamer] Found offsets via qb_hash: ep=%s, season=%s\",\n                            bangumi.episode_offset,\n                            bangumi.season_offset,\n                        )\n                        return bangumi.episode_offset, bangumi.season_offset\n\n                # Then try by bangumi_id from tags (for newly added torrents)\n                bangumi_id = self._parse_bangumi_id_from_tags(tags)\n                if bangumi_id:\n                    bangumi = db.bangumi.search_id(bangumi_id)\n                    if bangumi and not bangumi.deleted:\n                        logger.debug(\n                            \"[Renamer] Found offsets via tag ab:%s: ep=%s, season=%s\",\n                            bangumi_id,\n                            bangumi.episode_offset,\n                            bangumi.season_offset,\n                        )\n                        return bangumi.episode_offset, bangumi.season_offset\n\n                # Then try matching by torrent name\n                bangumi = db.bangumi.match_torrent(torrent_name)\n                if bangumi:\n                    logger.info(\n                        f\"[Renamer] Matched bangumi '{bangumi.official_title}' (id={bangumi.id}) via name, \"\n                        f\"offsets: ep={bangumi.episode_offset}, season={bangumi.season_offset}\"\n                    )\n                    return bangumi.episode_offset, bangumi.season_offset\n\n                # Finally fall back to save_path matching with normalization\n                normalized_save_path = self._normalize_path(save_path)\n                bangumi = db.bangumi.match_by_save_path(save_path)\n                if not bangumi:\n                    # Try with normalized path if exact match failed\n                    bangumi = db.bangumi.match_by_save_path(normalized_save_path)\n                if bangumi:\n                    logger.info(\n                        f\"[Renamer] Matched bangumi '{bangumi.official_title}' (id={bangumi.id}) via save_path, \"\n                        f\"offsets: ep={bangumi.episode_offset}, season={bangumi.season_offset}\"\n                    )\n                    return bangumi.episode_offset, bangumi.season_offset\n\n                logger.info(\n                    f\"[Renamer] No bangumi match for torrent (using offset=0): \"\n                    f\"name={torrent_name[:60] if torrent_name else 'N/A'}...\"\n                )\n        except Exception as e:\n            logger.debug(\"[Renamer] Could not lookup offsets for %s: %s\", save_path, e)\n        return 0, 0\n\n    async def rename(self) -> list[Notification]:\n        # Get torrent info\n        logger.debug(\"[Renamer] Start rename process.\")\n        rename_method = settings.bangumi_manage.rename_method\n        torrents_info = await self.get_torrent_info()\n        renamed_info: list[Notification] = []\n        # Fetch all torrent files concurrently\n        all_files = await asyncio.gather(\n            *[self.get_torrent_files(info[\"hash\"]) for info in torrents_info]\n        )\n        # Batch lookup all offsets in a single database session\n        offset_map = self._batch_lookup_offsets(torrents_info)\n        for info, files in zip(torrents_info, all_files):\n            torrent_hash = info[\"hash\"]\n            torrent_name = info[\"name\"]\n            save_path = info[\"save_path\"]\n            media_list, subtitle_list = self.check_files(files)\n            bangumi_name, season = self._path_to_bangumi(save_path, torrent_name)\n            # Use pre-fetched offsets\n            episode_offset, season_offset = offset_map.get(torrent_hash, (0, 0))\n            kwargs = {\n                \"torrent_name\": torrent_name,\n                \"bangumi_name\": bangumi_name,\n                \"method\": rename_method,\n                \"season\": season,\n                \"_hash\": torrent_hash,\n                \"episode_offset\": episode_offset,\n                \"season_offset\": season_offset,\n            }\n            # Rename single media file\n            if len(media_list) == 1:\n                notify_info = await self.rename_file(media_path=media_list[0], **kwargs)\n                if notify_info:\n                    renamed_info.append(notify_info)\n                # Rename subtitle file\n                if len(subtitle_list) > 0:\n                    await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)\n            # Rename collection\n            elif len(media_list) > 1:\n                logger.info(\"[Renamer] Start rename collection\")\n                await self.rename_collection(media_list=media_list, **kwargs)\n                if len(subtitle_list) > 0:\n                    await self.rename_subtitles(subtitle_list=subtitle_list, **kwargs)\n                await self.set_category(torrent_hash, \"BangumiCollection\")\n            else:\n                logger.warning(f\"[Renamer] {torrent_name} has no media file\")\n        logger.debug(\"[Renamer] Rename process finished.\")\n        return renamed_info\n"
  },
  {
    "path": "backend/src/module/manager/torrent.py",
    "content": "import logging\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.downloader import DownloadClient\nfrom module.models import Bangumi, BangumiUpdate, ResponseModel\nfrom module.parser import TitleParser\nfrom module.parser.analyser.bgm_calendar import fetch_bgm_calendar, match_weekday\nfrom module.parser.analyser.tmdb_parser import tmdb_parser\n\nlogger = logging.getLogger(__name__)\n\n\nclass TorrentManager(Database):\n    @staticmethod\n    async def __match_torrents_list(data: Bangumi | BangumiUpdate) -> list:\n        async with DownloadClient() as client:\n            torrents = await client.get_torrent_info(status_filter=None)\n        return [\n            torrent.get(\"hash\", torrent.get(\"infohash_v1\", \"\"))\n            for torrent in torrents\n            if torrent.get(\"save_path\") == data.save_path\n        ]\n\n    async def delete_torrents(self, data: Bangumi, client: DownloadClient):\n        hash_list = await self.__match_torrents_list(data)\n        if hash_list:\n            await client.delete_torrent(hash_list)\n            logger.info(f\"Delete rule and torrents for {data.official_title}\")\n            return ResponseModel(\n                status_code=200,\n                status=True,\n                msg_en=f\"Delete rule and torrents for {data.official_title}\",\n                msg_zh=f\"删除 {data.official_title} 规则和种子\",\n            )\n        else:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find torrents for {data.official_title}\",\n                msg_zh=f\"无法找到 {data.official_title} 的种子\",\n            )\n\n    async def delete_rule(self, _id: int | str, file: bool = False):\n        data = self.bangumi.search_id(int(_id))\n        if isinstance(data, Bangumi):\n            async with DownloadClient() as client:\n                self.rss.delete(data.official_title)\n                # Clean up torrent records so re-adding the same anime can re-download\n                self.torrent.delete_by_bangumi_id(int(_id))\n                self.bangumi.delete_one(int(_id))\n                torrent_message = None\n                if file:\n                    torrent_message = await self.delete_torrents(data, client)\n                logger.info(f\"[Manager] Delete rule for {data.official_title}\")\n                return ResponseModel(\n                    status_code=200,\n                    status=True,\n                    msg_en=f\"Delete rule for {data.official_title}. {torrent_message.msg_en if file and torrent_message else ''}\",\n                    msg_zh=f\"删除 {data.official_title} 规则。{torrent_message.msg_zh if file and torrent_message else ''}\",\n                )\n        else:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find id {_id}\",\n                msg_zh=f\"无法找到 id {_id}\",\n            )\n\n    async def disable_rule(self, _id: str | int, file: bool = False):\n        data = self.bangumi.search_id(int(_id))\n        if isinstance(data, Bangumi):\n            async with DownloadClient() as client:\n                data.deleted = True\n                self.bangumi.update(data)\n                if file:\n                    torrent_message = await self.delete_torrents(data, client)\n                    return torrent_message\n                logger.info(f\"[Manager] Disable rule for {data.official_title}\")\n                return ResponseModel(\n                    status_code=200,\n                    status=True,\n                    msg_en=f\"Disable rule for {data.official_title}\",\n                    msg_zh=f\"禁用 {data.official_title} 规则\",\n                )\n        else:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find id {_id}\",\n                msg_zh=f\"无法找到 id {_id}\",\n            )\n\n    def enable_rule(self, _id: str | int):\n        data = self.bangumi.search_id(int(_id))\n        if data:\n            data.deleted = False\n            self.bangumi.update(data)\n            logger.info(f\"[Manager] Enable rule for {data.official_title}\")\n            return ResponseModel(\n                status_code=200,\n                status=True,\n                msg_en=f\"Enable rule for {data.official_title}\",\n                msg_zh=f\"启用 {data.official_title} 规则\",\n            )\n        else:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find id {_id}\",\n                msg_zh=f\"无法找到 id {_id}\",\n            )\n\n    async def update_rule(self, bangumi_id, data: BangumiUpdate):\n        old_data: Bangumi = self.bangumi.search_id(bangumi_id)\n        if old_data:\n            # Move torrent\n            match_list = await self.__match_torrents_list(old_data)\n            async with DownloadClient() as client:\n                new_path = client._gen_save_path(data)\n                old_path = old_data.save_path\n\n                # Move existing torrents to new location if path changed\n                if match_list and new_path != old_path:\n                    await client.move_torrent(match_list, new_path)\n                    logger.info(\n                        f\"[Manager] Moved torrents from {old_path} to {new_path}\"\n                    )\n\n                # Update qBittorrent RSS rule if save_path changed\n                if new_path != old_path and old_data.rule_name:\n                    # Recreate the rule with the new save_path\n                    rule = {\n                        \"enable\": True,\n                        \"mustContain\": data.title_raw,\n                        \"mustNotContain\": \"|\".join(data.filter)\n                        if isinstance(data.filter, list)\n                        else data.filter,\n                        \"useRegex\": True,\n                        \"episodeFilter\": \"\",\n                        \"smartFilter\": False,\n                        \"previouslyMatchedEpisodes\": [],\n                        \"affectedFeeds\": data.rss_link\n                        if isinstance(data.rss_link, str)\n                        else \",\".join(data.rss_link),\n                        \"ignoreDays\": 0,\n                        \"lastMatch\": \"\",\n                        \"addPaused\": False,\n                        \"assignedCategory\": \"Bangumi\",\n                        \"savePath\": new_path,\n                    }\n                    await client.client.rss_set_rule(\n                        rule_name=old_data.rule_name, rule_def=rule\n                    )\n                    logger.info(\n                        f\"[Manager] Updated RSS rule {old_data.rule_name} with new save_path\"\n                    )\n\n            data.save_path = new_path\n            self.bangumi.update(data, bangumi_id)\n            return ResponseModel(\n                status_code=200,\n                status=True,\n                msg_en=f\"Update rule for {data.official_title}\",\n                msg_zh=f\"更新 {data.official_title} 规则\",\n            )\n        else:\n            logger.error(f\"[Manager] Can't find data with {bangumi_id}\")\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find data with {bangumi_id}\",\n                msg_zh=f\"无法找到 id {bangumi_id} 的数据\",\n            )\n\n    async def refresh_poster(self):\n        bangumis = self.bangumi.search_all()\n        for bangumi in bangumis:\n            if not bangumi.poster_link:\n                await TitleParser().tmdb_poster_parser(bangumi)\n        self.bangumi.update_all(bangumis)\n        return ResponseModel(\n            status_code=200,\n            status=True,\n            msg_en=\"Refresh poster link successfully.\",\n            msg_zh=\"刷新海报链接成功。\",\n        )\n\n    async def refind_poster(self, bangumi_id: int):\n        bangumi = self.bangumi.search_id(bangumi_id)\n        await TitleParser().tmdb_poster_parser(bangumi)\n        self.bangumi.update(bangumi)\n        return ResponseModel(\n            status_code=200,\n            status=True,\n            msg_en=\"Refresh poster link successfully.\",\n            msg_zh=\"刷新海报链接成功。\",\n        )\n\n    async def refresh_calendar(self):\n        \"\"\"Fetch Bangumi.tv calendar and update air_weekday for all bangumi.\"\"\"\n        calendar_items = await fetch_bgm_calendar()\n        if not calendar_items:\n            return ResponseModel(\n                status_code=500,\n                status=False,\n                msg_en=\"Failed to fetch calendar data from Bangumi.tv.\",\n                msg_zh=\"从 Bangumi.tv 获取放送表失败。\",\n            )\n        bangumis = self.bangumi.search_all()\n        updated = 0\n        for bangumi in bangumis:\n            if bangumi.deleted or bangumi.weekday_locked:\n                continue\n            weekday = match_weekday(\n                bangumi.official_title, bangumi.title_raw, calendar_items\n            )\n            if weekday is not None and weekday != bangumi.air_weekday:\n                bangumi.air_weekday = weekday\n                updated += 1\n        if updated > 0:\n            self.bangumi.update_all(bangumis)\n        logger.info(f\"[Manager] Calendar refresh: updated {updated} bangumi.\")\n        return ResponseModel(\n            status_code=200,\n            status=True,\n            msg_en=f\"Calendar refreshed. Updated {updated} anime.\",\n            msg_zh=f\"放送表已刷新，更新了 {updated} 部番剧。\",\n        )\n\n    def search_all_bangumi(self):\n        datas = self.bangumi.search_all()\n        if not datas:\n            return []\n        return [data for data in datas if not data.deleted]\n\n    def search_one(self, _id: int | str):\n        data = self.bangumi.search_id(int(_id))\n        if not data:\n            logger.error(f\"[Manager] Can't find data with {_id}\")\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find data with {_id}\",\n                msg_zh=f\"无法找到 id {_id} 的数据\",\n            )\n        else:\n            return data\n\n    def archive_rule(self, _id: int):\n        \"\"\"Archive a bangumi.\"\"\"\n        data = self.bangumi.search_id(_id)\n        if not data:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find id {_id}\",\n                msg_zh=f\"无法找到 id {_id}\",\n            )\n        if self.bangumi.archive_one(_id):\n            logger.info(f\"[Manager] Archived {data.official_title}\")\n            return ResponseModel(\n                status_code=200,\n                status=True,\n                msg_en=f\"Archived {data.official_title}\",\n                msg_zh=f\"已归档 {data.official_title}\",\n            )\n        return ResponseModel(\n            status_code=500,\n            status=False,\n            msg_en=f\"Failed to archive {data.official_title}\",\n            msg_zh=f\"归档 {data.official_title} 失败\",\n        )\n\n    def unarchive_rule(self, _id: int):\n        \"\"\"Unarchive a bangumi.\"\"\"\n        data = self.bangumi.search_id(_id)\n        if not data:\n            return ResponseModel(\n                status_code=406,\n                status=False,\n                msg_en=f\"Can't find id {_id}\",\n                msg_zh=f\"无法找到 id {_id}\",\n            )\n        if self.bangumi.unarchive_one(_id):\n            logger.info(f\"[Manager] Unarchived {data.official_title}\")\n            return ResponseModel(\n                status_code=200,\n                status=True,\n                msg_en=f\"Unarchived {data.official_title}\",\n                msg_zh=f\"已取消归档 {data.official_title}\",\n            )\n        return ResponseModel(\n            status_code=500,\n            status=False,\n            msg_en=f\"Failed to unarchive {data.official_title}\",\n            msg_zh=f\"取消归档 {data.official_title} 失败\",\n        )\n\n    async def refresh_metadata(self):\n        \"\"\"Refresh TMDB metadata and auto-archive ended series.\"\"\"\n        bangumis = self.bangumi.search_all()\n        language = settings.rss_parser.language\n        archived_count = 0\n        poster_count = 0\n\n        for bangumi in bangumis:\n            if bangumi.deleted:\n                continue\n            tmdb_info = await tmdb_parser(bangumi.official_title, language)\n            if tmdb_info:\n                # Update poster if missing\n                if not bangumi.poster_link and tmdb_info.poster_link:\n                    bangumi.poster_link = tmdb_info.poster_link\n                    poster_count += 1\n                # Auto-archive ended series\n                if tmdb_info.series_status == \"Ended\" and not bangumi.archived:\n                    bangumi.archived = True\n                    archived_count += 1\n                    logger.info(\n                        f\"[Manager] Auto-archived ended series: {bangumi.official_title}\"\n                    )\n\n        if archived_count > 0 or poster_count > 0:\n            self.bangumi.update_all(bangumis)\n\n        logger.info(\n            f\"[Manager] Metadata refresh: archived {archived_count}, updated posters {poster_count}\"\n        )\n        return ResponseModel(\n            status_code=200,\n            status=True,\n            msg_en=f\"Metadata refreshed. Archived {archived_count} ended series, updated {poster_count} posters.\",\n            msg_zh=f\"已刷新元数据。归档了 {archived_count} 部已完结番剧，更新了 {poster_count} 个海报。\",\n        )\n\n    async def suggest_offset(self, bangumi_id: int) -> dict:\n        \"\"\"Suggest offset based on TMDB episode counts.\"\"\"\n        data = self.bangumi.search_id(bangumi_id)\n        if not data:\n            return {\n                \"suggested_offset\": 0,\n                \"reason\": f\"Bangumi id {bangumi_id} not found\",\n            }\n\n        language = settings.rss_parser.language\n        tmdb_info = await tmdb_parser(data.official_title, language)\n\n        if not tmdb_info or not tmdb_info.season_episode_counts:\n            return {\n                \"suggested_offset\": 0,\n                \"reason\": \"Unable to fetch TMDB episode data\",\n            }\n\n        season = data.season\n        if season <= 1:\n            return {\"suggested_offset\": 0, \"reason\": \"Season 1 does not need offset\"}\n\n        offset = tmdb_info.get_offset_for_season(season)\n        if offset == 0:\n            return {\"suggested_offset\": 0, \"reason\": \"No previous seasons found\"}\n\n        # Build reason with episode counts\n        prev_seasons = [\n            f\"S{s}: {tmdb_info.season_episode_counts.get(s, 0)} eps\"\n            for s in range(1, season)\n            if s in tmdb_info.season_episode_counts\n        ]\n        reason = f\"Previous seasons: {', '.join(prev_seasons)}\"\n\n        return {\"suggested_offset\": offset, \"reason\": reason}\n"
  },
  {
    "path": "backend/src/module/mcp/__init__.py",
    "content": "\"\"\"MCP (Model Context Protocol) server for AutoBangumi.\n\nExposes anime subscriptions, RSS feeds, and download status to MCP clients\n(e.g. Claude Desktop) over a local-network-restricted SSE endpoint.\n\nUsage::\n\n    from module.mcp import create_mcp_app\n\n    app = create_mcp_app()  # returns a Starlette ASGI app, mount at /mcp\n\"\"\"\n\nfrom .server import create_mcp_starlette_app as create_mcp_app\n"
  },
  {
    "path": "backend/src/module/mcp/resources.py",
    "content": "\"\"\"MCP resource definitions and handlers for AutoBangumi.\n\n``RESOURCES`` lists static resources; ``RESOURCE_TEMPLATES`` lists URI\ntemplates for parameterised lookups. ``handle_resource`` resolves a URI\nto its JSON payload.\n\"\"\"\n\nimport json\nimport logging\n\nfrom mcp import types\n\nfrom module.conf import VERSION\nfrom module.manager import TorrentManager\nfrom module.models import Bangumi\nfrom module.rss import RSSEngine\n\nfrom .tools import _bangumi_to_dict\n\nlogger = logging.getLogger(__name__)\n\nRESOURCES = [\n    types.Resource(\n        uri=\"autobangumi://anime/list\",\n        name=\"All tracked anime\",\n        description=\"List of all anime subscriptions being tracked by AutoBangumi\",\n        mimeType=\"application/json\",\n    ),\n    types.Resource(\n        uri=\"autobangumi://status\",\n        name=\"Program status\",\n        description=\"Current AutoBangumi program status, version, and state\",\n        mimeType=\"application/json\",\n    ),\n    types.Resource(\n        uri=\"autobangumi://rss/feeds\",\n        name=\"RSS feeds\",\n        description=\"All configured RSS feeds with health status\",\n        mimeType=\"application/json\",\n    ),\n]\n\nRESOURCE_TEMPLATES = [\n    types.ResourceTemplate(\n        uriTemplate=\"autobangumi://anime/{id}\",\n        name=\"Anime details\",\n        description=\"Detailed information about a specific tracked anime by ID\",\n        mimeType=\"application/json\",\n    ),\n]\n\n\ndef handle_resource(uri: str) -> str:\n    \"\"\"Return a JSON string for the given MCP resource URI.\n\n    Supported URIs:\n    - ``autobangumi://anime/list`` - all tracked anime\n    - ``autobangumi://status`` - program version and running state\n    - ``autobangumi://rss/feeds`` - configured RSS feeds\n    - ``autobangumi://anime/{id}`` - single anime by integer ID\n    \"\"\"\n    if uri == \"autobangumi://anime/list\":\n        with TorrentManager() as manager:\n            items = manager.bangumi.search_all()\n        return json.dumps([_bangumi_to_dict(b) for b in items], ensure_ascii=False)\n\n    elif uri == \"autobangumi://status\":\n        from module.api.program import program\n\n        return json.dumps(\n            {\n                \"version\": VERSION,\n                \"running\": program.is_running,\n                \"first_run\": program.first_run,\n            }\n        )\n\n    elif uri == \"autobangumi://rss/feeds\":\n        with RSSEngine() as engine:\n            feeds = engine.rss.search_all()\n        return json.dumps(\n            [\n                {\n                    \"id\": f.id,\n                    \"name\": f.name,\n                    \"url\": f.url,\n                    \"enabled\": f.enabled,\n                    \"connection_status\": f.connection_status,\n                    \"last_checked_at\": f.last_checked_at,\n                }\n                for f in feeds\n            ],\n            ensure_ascii=False,\n        )\n\n    elif uri.startswith(\"autobangumi://anime/\"):\n        anime_id = uri.split(\"/\")[-1]\n        try:\n            anime_id = int(anime_id)\n        except ValueError:\n            return json.dumps({\"error\": f\"Invalid anime ID: {anime_id}\"})\n        with TorrentManager() as manager:\n            result = manager.search_one(anime_id)\n        if isinstance(result, Bangumi):\n            return json.dumps(_bangumi_to_dict(result), ensure_ascii=False)\n        return json.dumps({\"error\": result.msg_en})\n\n    return json.dumps({\"error\": f\"Unknown resource: {uri}\"})\n"
  },
  {
    "path": "backend/src/module/mcp/security.py",
    "content": "\"\"\"MCP access control: configurable IP whitelist and bearer token authentication.\"\"\"\n\nimport ipaddress\nimport logging\nfrom functools import lru_cache\n\nfrom starlette.middleware.base import BaseHTTPMiddleware\nfrom starlette.requests import Request\nfrom starlette.responses import JSONResponse\n\nfrom module.conf import settings\n\nlogger = logging.getLogger(__name__)\n\n\n@lru_cache(maxsize=128)\ndef _parse_network(cidr: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network | None:\n    try:\n        return ipaddress.ip_network(cidr, strict=False)\n    except ValueError:\n        logger.warning(\"[MCP] Invalid CIDR in whitelist: %s\", cidr)\n        return None\n\n\ndef _is_allowed(host: str, whitelist: list[str]) -> bool:\n    \"\"\"Return True if *host* falls within any CIDR range in *whitelist*.\"\"\"\n    try:\n        addr = ipaddress.ip_address(host)\n    except ValueError:\n        return False\n    for cidr in whitelist:\n        net = _parse_network(cidr)\n        if net and addr in net:\n            return True\n    return False\n\n\ndef clear_network_cache():\n    \"\"\"Clear the parsed network cache (call after config reload).\"\"\"\n    _parse_network.cache_clear()\n\n\nclass McpAccessMiddleware(BaseHTTPMiddleware):\n    \"\"\"Configurable access control for MCP endpoint.\n\n    Checks client IP against ``settings.security.mcp_whitelist`` CIDR ranges,\n    and ``Authorization`` header against ``settings.security.mcp_tokens``.\n    If the whitelist is empty and no tokens are configured, all access is denied.\n    \"\"\"\n\n    async def dispatch(self, request: Request, call_next):\n        # Check bearer token first\n        auth_header = request.headers.get(\"authorization\", \"\")\n        if auth_header.startswith(\"Bearer \"):\n            token = auth_header[7:]\n            if token and token in settings.security.mcp_tokens:\n                return await call_next(request)\n\n        # Check IP whitelist\n        client_host = request.client.host if request.client else None\n        if client_host and _is_allowed(client_host, settings.security.mcp_whitelist):\n            return await call_next(request)\n\n        logger.warning(\"[MCP] Rejected connection from %s\", client_host)\n        return JSONResponse(\n            status_code=403,\n            content={\"error\": \"MCP access denied\"},\n        )\n"
  },
  {
    "path": "backend/src/module/mcp/server.py",
    "content": "\"\"\"MCP server assembly for AutoBangumi.\n\nWires together the MCP ``Server``, SSE transport, tool/resource handlers,\nand local-network middleware into a single Starlette ASGI application.\n\nMount the app returned by ``create_mcp_starlette_app`` at a path prefix\n(e.g. ``/mcp``) in the parent FastAPI application to expose the MCP\nendpoint at ``/mcp/sse``.\n\"\"\"\n\nimport logging\n\nfrom mcp import types\nfrom mcp.server import Server\nfrom mcp.server.sse import SseServerTransport\nfrom starlette.applications import Starlette\nfrom starlette.requests import Request\nfrom starlette.responses import Response\nfrom starlette.routing import Mount, Route\n\nfrom .resources import RESOURCE_TEMPLATES, RESOURCES, handle_resource\nfrom .security import McpAccessMiddleware\nfrom .tools import TOOLS, handle_tool\n\nlogger = logging.getLogger(__name__)\n\nserver = Server(\"autobangumi\")\nsse = SseServerTransport(\"/messages/\")\n\n\n@server.list_tools()\nasync def list_tools() -> list[types.Tool]:\n    return TOOLS\n\n\n@server.call_tool()\nasync def call_tool(name: str, arguments: dict) -> list[types.TextContent]:\n    logger.debug(\"[MCP] Tool called: %s\", name)\n    return await handle_tool(name, arguments)\n\n\n@server.list_resources()\nasync def list_resources() -> list[types.Resource]:\n    return RESOURCES\n\n\n@server.list_resource_templates()\nasync def list_resource_templates() -> list[types.ResourceTemplate]:\n    return RESOURCE_TEMPLATES\n\n\n@server.read_resource()\nasync def read_resource(uri: str) -> str:\n    logger.debug(\"[MCP] Resource read: %s\", uri)\n    return handle_resource(uri)\n\n\nasync def handle_sse(request: Request):\n    \"\"\"Accept an SSE connection, run the MCP session until the client disconnects.\"\"\"\n    async with sse.connect_sse(\n        request.scope, request.receive, request._send\n    ) as streams:\n        await server.run(\n            streams[0],\n            streams[1],\n            server.create_initialization_options(),\n        )\n    return Response()\n\n\ndef create_mcp_starlette_app() -> Starlette:\n    \"\"\"Build and return the MCP Starlette sub-application.\n\n    Routes:\n    - ``GET /sse`` - SSE stream for MCP clients\n    - ``POST /messages/`` - client-to-server message posting\n\n    ``McpAccessMiddleware`` is applied to enforce configurable IP whitelist\n    and bearer token access control.\n    \"\"\"\n    app = Starlette(\n        routes=[\n            Route(\"/sse\", endpoint=handle_sse),\n            Mount(\"/messages\", app=sse.handle_post_message),\n        ],\n    )\n    app.add_middleware(McpAccessMiddleware)\n    return app\n"
  },
  {
    "path": "backend/src/module/mcp/tools.py",
    "content": "import json\nimport logging\n\nfrom mcp import types\n\nfrom module.conf import VERSION\nfrom module.downloader import DownloadClient\nfrom module.manager import SeasonCollector, TorrentManager\nfrom module.models import Bangumi, BangumiUpdate, RSSItem\nfrom module.rss import RSSAnalyser, RSSEngine\nfrom module.searcher import SearchTorrent\n\nlogger = logging.getLogger(__name__)\n\nTOOLS = [\n    types.Tool(\n        name=\"list_anime\",\n        description=\"List all tracked anime subscriptions. Returns title, season, status, and episode offset for each.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"active_only\": {\n                    \"type\": \"boolean\",\n                    \"description\": \"If true, only return active (non-disabled) anime\",\n                    \"default\": False,\n                },\n            },\n        },\n    ),\n    types.Tool(\n        name=\"get_anime\",\n        description=\"Get detailed information about a specific anime subscription by its ID.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"id\": {\n                    \"type\": \"integer\",\n                    \"description\": \"The anime/bangumi ID\",\n                },\n            },\n            \"required\": [\"id\"],\n        },\n    ),\n    types.Tool(\n        name=\"search_anime\",\n        description=\"Search for anime torrents across torrent sites (Mikan, DMHY, Nyaa). Returns available anime matching the keywords.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"keywords\": {\n                    \"type\": \"string\",\n                    \"description\": \"Search keywords (e.g. anime title)\",\n                },\n                \"site\": {\n                    \"type\": \"string\",\n                    \"description\": \"Torrent site to search\",\n                    \"enum\": [\"mikan\", \"dmhy\", \"nyaa\"],\n                    \"default\": \"mikan\",\n                },\n            },\n            \"required\": [\"keywords\"],\n        },\n    ),\n    types.Tool(\n        name=\"subscribe_anime\",\n        description=\"Subscribe to an anime series by providing its RSS link. Analyzes the RSS feed and sets up automatic downloading.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"rss_link\": {\n                    \"type\": \"string\",\n                    \"description\": \"RSS feed URL for the anime (obtained from search_anime results)\",\n                },\n                \"parser\": {\n                    \"type\": \"string\",\n                    \"description\": \"RSS parser type\",\n                    \"enum\": [\"mikan\", \"dmhy\", \"nyaa\"],\n                    \"default\": \"mikan\",\n                },\n            },\n            \"required\": [\"rss_link\"],\n        },\n    ),\n    types.Tool(\n        name=\"unsubscribe_anime\",\n        description=\"Unsubscribe from an anime. Can either disable (keeps data) or fully delete the subscription.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"id\": {\n                    \"type\": \"integer\",\n                    \"description\": \"The anime/bangumi ID to unsubscribe\",\n                },\n                \"delete\": {\n                    \"type\": \"boolean\",\n                    \"description\": \"If true, permanently delete the subscription. If false, just disable it.\",\n                    \"default\": False,\n                },\n            },\n            \"required\": [\"id\"],\n        },\n    ),\n    types.Tool(\n        name=\"list_downloads\",\n        description=\"Show current torrent download status from the download client (qBittorrent/Aria2).\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"status\": {\n                    \"type\": \"string\",\n                    \"description\": \"Filter by download status\",\n                    \"enum\": [\"all\", \"downloading\", \"completed\", \"paused\"],\n                    \"default\": \"all\",\n                },\n            },\n        },\n    ),\n    types.Tool(\n        name=\"list_rss_feeds\",\n        description=\"List all configured RSS feeds with their connection status and health information.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {},\n        },\n    ),\n    types.Tool(\n        name=\"get_program_status\",\n        description=\"Get the current program status including version, running state, and first-run flag.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {},\n        },\n    ),\n    types.Tool(\n        name=\"refresh_feeds\",\n        description=\"Trigger an immediate refresh of all RSS feeds to check for new episodes.\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {},\n        },\n    ),\n    types.Tool(\n        name=\"update_anime\",\n        description=\"Update settings for a tracked anime (episode offset, season offset, filters, etc.).\",\n        inputSchema={\n            \"type\": \"object\",\n            \"properties\": {\n                \"id\": {\n                    \"type\": \"integer\",\n                    \"description\": \"The anime/bangumi ID to update\",\n                },\n                \"episode_offset\": {\n                    \"type\": \"integer\",\n                    \"description\": \"Episode number offset for renaming\",\n                },\n                \"season_offset\": {\n                    \"type\": \"integer\",\n                    \"description\": \"Season number offset for renaming\",\n                },\n                \"season\": {\n                    \"type\": \"integer\",\n                    \"description\": \"Season number\",\n                },\n                \"filter\": {\n                    \"type\": \"string\",\n                    \"description\": \"Comma-separated filter patterns to exclude\",\n                },\n            },\n            \"required\": [\"id\"],\n        },\n    ),\n]\n\n\ndef _bangumi_to_dict(b: Bangumi) -> dict:\n    return {\n        \"id\": b.id,\n        \"official_title\": b.official_title,\n        \"title_raw\": b.title_raw,\n        \"season\": b.season,\n        \"group_name\": b.group_name,\n        \"dpi\": b.dpi,\n        \"source\": b.source,\n        \"subtitle\": b.subtitle,\n        \"episode_offset\": b.episode_offset,\n        \"season_offset\": b.season_offset,\n        \"filter\": b.filter,\n        \"rss_link\": b.rss_link,\n        \"poster_link\": b.poster_link,\n        \"added\": b.added,\n        \"save_path\": b.save_path,\n        \"deleted\": b.deleted,\n        \"archived\": b.archived,\n        \"eps_collect\": b.eps_collect,\n    }\n\n\nasync def handle_tool(name: str, arguments: dict) -> list[types.TextContent]:\n    try:\n        result = await _dispatch(name, arguments)\n        return [\n            types.TextContent(type=\"text\", text=json.dumps(result, ensure_ascii=False))\n        ]\n    except Exception as e:\n        logger.exception(\"[MCP] Tool %s failed\", name)\n        return [\n            types.TextContent(\n                type=\"text\", text=json.dumps({\"error\": str(e)}, ensure_ascii=False)\n            )\n        ]\n\n\nasync def _dispatch(name: str, args: dict) -> dict | list:\n    if name == \"list_anime\":\n        return _list_anime(args.get(\"active_only\", False))\n    elif name == \"get_anime\":\n        return _get_anime(args[\"id\"])\n    elif name == \"search_anime\":\n        return await _search_anime(args[\"keywords\"], args.get(\"site\", \"mikan\"))\n    elif name == \"subscribe_anime\":\n        return await _subscribe_anime(args[\"rss_link\"], args.get(\"parser\", \"mikan\"))\n    elif name == \"unsubscribe_anime\":\n        return await _unsubscribe_anime(args[\"id\"], args.get(\"delete\", False))\n    elif name == \"list_downloads\":\n        return await _list_downloads(args.get(\"status\", \"all\"))\n    elif name == \"list_rss_feeds\":\n        return _list_rss_feeds()\n    elif name == \"get_program_status\":\n        return _get_program_status()\n    elif name == \"refresh_feeds\":\n        return await _refresh_feeds()\n    elif name == \"update_anime\":\n        return await _update_anime(args)\n    else:\n        return {\"error\": f\"Unknown tool: {name}\"}\n\n\ndef _list_anime(active_only: bool) -> list[dict]:\n    with TorrentManager() as manager:\n        if active_only:\n            items = manager.search_all_bangumi()\n        else:\n            items = manager.bangumi.search_all()\n    return [_bangumi_to_dict(b) for b in items]\n\n\ndef _get_anime(bangumi_id: int) -> dict:\n    with TorrentManager() as manager:\n        result = manager.search_one(bangumi_id)\n    if isinstance(result, Bangumi):\n        return _bangumi_to_dict(result)\n    return {\"error\": result.msg_en}\n\n\nasync def _search_anime(keywords: str, site: str) -> list[dict]:\n    keyword_list = keywords.split()\n    results = []\n    async with SearchTorrent() as st:\n        async for item_json in st.analyse_keyword(keywords=keyword_list, site=site):\n            results.append(json.loads(item_json))\n            if len(results) >= 20:\n                break\n    return results\n\n\nasync def _subscribe_anime(rss_link: str, parser: str) -> dict:\n    analyser = RSSAnalyser()\n    rss = RSSItem(url=rss_link, parser=parser)\n    data = await analyser.link_to_data(rss)\n    if not isinstance(data, Bangumi):\n        return {\"error\": data.msg_en if hasattr(data, \"msg_en\") else str(data)}\n    resp = await SeasonCollector.subscribe_season(data, parser=parser)\n    return {\"status\": resp.status, \"message\": resp.msg_en}\n\n\nasync def _unsubscribe_anime(bangumi_id: int, delete: bool) -> dict:\n    with TorrentManager() as manager:\n        if delete:\n            resp = await manager.delete_rule(bangumi_id)\n        else:\n            resp = await manager.disable_rule(bangumi_id)\n    return {\"status\": resp.status, \"message\": resp.msg_en}\n\n\nasync def _list_downloads(status: str) -> list[dict]:\n    status_filter = None if status == \"all\" else status\n    async with DownloadClient() as client:\n        torrents = await client.get_torrent_info(\n            status_filter=status_filter, category=\"Bangumi\"\n        )\n    return [\n        {\n            \"name\": t.get(\"name\", \"\"),\n            \"size\": t.get(\"size\", 0),\n            \"progress\": t.get(\"progress\", 0),\n            \"state\": t.get(\"state\", \"\"),\n            \"dlspeed\": t.get(\"dlspeed\", 0),\n            \"upspeed\": t.get(\"upspeed\", 0),\n            \"eta\": t.get(\"eta\", 0),\n        }\n        for t in torrents\n    ]\n\n\ndef _list_rss_feeds() -> list[dict]:\n    with RSSEngine() as engine:\n        feeds = engine.rss.search_all()\n    return [\n        {\n            \"id\": f.id,\n            \"name\": f.name,\n            \"url\": f.url,\n            \"aggregate\": f.aggregate,\n            \"parser\": f.parser,\n            \"enabled\": f.enabled,\n            \"connection_status\": f.connection_status,\n            \"last_checked_at\": f.last_checked_at,\n            \"last_error\": f.last_error,\n        }\n        for f in feeds\n    ]\n\n\ndef _get_program_status() -> dict:\n    from module.api.program import program\n\n    return {\n        \"version\": VERSION,\n        \"running\": program.is_running,\n        \"first_run\": program.first_run,\n    }\n\n\nasync def _refresh_feeds() -> dict:\n    async with DownloadClient() as client:\n        with RSSEngine() as engine:\n            await engine.refresh_rss(client)\n    return {\"status\": True, \"message\": \"RSS feeds refreshed successfully\"}\n\n\nasync def _update_anime(args: dict) -> dict:\n    bangumi_id = args[\"id\"]\n    with TorrentManager() as manager:\n        existing = manager.bangumi.search_id(bangumi_id)\n        if not existing:\n            return {\"error\": f\"Anime with id {bangumi_id} not found\"}\n\n        update_data = BangumiUpdate(**existing.model_dump())\n        if \"episode_offset\" in args:\n            update_data.episode_offset = args[\"episode_offset\"]\n        if \"season_offset\" in args:\n            update_data.season_offset = args[\"season_offset\"]\n        if \"season\" in args:\n            update_data.season = args[\"season\"]\n        if \"filter\" in args:\n            update_data.filter = args[\"filter\"]\n\n        resp = await manager.update_rule(bangumi_id, update_data)\n    return {\"status\": resp.status, \"message\": resp.msg_en}\n"
  },
  {
    "path": "backend/src/module/models/__init__.py",
    "content": "from .bangumi import Bangumi, BangumiUpdate, Episode, Notification\nfrom .config import Config\nfrom .passkey import Passkey, PasskeyCreate, PasskeyDelete, PasskeyList\nfrom .response import APIResponse, ResponseModel\nfrom .rss import RSSItem, RSSUpdate\nfrom .torrent import EpisodeFile, SubtitleFile, Torrent, TorrentUpdate\nfrom .user import User, UserLogin, UserUpdate\n"
  },
  {
    "path": "backend/src/module/models/api.py",
    "content": "from pydantic import BaseModel\n\n\nclass RssLink(BaseModel):\n    rss_link: str\n\n\nclass AddRule(BaseModel):\n    title: str\n    season: int\n\n\nclass ChangeConfig(BaseModel):\n    config: dict\n\n\nclass ChangeRule(BaseModel):\n    rule: dict\n"
  },
  {
    "path": "backend/src/module/models/bangumi.py",
    "content": "from dataclasses import dataclass\nfrom typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass Bangumi(SQLModel, table=True):\n    id: int = Field(default=None, primary_key=True)\n    official_title: str = Field(\n        default=\"official_title\", alias=\"official_title\", title=\"番剧中文名\"\n    )\n    year: Optional[str] = Field(alias=\"year\", title=\"番剧年份\")\n    title_raw: str = Field(\n        default=\"title_raw\", alias=\"title_raw\", title=\"番剧原名\", index=True\n    )\n    season: int = Field(default=1, alias=\"season\", title=\"番剧季度\")\n    season_raw: Optional[str] = Field(alias=\"season_raw\", title=\"番剧季度原名\")\n    group_name: Optional[str] = Field(alias=\"group_name\", title=\"字幕组\")\n    dpi: Optional[str] = Field(alias=\"dpi\", title=\"分辨率\")\n    source: Optional[str] = Field(alias=\"source\", title=\"来源\")\n    subtitle: Optional[str] = Field(alias=\"subtitle\", title=\"字幕\")\n    eps_collect: bool = Field(default=False, alias=\"eps_collect\", title=\"是否已收集\")\n    episode_offset: int = Field(default=0, alias=\"episode_offset\", title=\"集数偏移量\")\n    season_offset: int = Field(default=0, alias=\"season_offset\", title=\"季度偏移量\")\n    filter: str = Field(default=\"720,\\\\d+-\\\\d+\", alias=\"filter\", title=\"番剧过滤器\")\n    rss_link: str = Field(default=\"\", alias=\"rss_link\", title=\"番剧RSS链接\")\n    poster_link: Optional[str] = Field(alias=\"poster_link\", title=\"番剧海报链接\")\n    added: bool = Field(default=False, alias=\"added\", title=\"是否已添加\")\n    rule_name: Optional[str] = Field(alias=\"rule_name\", title=\"番剧规则名\")\n    save_path: Optional[str] = Field(alias=\"save_path\", title=\"番剧保存路径\")\n    deleted: bool = Field(False, alias=\"deleted\", title=\"是否已删除\", index=True)\n    archived: bool = Field(\n        default=False, alias=\"archived\", title=\"是否已归档\", index=True\n    )\n    air_weekday: Optional[int] = Field(\n        default=None, alias=\"air_weekday\", title=\"放送星期\"\n    )\n    weekday_locked: bool = Field(\n        default=False, alias=\"weekday_locked\", title=\"放送星期锁定\"\n    )\n    needs_review: bool = Field(default=False, alias=\"needs_review\", title=\"需要检查\")\n    needs_review_reason: Optional[str] = Field(\n        default=None, alias=\"needs_review_reason\", title=\"检查原因\"\n    )\n    suggested_season_offset: Optional[int] = Field(\n        default=None, alias=\"suggested_season_offset\", title=\"建议季度偏移\"\n    )\n    suggested_episode_offset: Optional[int] = Field(\n        default=None, alias=\"suggested_episode_offset\", title=\"建议集数偏移\"\n    )\n    title_aliases: Optional[str] = Field(\n        default=None, alias=\"title_aliases\", title=\"标题别名\"\n    )  # JSON list: [\"alt_title_1\", \"alt_title_2\"]\n\n\nclass BangumiUpdate(SQLModel):\n    official_title: str = Field(\n        default=\"official_title\", alias=\"official_title\", title=\"番剧中文名\"\n    )\n    year: Optional[str] = Field(alias=\"year\", title=\"番剧年份\")\n    title_raw: str = Field(default=\"title_raw\", alias=\"title_raw\", title=\"番剧原名\")\n    season: int = Field(default=1, alias=\"season\", title=\"番剧季度\")\n    season_raw: Optional[str] = Field(alias=\"season_raw\", title=\"番剧季度原名\")\n    group_name: Optional[str] = Field(alias=\"group_name\", title=\"字幕组\")\n    dpi: Optional[str] = Field(alias=\"dpi\", title=\"分辨率\")\n    source: Optional[str] = Field(alias=\"source\", title=\"来源\")\n    subtitle: Optional[str] = Field(alias=\"subtitle\", title=\"字幕\")\n    eps_collect: bool = Field(default=False, alias=\"eps_collect\", title=\"是否已收集\")\n    episode_offset: int = Field(default=0, alias=\"episode_offset\", title=\"集数偏移量\")\n    season_offset: int = Field(default=0, alias=\"season_offset\", title=\"季度偏移量\")\n    filter: str = Field(default=\"720,\\\\d+-\\\\d+\", alias=\"filter\", title=\"番剧过滤器\")\n    rss_link: str = Field(default=\"\", alias=\"rss_link\", title=\"番剧RSS链接\")\n    poster_link: Optional[str] = Field(alias=\"poster_link\", title=\"番剧海报链接\")\n    added: bool = Field(default=False, alias=\"added\", title=\"是否已添加\")\n    rule_name: Optional[str] = Field(alias=\"rule_name\", title=\"番剧规则名\")\n    save_path: Optional[str] = Field(alias=\"save_path\", title=\"番剧保存路径\")\n    deleted: bool = Field(False, alias=\"deleted\", title=\"是否已删除\")\n    archived: bool = Field(default=False, alias=\"archived\", title=\"是否已归档\")\n    air_weekday: Optional[int] = Field(\n        default=None, alias=\"air_weekday\", title=\"放送星期\"\n    )\n    weekday_locked: bool = Field(\n        default=False, alias=\"weekday_locked\", title=\"放送星期锁定\"\n    )\n    needs_review: bool = Field(default=False, alias=\"needs_review\", title=\"需要检查\")\n    needs_review_reason: Optional[str] = Field(\n        default=None, alias=\"needs_review_reason\", title=\"检查原因\"\n    )\n    title_aliases: Optional[str] = Field(\n        default=None, alias=\"title_aliases\", title=\"标题别名\"\n    )\n\n\nclass Notification(BaseModel):\n    official_title: str = Field(..., alias=\"official_title\", title=\"番剧名\")\n    season: int = Field(..., alias=\"season\", title=\"番剧季度\")\n    episode: int = Field(..., alias=\"episode\", title=\"番剧集数\")\n    poster_path: Optional[str] = Field(None, alias=\"poster_path\", title=\"番剧海报路径\")\n\n\n@dataclass(slots=True)\nclass Episode:\n    title_en: Optional[str]\n    title_zh: Optional[str]\n    title_jp: Optional[str]\n    season: int\n    season_raw: str\n    episode: int\n    sub: str\n    group: str\n    resolution: str\n    source: str\n\n\n@dataclass(slots=True)\nclass SeasonInfo:\n    official_title: str\n    title_raw: str\n    season: int\n    season_raw: str\n    group: str\n    filter: list | None\n    episode_offset: int | None\n    season_offset: int | None\n    dpi: str\n    source: str\n    subtitle: str\n    added: bool\n    eps_collect: bool\n"
  },
  {
    "path": "backend/src/module/models/config.py",
    "content": "from os.path import expandvars\nfrom typing import Literal, Optional\n\nfrom pydantic import BaseModel, Field, field_validator, model_validator\n\n\ndef _expand(value: str | None) -> str:\n    \"\"\"Expand shell environment variables in *value*, returning empty string for None.\"\"\"\n    return expandvars(value) if value else \"\"\n\n\nclass Program(BaseModel):\n    \"\"\"Scheduler timing and WebUI port settings.\"\"\"\n\n    rss_time: int = Field(900, description=\"Sleep time\")\n    rename_time: int = Field(60, description=\"Rename times in one loop\")\n    webui_port: int = Field(7892, description=\"WebUI port\")\n\n\nclass Downloader(BaseModel):\n    \"\"\"Download client connection settings.\n\n    Credential fields (``host``, ``username``, ``password``) are stored with a\n    trailing underscore and exposed via properties that expand ``$VAR``\n    environment variable references at access time.\n    \"\"\"\n\n    type: str = Field(\"qbittorrent\", description=\"Downloader type\")\n    host_: str = Field(\"172.17.0.1:8080\", alias=\"host\", description=\"Downloader host\")\n    username_: str = Field(\"admin\", alias=\"username\", description=\"Downloader username\")\n    password_: str = Field(\n        \"adminadmin\", alias=\"password\", description=\"Downloader password\"\n    )\n    path: str = Field(\"/downloads/Bangumi\", description=\"Downloader path\")\n    ssl: bool = Field(False, description=\"Downloader ssl\")\n\n    @property\n    def host(self):\n        return _expand(self.host_)\n\n    @property\n    def username(self):\n        return _expand(self.username_)\n\n    @property\n    def password(self):\n        return _expand(self.password_)\n\n\nclass RSSParser(BaseModel):\n    \"\"\"RSS feed parsing settings.\"\"\"\n\n    enable: bool = Field(True, description=\"Enable RSS parser\")\n    filter: list[str] = Field([\"720\", r\"\\d+-\\d+\"], description=\"Filter\")\n    language: str = \"zh\"\n\n\nclass BangumiManage(BaseModel):\n    \"\"\"File organisation and renaming settings.\"\"\"\n\n    enable: bool = Field(True, description=\"Enable bangumi manage\")\n    eps_complete: bool = Field(False, description=\"Enable eps complete\")\n    rename_method: str = Field(\"pn\", description=\"Rename method\")\n    group_tag: bool = Field(False, description=\"Enable group tag\")\n    remove_bad_torrent: bool = Field(False, description=\"Remove bad torrent\")\n\n\nclass Log(BaseModel):\n    \"\"\"Logging verbosity settings.\"\"\"\n\n    debug_enable: bool = Field(False, description=\"Enable debug\")\n\n\nclass Proxy(BaseModel):\n    \"\"\"HTTP/SOCKS proxy settings. Credentials support ``$VAR`` expansion.\"\"\"\n\n    enable: bool = Field(False, description=\"Enable proxy\")\n    type: str = Field(\"http\", description=\"Proxy type\")\n    host: str = Field(\"\", description=\"Proxy host\")\n    port: int = Field(0, description=\"Proxy port\")\n    username_: str = Field(\"\", alias=\"username\", description=\"Proxy username\")\n    password_: str = Field(\"\", alias=\"password\", description=\"Proxy password\")\n\n    @property\n    def username(self):\n        return _expand(self.username_)\n\n    @property\n    def password(self):\n        return _expand(self.password_)\n\n\nclass NotificationProvider(BaseModel):\n    \"\"\"Configuration for a single notification provider.\"\"\"\n\n    type: str = Field(..., description=\"Provider type (telegram, discord, bark, etc.)\")\n    enabled: bool = Field(True, description=\"Whether this provider is enabled\")\n\n    # Common fields (with env var expansion)\n    token_: Optional[str] = Field(None, alias=\"token\", description=\"Auth token\")\n    chat_id_: Optional[str] = Field(None, alias=\"chat_id\", description=\"Chat/channel ID\")\n\n    # Provider-specific fields\n    webhook_url_: Optional[str] = Field(\n        None, alias=\"webhook_url\", description=\"Webhook URL for discord/wecom\"\n    )\n    server_url_: Optional[str] = Field(\n        None, alias=\"server_url\", description=\"Server URL for gotify/bark\"\n    )\n    device_key_: Optional[str] = Field(\n        None, alias=\"device_key\", description=\"Device key for bark\"\n    )\n    user_key_: Optional[str] = Field(\n        None, alias=\"user_key\", description=\"User key for pushover\"\n    )\n    api_token_: Optional[str] = Field(\n        None, alias=\"api_token\", description=\"API token for pushover\"\n    )\n    template: Optional[str] = Field(\n        None, description=\"Custom template for webhook provider\"\n    )\n    url_: Optional[str] = Field(\n        None, alias=\"url\", description=\"URL for generic webhook provider\"\n    )\n\n    @property\n    def token(self) -> str:\n        return _expand(self.token_)\n\n    @property\n    def chat_id(self) -> str:\n        return _expand(self.chat_id_)\n\n    @property\n    def webhook_url(self) -> str:\n        return _expand(self.webhook_url_)\n\n    @property\n    def server_url(self) -> str:\n        return _expand(self.server_url_)\n\n    @property\n    def device_key(self) -> str:\n        return _expand(self.device_key_)\n\n    @property\n    def user_key(self) -> str:\n        return _expand(self.user_key_)\n\n    @property\n    def api_token(self) -> str:\n        return _expand(self.api_token_)\n\n    @property\n    def url(self) -> str:\n        return _expand(self.url_)\n\n\nclass Notification(BaseModel):\n    \"\"\"Notification configuration supporting multiple providers.\"\"\"\n\n    enable: bool = Field(False, description=\"Enable notification system\")\n    providers: list[NotificationProvider] = Field(\n        default_factory=list, description=\"List of notification providers\"\n    )\n\n    # Legacy fields for backward compatibility (deprecated)\n    type: Optional[str] = Field(None, description=\"[Deprecated] Use providers instead\")\n    token_: Optional[str] = Field(None, alias=\"token\", description=\"[Deprecated]\")\n    chat_id_: Optional[str] = Field(None, alias=\"chat_id\", description=\"[Deprecated]\")\n\n    @property\n    def token(self) -> str:\n        return _expand(self.token_)\n\n    @property\n    def chat_id(self) -> str:\n        return _expand(self.chat_id_)\n\n    @model_validator(mode=\"after\")\n    def migrate_legacy_config(self) -> \"Notification\":\n        \"\"\"Auto-migrate old single-provider config to new format.\"\"\"\n        if self.type and not self.providers:\n            # Old format detected, migrate to new format\n            legacy_provider = NotificationProvider(\n                type=self.type,\n                enabled=True,\n                token=self.token_ or \"\",\n                chat_id=self.chat_id_ or \"\",\n            )\n            self.providers = [legacy_provider]\n        return self\n\n\nclass ExperimentalOpenAI(BaseModel):\n    enable: bool = Field(False, description=\"Enable experimental OpenAI\")\n    api_key: str = Field(\"\", description=\"OpenAI api key\")\n    api_base: str = Field(\n        \"https://api.openai.com/v1\", description=\"OpenAI api base url\"\n    )\n    api_type: Literal[\"azure\", \"openai\"] = Field(\n        \"openai\", description=\"OpenAI api type, usually for azure\"\n    )\n    api_version: str = Field(\n        \"2023-05-15\", description=\"OpenAI api version, only for Azure\"\n    )\n    model: str = Field(\n        \"gpt-3.5-turbo\", description=\"OpenAI model, ignored when api type is azure\"\n    )\n    deployment_id: str = Field(\n        \"\", description=\"Azure OpenAI deployment id, ignored when api type is openai\"\n    )\n\n    @field_validator(\"api_base\")\n    @classmethod\n    def validate_api_base(cls, value: str) -> str:\n        if value == \"https://api.openai.com/\":\n            return \"https://api.openai.com/v1\"\n        return value\n\n\nclass Security(BaseModel):\n    \"\"\"Access control configuration for the login endpoint and MCP server.\n\n    Both ``login_whitelist`` and ``mcp_whitelist`` accept IPv4/IPv6 CIDR ranges.\n    An empty ``login_whitelist`` allows all IPs; an empty ``mcp_whitelist``\n    denies all IP-based access (tokens still work).\n    \"\"\"\n\n    login_whitelist: list[str] = Field(\n        default_factory=list,\n        description=\"IP/CIDR whitelist for login access. Empty = allow all.\",\n    )\n    login_tokens: list[str] = Field(\n        default_factory=list,\n        description=\"API bearer tokens that bypass login authentication.\",\n    )\n    mcp_whitelist: list[str] = Field(\n        default_factory=list,\n        description=\"IP/CIDR whitelist for MCP access. Empty = deny all.\",\n    )\n    mcp_tokens: list[str] = Field(\n        default_factory=list,\n        description=\"API bearer tokens for MCP access.\",\n    )\n\n\nclass Config(BaseModel):\n    \"\"\"Root configuration model composed of all subsection models.\"\"\"\n\n    program: Program = Program()\n    downloader: Downloader = Downloader()\n    rss_parser: RSSParser = RSSParser()\n    bangumi_manage: BangumiManage = BangumiManage()\n    log: Log = Log()\n    proxy: Proxy = Proxy()\n    notification: Notification = Notification()\n    experimental_openai: ExperimentalOpenAI = ExperimentalOpenAI()\n    security: Security = Security()\n\n    def model_dump(self, *args, by_alias=True, **kwargs):\n        return super().model_dump(*args, by_alias=by_alias, **kwargs)\n\n    # Keep dict() for backward compatibility\n    def dict(self, *args, by_alias=True, **kwargs):\n        return self.model_dump(*args, by_alias=by_alias, **kwargs)\n"
  },
  {
    "path": "backend/src/module/models/passkey.py",
    "content": "\"\"\"\nWebAuthn Passkey 数据模型\n\"\"\"\n\nfrom datetime import datetime, timezone\nfrom typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass Passkey(SQLModel, table=True):\n    \"\"\"存储 WebAuthn 凭证的数据库模型\"\"\"\n\n    __tablename__ = \"passkey\"\n\n    id: int = Field(default=None, primary_key=True)\n    user_id: int = Field(foreign_key=\"user.id\", index=True)\n\n    # 用户友好的名称 (e.g., \"iPhone 15\", \"MacBook Pro\")\n    name: str = Field(min_length=1, max_length=64)\n\n    # WebAuthn 核心字段\n    credential_id: str = Field(unique=True, index=True)  # Base64URL encoded\n    public_key: str  # CBOR encoded public key, Base64 stored\n    sign_count: int = Field(default=0)  # 防止克隆攻击\n\n    # 可选的设备信息\n    aaguid: Optional[str] = None  # Authenticator AAGUID\n    transports: Optional[str] = None  # JSON array: [\"usb\", \"nfc\", \"ble\", \"internal\"]\n\n    # 审计字段\n    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))\n    last_used_at: Optional[datetime] = None\n\n    # 备份状态 (是否为多设备凭证，如 iCloud Keychain)\n    backup_eligible: bool = Field(default=False)\n    backup_state: bool = Field(default=False)\n\n\nclass PasskeyCreate(BaseModel):\n    \"\"\"创建 Passkey 的请求模型\"\"\"\n\n    name: str = Field(min_length=1, max_length=64)\n    # 注册完成后的 WebAuthn 响应\n    attestation_response: dict\n\n\nclass PasskeyList(BaseModel):\n    \"\"\"返回给前端的 Passkey 列表（不含敏感数据）\"\"\"\n\n    id: int\n    name: str\n    created_at: datetime\n    last_used_at: Optional[datetime]\n    backup_eligible: bool\n    aaguid: Optional[str]\n\n\nclass PasskeyDelete(BaseModel):\n    \"\"\"删除 Passkey 请求\"\"\"\n\n    passkey_id: int\n\n\nclass PasskeyAuthStart(BaseModel):\n    \"\"\"Passkey 认证开始请求\"\"\"\n\n    username: Optional[str] = None  # Optional for discoverable credentials\n\n\nclass PasskeyAuthFinish(BaseModel):\n    \"\"\"Passkey 认证完成请求\"\"\"\n\n    username: Optional[str] = None  # Optional for discoverable credentials\n    credential: dict\n"
  },
  {
    "path": "backend/src/module/models/response.py",
    "content": "from pydantic import BaseModel, Field\n\n\nclass ResponseModel(BaseModel):\n    status: bool = Field(..., json_schema_extra={\"example\": True})\n    status_code: int = Field(..., json_schema_extra={\"example\": 200})\n    msg_en: str\n    msg_zh: str\n    data: dict | None = None\n\n\nclass APIResponse(BaseModel):\n    status: bool = Field(..., json_schema_extra={\"example\": True})\n    msg_en: str = Field(..., json_schema_extra={\"example\": \"Success\"})\n    msg_zh: str = Field(..., json_schema_extra={\"example\": \"成功\"})\n"
  },
  {
    "path": "backend/src/module/models/rss.py",
    "content": "from typing import Optional\n\nfrom sqlmodel import Field, SQLModel\n\n\nclass RSSItem(SQLModel, table=True):\n    id: int = Field(default=None, primary_key=True, alias=\"id\")\n    name: Optional[str] = Field(None, alias=\"name\")\n    url: str = Field(\"https://mikanani.me\", alias=\"url\", index=True)\n    aggregate: bool = Field(False, alias=\"aggregate\")\n    parser: str = Field(\"mikan\", alias=\"parser\")\n    enabled: bool = Field(True, alias=\"enabled\")\n    connection_status: Optional[str] = Field(None, alias=\"connection_status\")\n    last_checked_at: Optional[str] = Field(None, alias=\"last_checked_at\")\n    last_error: Optional[str] = Field(None, alias=\"last_error\")\n\n\nclass RSSUpdate(SQLModel):\n    name: Optional[str] = Field(None, alias=\"name\")\n    url: Optional[str] = Field(\"https://mikanani.me\", alias=\"url\")\n    aggregate: Optional[bool] = Field(True, alias=\"aggregate\")\n    parser: Optional[str] = Field(\"mikan\", alias=\"parser\")\n    enabled: Optional[bool] = Field(True, alias=\"enabled\")\n"
  },
  {
    "path": "backend/src/module/models/torrent.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass Torrent(SQLModel, table=True):\n    id: int = Field(default=None, primary_key=True, alias=\"id\")\n    bangumi_id: Optional[int] = Field(None, alias=\"refer_id\", foreign_key=\"bangumi.id\")\n    rss_id: Optional[int] = Field(None, alias=\"rss_id\", foreign_key=\"rssitem.id\", index=True)\n    name: str = Field(\"\", alias=\"name\")\n    url: str = Field(\"https://example.com/torrent\", alias=\"url\", index=True)\n    homepage: Optional[str] = Field(None, alias=\"homepage\")\n    downloaded: bool = Field(False, alias=\"downloaded\")\n    qb_hash: Optional[str] = Field(None, alias=\"qb_hash\", index=True)\n\n\nclass TorrentUpdate(SQLModel):\n    downloaded: bool = Field(False, alias=\"downloaded\")\n\n\nclass EpisodeFile(BaseModel):\n    media_path: str = Field(...)\n    group: str | None = Field(None)\n    title: str = Field(...)\n    season: int = Field(...)\n    episode: int | float = Field(None)\n    suffix: str = Field(..., regex=r\"\\.(mkv|mp4|MKV|MP4)$\")\n\n\nclass SubtitleFile(BaseModel):\n    media_path: str = Field(...)\n    group: str | None = Field(None)\n    title: str = Field(...)\n    season: int = Field(...)\n    episode: int | float = Field(None)\n    language: str = Field(..., regex=r\"(zh|zh-tw)\")\n    suffix: str = Field(..., regex=r\"\\.(ass|srt|ASS|SRT)$\")\n"
  },
  {
    "path": "backend/src/module/models/user.py",
    "content": "from typing import Optional\n\nfrom pydantic import BaseModel\nfrom sqlmodel import Field, SQLModel\n\n\nclass User(SQLModel, table=True):\n    id: int = Field(default=None, primary_key=True)\n    username: str = Field(\n        \"admin\", min_length=4, max_length=20, regex=r\"^[a-zA-Z0-9_]+$\"\n    )\n    password: str = Field(\"\", min_length=8)\n\n\nclass UserUpdate(SQLModel):\n    username: Optional[str] = Field(\n        None, min_length=4, max_length=20, regex=r\"^[a-zA-Z0-9_]+$\"\n    )\n    password: Optional[str] = Field(None, min_length=8)\n\n\nclass UserLogin(SQLModel):\n    username: str\n    password: str = Field(..., min_length=8)\n\n\nclass Token(BaseModel):\n    token: str\n    token_type: str\n\n\nclass TokenData(BaseModel):\n    username: str | None = None\n"
  },
  {
    "path": "backend/src/module/network/__init__.py",
    "content": "from .request_contents import RequestContent\n"
  },
  {
    "path": "backend/src/module/network/request_contents.py",
    "content": "import logging\nimport re\nimport xml.etree.ElementTree\n\nfrom module.conf import settings\nfrom module.models import Torrent\n\nfrom .request_url import RequestURL\nfrom .site import rss_parser\n\nlogger = logging.getLogger(__name__)\n\n\nclass RequestContent(RequestURL):\n    async def get_torrents(\n        self,\n        _url: str,\n        _filter: str = None,\n        limit: int = None,\n        retry: int = 3,\n    ) -> list[Torrent]:\n        soup = await self.get_xml(_url, retry)\n        if soup:\n            parsed_items = rss_parser(soup)\n            torrents: list[Torrent] = []\n            if _filter is None:\n                _filter = \"|\".join(settings.rss_parser.filter)\n            for _title, torrent_url, homepage in parsed_items:\n                if re.search(_filter, _title) is None:\n                    torrents.append(\n                        Torrent(name=_title, url=torrent_url, homepage=homepage)\n                    )\n                if isinstance(limit, int):\n                    if len(torrents) >= limit:\n                        break\n            return torrents\n        else:\n            logger.warning(f\"[Network] Failed to get torrents: {_url}\")\n            return []\n\n    async def get_xml(self, _url, retry: int = 3) -> xml.etree.ElementTree.Element:\n        req = await self.get_url(_url, retry)\n        if req:\n            try:\n                return xml.etree.ElementTree.fromstring(req.text)\n            except xml.etree.ElementTree.ParseError as e:\n                logger.warning(f\"[Network] Failed to parse XML from {_url}: {e}\")\n                return None\n\n    # API JSON\n    async def get_json(self, _url) -> dict:\n        req = await self.get_url(_url)\n        if req:\n            return req.json()\n\n    async def post_json(self, _url, data: dict) -> dict:\n        resp = await self.post_url(_url, data)\n        return resp.json()\n\n    async def post_data(self, _url, data: dict):\n        return await self.post_url(_url, data)\n\n    async def post_files(self, _url, data: dict, files: dict):\n        return await self.post_form(_url, data, files)\n\n    async def get_html(self, _url):\n        resp = await self.get_url(_url)\n        return resp.text if resp else None\n\n    async def get_content(self, _url):\n        req = await self.get_url(_url)\n        if req:\n            return req.content\n        logger.warning(f\"[Network] Failed to get content from {_url}\")\n        return None\n\n    async def check_connection(self, _url):\n        return await self.check_url(_url)\n\n    async def get_rss_title(self, _url):\n        soup = await self.get_xml(_url)\n        if soup:\n            return soup.find(\"./channel/title\").text\n"
  },
  {
    "path": "backend/src/module/network/request_url.py",
    "content": "import asyncio\nimport logging\n\nimport httpx\nfrom httpx_socks import AsyncProxyTransport\n\nfrom module.conf import settings\n\nlogger = logging.getLogger(__name__)\n\n# Module-level shared client for connection reuse\n_shared_client: httpx.AsyncClient | None = None\n_shared_client_proxy_key: str | None = None\n\n\ndef _proxy_config_key() -> str:\n    if settings.proxy.enable:\n        return f\"{settings.proxy.type}:{settings.proxy.host}:{settings.proxy.port}:{settings.proxy.username}\"\n    return \"\"\n\n\nasync def get_shared_client() -> httpx.AsyncClient:\n    global _shared_client, _shared_client_proxy_key\n    current_key = _proxy_config_key()\n    if _shared_client is not None and _shared_client_proxy_key == current_key:\n        return _shared_client\n    if _shared_client is not None:\n        await _shared_client.aclose()\n    timeout = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)\n    if settings.proxy.enable:\n        if \"http\" in settings.proxy.type:\n            if settings.proxy.username:\n                proxy_url = f\"http://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}\"\n            else:\n                proxy_url = f\"http://{settings.proxy.host}:{settings.proxy.port}\"\n            _shared_client = httpx.AsyncClient(proxy=proxy_url, timeout=timeout)\n        elif settings.proxy.type == \"socks5\":\n            if settings.proxy.username:\n                socks_url = f\"socks5://{settings.proxy.username}:{settings.proxy.password}@{settings.proxy.host}:{settings.proxy.port}\"\n            else:\n                socks_url = f\"socks5://{settings.proxy.host}:{settings.proxy.port}\"\n            transport = AsyncProxyTransport.from_url(socks_url, rdns=True)\n            _shared_client = httpx.AsyncClient(transport=transport, timeout=timeout)\n        else:\n            _shared_client = httpx.AsyncClient(timeout=timeout)\n    else:\n        _shared_client = httpx.AsyncClient(timeout=timeout)\n    _shared_client_proxy_key = current_key\n    return _shared_client\n\n\nclass RequestURL:\n    # More complete User-Agent to avoid Cloudflare blocking\n    DEFAULT_UA = \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"\n\n    def __init__(self):\n        self.header = {\"User-Agent\": self.DEFAULT_UA, \"Accept\": \"application/xml\"}\n        self._client: httpx.AsyncClient | None = None\n\n    def _get_headers(self, url: str) -> dict:\n        \"\"\"Get appropriate headers based on URL type.\"\"\"\n        base_headers = {\n            \"User-Agent\": self.DEFAULT_UA,\n            \"Accept-Language\": \"en-US,en;q=0.9\",\n            \"Accept-Encoding\": \"gzip, deflate\",\n            \"Connection\": \"keep-alive\",\n        }\n        # For torrent files, use different Accept header\n        if url.endswith(\".torrent\") or \"/download/\" in url:\n            base_headers[\"Accept\"] = \"application/x-bittorrent, application/octet-stream, */*\"\n        else:\n            base_headers[\"Accept\"] = \"application/xml, text/xml, */*\"\n        return base_headers\n\n    async def get_url(self, url, retry=3):\n        try_time = 0\n        headers = self._get_headers(url)\n        while True:\n            try:\n                req = await self._client.get(url=url, headers=headers)\n                logger.debug(\"[Network] Successfully connected to %s. Status: %s\", url, req.status_code)\n                req.raise_for_status()\n                return req\n            except httpx.HTTPStatusError as e:\n                logger.warning(f\"[Network] HTTP {e.response.status_code} from {url}\")\n                break\n            except httpx.RequestError as e:\n                logger.warning(\n                    f\"[Network] Request error for {url}: {type(e).__name__}. Retry {try_time + 1}/{retry}\"\n                )\n                try_time += 1\n                if try_time >= retry:\n                    break\n                await asyncio.sleep(5)\n            except Exception as e:\n                logger.warning(f\"[Network] Unexpected error for {url}: {e}\")\n                break\n        logger.error(f\"[Network] Unable to connect to {url}, Please check your network settings\")\n        return None\n\n    async def post_url(self, url: str, data: dict, retry=3):\n        try_time = 0\n        while True:\n            try:\n                req = await self._client.post(\n                    url=url, headers=self.header, data=data\n                )\n                req.raise_for_status()\n                return req\n            except httpx.RequestError:\n                logger.warning(\n                    f\"[Network] Cannot connect to {url}. Wait for 5 seconds.\"\n                )\n                try_time += 1\n                if try_time >= retry:\n                    break\n                await asyncio.sleep(5)\n            except Exception as e:\n                logger.debug(e)\n                break\n        logger.error(f\"[Network] Failed connecting to {url}\")\n        logger.warning(\"[Network] Please check DNS/Connection settings\")\n        return None\n\n    async def check_url(self, url: str):\n        if \"://\" not in url:\n            url = f\"http://{url}\"\n        try:\n            req = await self._client.head(url=url, headers=self.header)\n            req.raise_for_status()\n            return True\n        except (httpx.RequestError, httpx.HTTPStatusError):\n            logger.debug(\"[Network] Cannot connect to %s.\", url)\n            return False\n\n    async def post_form(self, url: str, data: dict, files):\n        try:\n            req = await self._client.post(\n                url=url, headers=self.header, data=data, files=files\n            )\n            req.raise_for_status()\n            return req\n        except (httpx.RequestError, httpx.HTTPStatusError):\n            logger.warning(f\"[Network] Cannot connect to {url}.\")\n            return None\n\n    async def __aenter__(self):\n        self._client = await get_shared_client()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        # Client is shared; do not close it here\n        self._client = None\n"
  },
  {
    "path": "backend/src/module/network/site/__init__.py",
    "content": "from .mikan import rss_parser\n"
  },
  {
    "path": "backend/src/module/network/site/mikan.py",
    "content": "import logging\n\nlogger = logging.getLogger(__name__)\n\n\ndef rss_parser(soup):\n    results = []\n    for item in soup.findall(\"./channel/item\"):\n        try:\n            title = item.find(\"title\").text\n            enclosure = item.find(\"enclosure\")\n            if enclosure is not None:\n                homepage = item.find(\"link\").text\n                url = enclosure.attrib.get(\"url\")\n            else:\n                url = item.find(\"link\").text\n                homepage = \"\"\n            results.append((title, url, homepage))\n        except Exception as e:\n            logger.warning(\"[RSS] Failed to parse RSS item: %s\", e)\n            continue\n    return results\n\n\ndef mikan_title(soup):\n    return soup.find(\"title\").text\n"
  },
  {
    "path": "backend/src/module/notification/__init__.py",
    "content": "from .notification import PostNotification\nfrom .manager import NotificationManager\nfrom .base import NotificationProvider\nfrom .providers import PROVIDER_REGISTRY\n\n__all__ = [\n    \"PostNotification\",\n    \"NotificationManager\",\n    \"NotificationProvider\",\n    \"PROVIDER_REGISTRY\",\n]\n"
  },
  {
    "path": "backend/src/module/notification/base.py",
    "content": "\"\"\"Base class for notification providers.\"\"\"\n\nfrom abc import ABC, abstractmethod\n\nfrom module.models.bangumi import Notification\nfrom module.network import RequestContent\n\n\nclass NotificationProvider(RequestContent, ABC):\n    \"\"\"Abstract base class for notification providers.\n\n    All notification providers must inherit from this class and implement\n    the send() and test() methods.\n    \"\"\"\n\n    @abstractmethod\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send a notification.\n\n        Args:\n            notification: The notification data containing anime info.\n\n        Returns:\n            True if the notification was sent successfully, False otherwise.\n        \"\"\"\n        pass\n\n    @abstractmethod\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test the notification provider configuration.\n\n        Returns:\n            A tuple of (success, message) where success is True if the test\n            passed and message contains details about the result.\n        \"\"\"\n        pass\n\n    def _format_message(self, notify: Notification) -> str:\n        \"\"\"Format the default notification message.\n\n        Args:\n            notify: The notification data.\n\n        Returns:\n            Formatted message string.\n        \"\"\"\n        return (\n            f\"番剧名称：{notify.official_title}\\n\"\n            f\"季度： 第{notify.season}季\\n\"\n            f\"更新集数： 第{notify.episode}集\"\n        )\n"
  },
  {
    "path": "backend/src/module/notification/manager.py",
    "content": "\"\"\"Notification manager for handling multiple providers.\"\"\"\n\nimport asyncio\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.models.bangumi import Notification\n\nif TYPE_CHECKING:\n    from module.notification.base import NotificationProvider\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass NotificationManager:\n    \"\"\"Manager for handling notifications across multiple providers.\"\"\"\n\n    def __init__(self):\n        self.providers: list[\"NotificationProvider\"] = []\n        self._load_providers()\n\n    def _load_providers(self):\n        \"\"\"Initialize providers from configuration.\"\"\"\n        from module.notification.providers import PROVIDER_REGISTRY\n\n        for cfg in settings.notification.providers:\n            if not cfg.enabled:\n                continue\n\n            provider_cls = PROVIDER_REGISTRY.get(cfg.type.lower())\n            if provider_cls:\n                try:\n                    provider = provider_cls(cfg)\n                    self.providers.append(provider)\n                    logger.debug(\"Loaded notification provider: %s\", cfg.type)\n                except Exception as e:\n                    logger.warning(f\"Failed to load provider {cfg.type}: {e}\")\n            else:\n                logger.warning(f\"Unknown notification provider type: {cfg.type}\")\n\n    async def _get_poster(self, notification: Notification):\n        \"\"\"Fetch poster path from database if not already set.\"\"\"\n        if notification.poster_path:\n            return\n\n        def _get_poster_sync():\n            with Database() as db:\n                data = db.bangumi.search_official_title(notification.official_title)\n                if data:\n                    notification.poster_path = data.poster_link\n\n        await asyncio.to_thread(_get_poster_sync)\n\n    async def send_all(self, notification: Notification):\n        \"\"\"Send notification to all enabled providers.\n\n        Args:\n            notification: The notification data to send.\n        \"\"\"\n        if not self.providers:\n            logger.debug(\"No notification providers configured\")\n            return\n\n        # Fetch poster if needed\n        await self._get_poster(notification)\n\n        # Send to all providers in parallel\n        async def send_to_provider(provider: \"NotificationProvider\"):\n            try:\n                async with provider:\n                    await provider.send(notification)\n                logger.debug(\n                    \"Sent notification via %s: %s\",\n                    provider.__class__.__name__,\n                    notification.official_title,\n                )\n            except Exception as e:\n                logger.warning(\n                    f\"Failed to send notification via {provider.__class__.__name__}: {e}\"\n                )\n\n        await asyncio.gather(\n            *[send_to_provider(p) for p in self.providers],\n            return_exceptions=True,\n        )\n\n    async def test_provider(self, index: int) -> tuple[bool, str]:\n        \"\"\"Test a specific provider by index.\n\n        Args:\n            index: The index of the provider in the providers list.\n\n        Returns:\n            A tuple of (success, message).\n        \"\"\"\n        if index < 0 or index >= len(self.providers):\n            return False, f\"Invalid provider index: {index}\"\n\n        provider = self.providers[index]\n        try:\n            async with provider:\n                return await provider.test()\n        except Exception as e:\n            return False, f\"Test failed: {e}\"\n\n    @staticmethod\n    async def test_provider_config(config: \"ProviderConfig\") -> tuple[bool, str]:\n        \"\"\"Test a provider configuration without saving it.\n\n        Args:\n            config: The provider configuration to test.\n\n        Returns:\n            A tuple of (success, message).\n        \"\"\"\n        from module.notification.providers import PROVIDER_REGISTRY\n\n        provider_cls = PROVIDER_REGISTRY.get(config.type.lower())\n        if not provider_cls:\n            return False, f\"Unknown provider type: {config.type}\"\n\n        try:\n            provider = provider_cls(config)\n            async with provider:\n                return await provider.test()\n        except Exception as e:\n            return False, f\"Test failed: {e}\"\n\n    def __len__(self) -> int:\n        return len(self.providers)\n"
  },
  {
    "path": "backend/src/module/notification/notification.py",
    "content": "import asyncio\nimport logging\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.models import Notification\n\nfrom .plugin import (\n    BarkNotification,\n    ServerChanNotification,\n    TelegramNotification,\n    WecomNotification,\n)\n\nlogger = logging.getLogger(__name__)\n\n\ndef getClient(type: str):\n    if type.lower() == \"telegram\":\n        return TelegramNotification\n    elif type.lower() == \"server-chan\":\n        return ServerChanNotification\n    elif type.lower() == \"bark\":\n        return BarkNotification\n    elif type.lower() == \"wecom\":\n        return WecomNotification\n    else:\n        return None\n\n\nclass PostNotification:\n    def __init__(self):\n        Notifier = getClient(settings.notification.type)\n        self.notifier = Notifier(\n            token=settings.notification.token, chat_id=settings.notification.chat_id\n        )\n\n    @staticmethod\n    def _get_poster_sync(notify: Notification):\n        with Database() as db:\n            poster_path = db.bangumi.match_poster(notify.official_title)\n        notify.poster_path = poster_path\n\n    async def send_msg(self, notify: Notification) -> bool:\n        await asyncio.to_thread(self._get_poster_sync, notify)\n        try:\n            await self.notifier.post_msg(notify)\n            logger.debug(\"Send notification: %s\", notify.official_title)\n        except Exception as e:\n            logger.warning(f\"Failed to send notification: {e}\")\n            return False\n\n    async def __aenter__(self):\n        await self.notifier.__aenter__()\n        return self\n\n    async def __aexit__(self, exc_type, exc_val, exc_tb):\n        await self.notifier.__aexit__(exc_type, exc_val, exc_tb)\n"
  },
  {
    "path": "backend/src/module/notification/plugin/__init__.py",
    "content": "from .bark import BarkNotification\nfrom .server_chan import ServerChanNotification\nfrom .telegram import TelegramNotification\nfrom .wecom import WecomNotification\n"
  },
  {
    "path": "backend/src/module/notification/plugin/bark.py",
    "content": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\n\nclass BarkNotification(RequestContent):\n    def __init__(self, token, **kwargs):\n        super().__init__()\n        self.token = token\n        self.notification_url = \"https://api.day.app/push\"\n\n    @staticmethod\n    def gen_message(notify: Notification) -> str:\n        text = f\"\"\"\n        番剧名称：{notify.official_title}\\n季度： 第{notify.season}季\\n更新集数： 第{notify.episode}集\\n{notify.poster_path}\\n\n        \"\"\"\n        return text.strip()\n\n    async def post_msg(self, notify: Notification) -> bool:\n        text = self.gen_message(notify)\n        data = {\"title\": notify.official_title, \"body\": text, \"icon\": notify.poster_path, \"device_key\": self.token}\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Bark notification: %s\", resp.status_code)\n        return resp.status_code == 200\n"
  },
  {
    "path": "backend/src/module/notification/plugin/server_chan.py",
    "content": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\n\nclass ServerChanNotification(RequestContent):\n    \"\"\"Server酱推送\"\"\"\n\n    def __init__(self, token, **kwargs):\n        super().__init__()\n        self.notification_url = f\"https://sctapi.ftqq.com/{token}.send\"\n\n    @staticmethod\n    def gen_message(notify: Notification) -> str:\n        text = f\"\"\"\n        番剧名称：{notify.official_title}\\n季度： 第{notify.season}季\\n更新集数： 第{notify.episode}集\\n{notify.poster_path}\\n\n        \"\"\"\n        return text.strip()\n\n    async def post_msg(self, notify: Notification) -> bool:\n        text = self.gen_message(notify)\n        data = {\n            \"title\": notify.official_title,\n            \"desp\": text,\n        }\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"ServerChan notification: %s\", resp.status_code)\n        return resp.status_code == 200\n"
  },
  {
    "path": "backend/src/module/notification/plugin/slack.py",
    "content": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\n\nclass SlackNotification(RequestContent):\n    def __init__(self, token, **kwargs):\n        super().__init__()\n        self.token = token\n        self.notification_url = \"https://api.day.app/push\"\n\n    @staticmethod\n    def gen_message(notify: Notification) -> str:\n        text = f\"\"\"\n        番剧名称：{notify.official_title}\\n季度： 第{notify.season}季\\n更新集数： 第{notify.episode}集\\n{notify.poster_path}\\n\n        \"\"\"\n        return text.strip()\n\n    async def post_msg(self, notify: Notification) -> bool:\n        text = self.gen_message(notify)\n        data = {\"title\": notify.official_title, \"body\": text, \"device_key\": self.token}\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Bark notification: %s\", resp.status_code)\n        return resp.status_code == 200\n"
  },
  {
    "path": "backend/src/module/notification/plugin/telegram.py",
    "content": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\nfrom module.utils import load_image\n\nlogger = logging.getLogger(__name__)\n\n\nclass TelegramNotification(RequestContent):\n    def __init__(self, token, chat_id):\n        super().__init__()\n        self.photo_url = f\"https://api.telegram.org/bot{token}/sendPhoto\"\n        self.message_url = f\"https://api.telegram.org/bot{token}/sendMessage\"\n        self.chat_id = chat_id\n\n    @staticmethod\n    def gen_message(notify: Notification) -> str:\n        text = f\"\"\"\n        番剧名称：{notify.official_title}\\n季度： 第{notify.season}季\\n更新集数： 第{notify.episode}集\n        \"\"\"\n        return text.strip()\n\n    async def post_msg(self, notify: Notification) -> bool:\n        text = self.gen_message(notify)\n        data = {\n            \"chat_id\": self.chat_id,\n            \"caption\": text,\n            \"text\": text,\n            \"disable_notification\": True,\n        }\n        photo = load_image(notify.poster_path)\n        if photo:\n            resp = await self.post_files(self.photo_url, data, files={\"photo\": photo})\n        else:\n            resp = await self.post_data(self.message_url, data)\n        logger.debug(\"Telegram notification: %s\", resp.status_code)\n        return resp.status_code == 200\n"
  },
  {
    "path": "backend/src/module/notification/plugin/wecom.py",
    "content": "import logging\n\nfrom module.models import Notification\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\n\nclass WecomNotification(RequestContent):\n    \"\"\"企业微信推送 基于图文消息\"\"\"\n\n    def __init__(self, token, chat_id, **kwargs):\n        super().__init__()\n        # Chat_id is used as noti_url in this push tunnel\n        self.notification_url = f\"{chat_id}\"\n        self.token = token\n\n    @staticmethod\n    def gen_message(notify: Notification) -> str:\n        text = f\"\"\"\n        番剧名称：{notify.official_title}\\n季度： 第{notify.season}季\\n更新集数： 第{notify.episode}集\\n{notify.poster_path}\\n\n        \"\"\"\n        return text.strip()\n\n    async def post_msg(self, notify: Notification) -> bool:\n        ##Change message format to match Wecom push better\n        title = \"【番剧更新】\" + notify.official_title\n        msg = self.gen_message(notify)\n        picurl = notify.poster_path\n        # Default pic to avoid blank in message. Resolution:1068*455\n        if picurl == \"https://mikanani.me\":\n            picurl = \"https://article.biliimg.com/bfs/article/d8bcd0408bf32594fd82f27de7d2c685829d1b2e.png\"\n        data = {\n            \"key\": self.token,\n            \"type\": \"news\",\n            \"title\": title,\n            \"msg\": msg,\n            \"picurl\": picurl,\n        }\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Wecom notification: %s\", resp.status_code)\n        return resp.status_code == 200\n"
  },
  {
    "path": "backend/src/module/notification/providers/__init__.py",
    "content": "\"\"\"Notification providers registry.\"\"\"\n\nfrom typing import TYPE_CHECKING\n\nfrom module.notification.providers.telegram import TelegramProvider\nfrom module.notification.providers.discord import DiscordProvider\nfrom module.notification.providers.bark import BarkProvider\nfrom module.notification.providers.server_chan import ServerChanProvider\nfrom module.notification.providers.wecom import WecomProvider\nfrom module.notification.providers.gotify import GotifyProvider\nfrom module.notification.providers.pushover import PushoverProvider\nfrom module.notification.providers.webhook import WebhookProvider\n\nif TYPE_CHECKING:\n    from module.notification.base import NotificationProvider\n\n# Registry mapping provider type names to their classes\nPROVIDER_REGISTRY: dict[str, type[\"NotificationProvider\"]] = {\n    \"telegram\": TelegramProvider,\n    \"discord\": DiscordProvider,\n    \"bark\": BarkProvider,\n    \"server-chan\": ServerChanProvider,\n    \"serverchan\": ServerChanProvider,  # Alternative name\n    \"wecom\": WecomProvider,\n    \"gotify\": GotifyProvider,\n    \"pushover\": PushoverProvider,\n    \"webhook\": WebhookProvider,\n}\n\n__all__ = [\n    \"PROVIDER_REGISTRY\",\n    \"TelegramProvider\",\n    \"DiscordProvider\",\n    \"BarkProvider\",\n    \"ServerChanProvider\",\n    \"WecomProvider\",\n    \"GotifyProvider\",\n    \"PushoverProvider\",\n    \"WebhookProvider\",\n]\n"
  },
  {
    "path": "backend/src/module/notification/providers/bark.py",
    "content": "\"\"\"Bark notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass BarkProvider(NotificationProvider):\n    \"\"\"Bark (iOS) notification provider.\"\"\"\n\n    DEFAULT_SERVER = \"https://api.day.app\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        # Support both legacy token field and new device_key field\n        self.device_key = config.device_key or config.token\n        server_url = config.server_url or self.DEFAULT_SERVER\n        self.notification_url = f\"{server_url.rstrip('/')}/push\"\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Bark.\"\"\"\n        text = self._format_message(notification)\n        data = {\n            \"title\": notification.official_title,\n            \"body\": text,\n            \"icon\": notification.poster_path or \"\",\n            \"device_key\": self.device_key,\n        }\n\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Bark notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Bark configuration by sending a test notification.\"\"\"\n        data = {\n            \"title\": \"AutoBangumi\",\n            \"body\": \"通知测试成功！\\nNotification test successful!\",\n            \"device_key\": self.device_key,\n        }\n        try:\n            resp = await self.post_data(self.notification_url, data)\n            if resp.status_code == 200:\n                return True, \"Bark test notification sent successfully\"\n            else:\n                return False, f\"Bark API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Bark test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/discord.py",
    "content": "\"\"\"Discord notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass DiscordProvider(NotificationProvider):\n    \"\"\"Discord webhook notification provider.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        self.webhook_url = config.webhook_url\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Discord webhook.\"\"\"\n        embed = {\n            \"title\": f\"📺 {notification.official_title}\",\n            \"description\": (\n                f\"**季度:** 第{notification.season}季\\n\"\n                f\"**集数:** 第{notification.episode}集\"\n            ),\n            \"color\": 0x00BFFF,  # Deep Sky Blue\n        }\n\n        # Add poster as thumbnail if available\n        if notification.poster_path and notification.poster_path != \"https://mikanani.me\":\n            embed[\"thumbnail\"] = {\"url\": notification.poster_path}\n\n        data = {\n            \"embeds\": [embed],\n        }\n\n        resp = await self.post_data(self.webhook_url, data)\n        logger.debug(\"Discord notification: %s\", resp.status_code)\n        return resp.status_code in (200, 204)\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Discord webhook by sending a test message.\"\"\"\n        embed = {\n            \"title\": \"AutoBangumi 通知测试\",\n            \"description\": \"通知测试成功！\\nNotification test successful!\",\n            \"color\": 0x00FF00,  # Green\n        }\n        data = {\"embeds\": [embed]}\n\n        try:\n            resp = await self.post_data(self.webhook_url, data)\n            if resp.status_code in (200, 204):\n                return True, \"Discord test message sent successfully\"\n            else:\n                return False, f\"Discord API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Discord test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/gotify.py",
    "content": "\"\"\"Gotify notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass GotifyProvider(NotificationProvider):\n    \"\"\"Gotify notification provider.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        server_url = config.server_url.rstrip(\"/\")\n        self.token = config.token\n        self.notification_url = f\"{server_url}/message?token={self.token}\"\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Gotify.\"\"\"\n        message = self._format_message(notification)\n\n        # Build extras for markdown support and image\n        extras = {\n            \"client::display\": {\"contentType\": \"text/markdown\"},\n        }\n\n        if notification.poster_path and notification.poster_path != \"https://mikanani.me\":\n            extras[\"client::notification\"] = {\n                \"bigImageUrl\": notification.poster_path,\n            }\n\n        data = {\n            \"title\": notification.official_title,\n            \"message\": message,\n            \"priority\": 5,\n            \"extras\": extras,\n        }\n\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Gotify notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Gotify configuration by sending a test message.\"\"\"\n        data = {\n            \"title\": \"AutoBangumi 通知测试\",\n            \"message\": \"通知测试成功！\\nNotification test successful!\",\n            \"priority\": 5,\n        }\n        try:\n            resp = await self.post_data(self.notification_url, data)\n            if resp.status_code == 200:\n                return True, \"Gotify test message sent successfully\"\n            else:\n                return False, f\"Gotify API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Gotify test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/pushover.py",
    "content": "\"\"\"Pushover notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass PushoverProvider(NotificationProvider):\n    \"\"\"Pushover notification provider.\"\"\"\n\n    API_URL = \"https://api.pushover.net/1/messages.json\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        self.user_key = config.user_key\n        self.api_token = config.api_token\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Pushover.\"\"\"\n        message = self._format_message(notification)\n\n        data = {\n            \"token\": self.api_token,\n            \"user\": self.user_key,\n            \"title\": notification.official_title,\n            \"message\": message,\n            \"html\": 0,\n        }\n\n        # Add poster as supplementary URL if available\n        if notification.poster_path and notification.poster_path != \"https://mikanani.me\":\n            data[\"url\"] = notification.poster_path\n            data[\"url_title\"] = \"查看海报\"\n\n        resp = await self.post_data(self.API_URL, data)\n        logger.debug(\"Pushover notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Pushover configuration by sending a test message.\"\"\"\n        data = {\n            \"token\": self.api_token,\n            \"user\": self.user_key,\n            \"title\": \"AutoBangumi 通知测试\",\n            \"message\": \"通知测试成功！\\nNotification test successful!\",\n        }\n        try:\n            resp = await self.post_data(self.API_URL, data)\n            if resp.status_code == 200:\n                return True, \"Pushover test message sent successfully\"\n            else:\n                # Try to parse error message from response\n                try:\n                    error_data = resp.json()\n                    errors = error_data.get(\"errors\", [])\n                    if errors:\n                        return False, f\"Pushover error: {', '.join(errors)}\"\n                except Exception:\n                    pass\n                return False, f\"Pushover API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Pushover test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/server_chan.py",
    "content": "\"\"\"Server Chan notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass ServerChanProvider(NotificationProvider):\n    \"\"\"Server Chan (Server酱) notification provider for WeChat.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        token = config.token\n        self.notification_url = f\"https://sctapi.ftqq.com/{token}.send\"\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Server Chan.\"\"\"\n        text = self._format_message(notification)\n        data = {\n            \"title\": notification.official_title,\n            \"desp\": text,\n        }\n\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"ServerChan notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Server Chan configuration by sending a test message.\"\"\"\n        data = {\n            \"title\": \"AutoBangumi 通知测试\",\n            \"desp\": \"通知测试成功！\\nNotification test successful!\",\n        }\n        try:\n            resp = await self.post_data(self.notification_url, data)\n            if resp.status_code == 200:\n                return True, \"Server Chan test message sent successfully\"\n            else:\n                return False, f\"Server Chan API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Server Chan test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/telegram.py",
    "content": "\"\"\"Telegram notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\nfrom module.utils import load_image\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n\nclass TelegramProvider(NotificationProvider):\n    \"\"\"Telegram Bot notification provider.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        token = config.token\n        self.chat_id = config.chat_id\n        self.photo_url = f\"https://api.telegram.org/bot{token}/sendPhoto\"\n        self.message_url = f\"https://api.telegram.org/bot{token}/sendMessage\"\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via Telegram.\"\"\"\n        text = self._format_message(notification)\n        data = {\n            \"chat_id\": self.chat_id,\n            \"caption\": text,\n            \"text\": text,\n            \"disable_notification\": True,\n        }\n\n        photo = load_image(notification.poster_path)\n        if photo:\n            resp = await self.post_files(self.photo_url, data, files={\"photo\": photo})\n        else:\n            resp = await self.post_data(self.message_url, data)\n\n        logger.debug(\"Telegram notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test Telegram configuration by sending a test message.\"\"\"\n        data = {\n            \"chat_id\": self.chat_id,\n            \"text\": \"AutoBangumi 通知测试成功！\\nNotification test successful!\",\n        }\n        try:\n            resp = await self.post_data(self.message_url, data)\n            if resp.status_code == 200:\n                return True, \"Telegram test message sent successfully\"\n            else:\n                return False, f\"Telegram API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Telegram test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/webhook.py",
    "content": "\"\"\"Generic webhook notification provider.\"\"\"\n\nimport json\nimport logging\nimport re\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n# Default template for webhook payload\nDEFAULT_TEMPLATE = json.dumps(\n    {\n        \"title\": \"{{title}}\",\n        \"season\": \"{{season}}\",\n        \"episode\": \"{{episode}}\",\n        \"poster_url\": \"{{poster_url}}\",\n    },\n    ensure_ascii=False,\n)\n\n\nclass WebhookProvider(NotificationProvider):\n    \"\"\"Generic webhook notification provider with customizable templates.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        self.url = config.url\n        self.template = config.template or DEFAULT_TEMPLATE\n\n    def _render_template(self, notification: Notification) -> dict:\n        \"\"\"Render the template with notification data.\n\n        Supported variables:\n        - {{title}} - Anime title\n        - {{season}} - Season number\n        - {{episode}} - Episode number\n        - {{poster_url}} - Poster image URL\n        \"\"\"\n        rendered = self.template\n\n        # Replace template variables\n        replacements = {\n            \"{{title}}\": notification.official_title,\n            \"{{season}}\": str(notification.season),\n            \"{{episode}}\": str(notification.episode),\n            \"{{poster_url}}\": notification.poster_path or \"\",\n        }\n\n        for pattern, value in replacements.items():\n            # Escape special characters for JSON string values\n            escaped_value = value.replace(\"\\\\\", \"\\\\\\\\\").replace('\"', '\\\\\"')\n            rendered = rendered.replace(pattern, escaped_value)\n\n        try:\n            return json.loads(rendered)\n        except json.JSONDecodeError as e:\n            logger.warning(f\"Invalid webhook template JSON: {e}\")\n            # Fallback to default structure\n            return {\n                \"title\": notification.official_title,\n                \"season\": notification.season,\n                \"episode\": notification.episode,\n                \"poster_url\": notification.poster_path or \"\",\n            }\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via generic webhook.\"\"\"\n        data = self._render_template(notification)\n\n        resp = await self.post_data(self.url, data)\n        logger.debug(\"Webhook notification: %s\", resp.status_code)\n        # Accept any 2xx status code as success\n        return 200 <= resp.status_code < 300\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test webhook by sending a test payload.\"\"\"\n        test_notification = Notification(\n            official_title=\"AutoBangumi 通知测试\",\n            season=1,\n            episode=1,\n            poster_path=\"\",\n        )\n        data = self._render_template(test_notification)\n\n        try:\n            resp = await self.post_data(self.url, data)\n            if 200 <= resp.status_code < 300:\n                return True, \"Webhook test request sent successfully\"\n            else:\n                return False, f\"Webhook returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"Webhook test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/notification/providers/wecom.py",
    "content": "\"\"\"WeChat Work (企业微信) notification provider.\"\"\"\n\nimport logging\nfrom typing import TYPE_CHECKING\n\nfrom module.models.bangumi import Notification\nfrom module.notification.base import NotificationProvider\n\nif TYPE_CHECKING:\n    from module.models.config import NotificationProvider as ProviderConfig\n\nlogger = logging.getLogger(__name__)\n\n# Default fallback image for when no poster is available\nDEFAULT_POSTER = (\n    \"https://article.biliimg.com/bfs/article/\"\n    \"d8bcd0408bf32594fd82f27de7d2c685829d1b2e.png\"\n)\n\n\nclass WecomProvider(NotificationProvider):\n    \"\"\"WeChat Work (企业微信) notification provider using news message format.\"\"\"\n\n    def __init__(self, config: \"ProviderConfig\"):\n        super().__init__()\n        # Support both webhook_url and legacy chat_id field\n        self.notification_url = config.webhook_url or config.chat_id\n        self.token = config.token\n\n    async def send(self, notification: Notification) -> bool:\n        \"\"\"Send notification via WeChat Work.\"\"\"\n        title = f\"【番剧更新】{notification.official_title}\"\n        msg = self._format_message(notification)\n\n        # Use default poster if none available or if it's just the base Mikan URL\n        picurl = notification.poster_path\n        if not picurl or picurl == \"https://mikanani.me\":\n            picurl = DEFAULT_POSTER\n\n        data = {\n            \"key\": self.token,\n            \"type\": \"news\",\n            \"title\": title,\n            \"msg\": msg,\n            \"picurl\": picurl,\n        }\n\n        resp = await self.post_data(self.notification_url, data)\n        logger.debug(\"Wecom notification: %s\", resp.status_code)\n        return resp.status_code == 200\n\n    async def test(self) -> tuple[bool, str]:\n        \"\"\"Test WeChat Work configuration by sending a test message.\"\"\"\n        data = {\n            \"key\": self.token,\n            \"type\": \"news\",\n            \"title\": \"AutoBangumi 通知测试\",\n            \"msg\": \"通知测试成功！\\nNotification test successful!\",\n            \"picurl\": DEFAULT_POSTER,\n        }\n        try:\n            resp = await self.post_data(self.notification_url, data)\n            if resp.status_code == 200:\n                return True, \"WeChat Work test message sent successfully\"\n            else:\n                return False, f\"WeChat Work API returned status {resp.status_code}\"\n        except Exception as e:\n            return False, f\"WeChat Work test failed: {e}\"\n"
  },
  {
    "path": "backend/src/module/parser/__init__.py",
    "content": "from .title_parser import TitleParser\n"
  },
  {
    "path": "backend/src/module/parser/analyser/__init__.py",
    "content": "from .mikan_parser import mikan_parser\nfrom .openai import OpenAIParser\nfrom .raw_parser import raw_parser\nfrom .tmdb_parser import tmdb_parser\nfrom .torrent_parser import torrent_parser\n"
  },
  {
    "path": "backend/src/module/parser/analyser/bgm_calendar.py",
    "content": "import logging\n\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\nBGM_CALENDAR_URL = \"https://api.bgm.tv/calendar\"\n\n\nasync def fetch_bgm_calendar() -> list[dict]:\n    \"\"\"Fetch the current season's broadcast calendar from Bangumi.tv API.\n\n    Returns a flat list of anime items with their air_weekday (0=Mon, ..., 6=Sun).\n    \"\"\"\n    async with RequestContent() as req:\n        data = await req.get_json(BGM_CALENDAR_URL)\n\n    if not data:\n        logger.warning(\"[BGM Calendar] Failed to fetch calendar data.\")\n        return []\n\n    items = []\n    for day_group in data:\n        weekday_info = day_group.get(\"weekday\", {})\n        # Bangumi.tv uses 1=Mon, 2=Tue, ..., 7=Sun\n        # Convert to 0=Mon, 1=Tue, ..., 6=Sun\n        bgm_weekday = weekday_info.get(\"id\")\n        if bgm_weekday is None:\n            continue\n        weekday = bgm_weekday - 1  # 1-7 → 0-6\n\n        for item in day_group.get(\"items\", []):\n            items.append({\n                \"name\": item.get(\"name\", \"\"),          # Japanese title\n                \"name_cn\": item.get(\"name_cn\", \"\"),    # Chinese title\n                \"air_weekday\": weekday,\n            })\n\n    logger.info(f\"[BGM Calendar] Fetched {len(items)} airing anime from Bangumi.tv.\")\n    return items\n\n\ndef match_weekday(official_title: str, title_raw: str, calendar_items: list[dict]) -> int | None:\n    \"\"\"Match a bangumi against calendar items to find its air weekday.\n\n    Matching strategy:\n    1. Exact match on Chinese title (name_cn == official_title)\n    2. Exact match on Japanese title (name == title_raw or official_title)\n    3. Substring match (name_cn in official_title or vice versa)\n    4. Substring match on Japanese title\n    \"\"\"\n    official_title_clean = official_title.strip()\n    title_raw_clean = title_raw.strip()\n\n    for item in calendar_items:\n        name_cn = item[\"name_cn\"].strip()\n        name = item[\"name\"].strip()\n\n        if not name_cn and not name:\n            continue\n\n        # Exact match on Chinese title\n        if name_cn and name_cn == official_title_clean:\n            return item[\"air_weekday\"]\n\n        # Exact match on Japanese/original title\n        if name and (name == title_raw_clean or name == official_title_clean):\n            return item[\"air_weekday\"]\n\n    # Second pass: substring matching\n    for item in calendar_items:\n        name_cn = item[\"name_cn\"].strip()\n        name = item[\"name\"].strip()\n\n        if not name_cn and not name:\n            continue\n\n        # Chinese title substring (at least 4 chars to avoid false positives)\n        if name_cn and len(name_cn) >= 4:\n            if name_cn in official_title_clean or official_title_clean in name_cn:\n                return item[\"air_weekday\"]\n\n        # Japanese title substring\n        if name and len(name) >= 4:\n            if name in title_raw_clean or title_raw_clean in name:\n                return item[\"air_weekday\"]\n\n    return None\n"
  },
  {
    "path": "backend/src/module/parser/analyser/bgm_parser.py",
    "content": "from module.network import RequestContent\n\n\ndef search_url(e):\n    return f\"https://api.bgm.tv/search/subject/{e}?responseGroup=large\"\n\n\nasync def bgm_parser(title):\n    url = search_url(title)\n    async with RequestContent() as req:\n        contents = await req.get_json(url)\n        if contents:\n            return contents[0]\n        else:\n            return None\n"
  },
  {
    "path": "backend/src/module/parser/analyser/mikan_parser.py",
    "content": "import logging\nimport re\n\nfrom bs4 import BeautifulSoup\nfrom urllib3.util import parse_url\n\nfrom module.network import RequestContent\nfrom module.utils import save_image\n\nlogger = logging.getLogger(__name__)\n\n# In-memory cache for Mikan homepage lookups\n_mikan_cache: dict[str, tuple[str, str]] = {}\n\n\nasync def mikan_parser(homepage: str):\n    if homepage in _mikan_cache:\n        return _mikan_cache[homepage]\n    root_path = parse_url(homepage).host\n    async with RequestContent() as req:\n        content = await req.get_html(homepage)\n        soup = BeautifulSoup(content, \"html.parser\")\n        poster_div = soup.find(\"div\", {\"class\": \"bangumi-poster\"}).get(\"style\")\n        official_title = soup.select_one(\n            'p.bangumi-title a[href^=\"/Home/Bangumi/\"]'\n        ).text\n        official_title = re.sub(r\"第.*季\", \"\", official_title).strip()\n        if poster_div:\n            poster_path = poster_div.split(\"url('\")[1].split(\"')\")[0]\n            poster_path = poster_path.split(\"?\")[0]\n            img = await req.get_content(f\"https://{root_path}{poster_path}\")\n            suffix = poster_path.split(\".\")[-1]\n            poster_link = save_image(img, suffix)\n            result = (poster_link, official_title)\n            _mikan_cache[homepage] = result\n            return result\n        result = (\"\", \"\")\n        _mikan_cache[homepage] = result\n        return result\n\n\nif __name__ == '__main__':\n    import asyncio\n    homepage = \"https://mikanani.me/Home/Episode/c89b3c6f0c1c0567a618f5288b853823c87a9862\"\n    print(asyncio.run(mikan_parser(homepage)))\n"
  },
  {
    "path": "backend/src/module/parser/analyser/offset_detector.py",
    "content": "\"\"\"Offset detector for detecting season/episode mismatches with TMDB data.\"\"\"\n\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Literal\n\nfrom module.parser.analyser.tmdb_parser import TMDBInfo\n\nlogger = logging.getLogger(__name__)\n\n\n@dataclass\nclass OffsetSuggestion:\n    \"\"\"Suggested offsets to align RSS parsed data with TMDB.\"\"\"\n\n    season_offset: int\n    episode_offset: int | None  # None means no episode offset needed\n    reason: str\n    confidence: Literal[\"high\", \"medium\", \"low\"]\n\n\ndef detect_offset_mismatch(\n    parsed_season: int,\n    parsed_episode: int,\n    tmdb_info: TMDBInfo,\n) -> OffsetSuggestion | None:\n    \"\"\"Detect if there's a mismatch between parsed season/episode and TMDB data.\n\n    Uses air date gaps to detect \"virtual seasons\" - when TMDB has 1 season but\n    subtitle groups split it into S1/S2 based on broadcast breaks (>6 months gap).\n\n    Args:\n        parsed_season: Season number parsed from RSS/torrent name\n        parsed_episode: Episode number parsed from RSS/torrent name\n        tmdb_info: TMDB information for the anime\n\n    Returns:\n        OffsetSuggestion if a mismatch is detected, None otherwise\n\n    Note:\n        When only season_offset is needed (simple season mismatch), episode_offset\n        will be None. Episode offset is only set when there's a virtual season split\n        where episodes need to be renumbered (e.g., RSS S2E01 → TMDB S1E25).\n    \"\"\"\n    if not tmdb_info or not tmdb_info.last_season:\n        return None\n\n    suggested_season_offset = 0\n    suggested_episode_offset: int | None = None  # Only set when virtual season detected\n    reasons = []\n    confidence: Literal[\"high\", \"medium\", \"low\"] = \"high\"\n\n    # Check season mismatch\n    # If parsed season exceeds TMDB's total seasons, suggest mapping to last season\n    if parsed_season > tmdb_info.last_season:\n        suggested_season_offset = tmdb_info.last_season - parsed_season\n        target_season = parsed_season + suggested_season_offset\n\n        # Check if this season has virtual season breakpoints (detected from air date gaps)\n        if (\n            tmdb_info.virtual_season_starts\n            and target_season in tmdb_info.virtual_season_starts\n        ):\n            vs_starts = tmdb_info.virtual_season_starts[target_season]\n            # Calculate which virtual season the parsed_season maps to\n            # e.g., if vs_starts = [1, 29] and parsed_season = 2, we're in the 2nd virtual season\n            virtual_season_index = (\n                parsed_season - target_season\n            )  # 0-indexed from target\n\n            if virtual_season_index > 0 and virtual_season_index < len(vs_starts):\n                # Only set episode offset for 2nd+ virtual season (index > 0)\n                # First virtual season (index 0) starts at episode 1, no offset needed\n                suggested_episode_offset = vs_starts[virtual_season_index] - 1\n                reasons.append(\n                    f\"RSS显示S{parsed_season}，但TMDB只有{tmdb_info.last_season}季\"\n                    f\"（检测到第{virtual_season_index + 1}部分从第{vs_starts[virtual_season_index]}集开始，\"\n                    f\"建议集数偏移+{suggested_episode_offset}）\"\n                )\n                logger.debug(\n                    f\"[OffsetDetector] Virtual season detected: S{parsed_season} maps to \"\n                    f\"TMDB S{target_season} starting at episode {vs_starts[virtual_season_index]}\"\n                )\n            else:\n                # Simple season mismatch, no episode offset needed\n                reasons.append(\n                    f\"RSS显示S{parsed_season}，但TMDB只有{tmdb_info.last_season}季\"\n                    f\"（建议季度偏移{suggested_season_offset}，无需调整集数）\"\n                )\n        else:\n            # Simple season mismatch, no episode offset needed\n            reasons.append(\n                f\"RSS显示S{parsed_season}，但TMDB只有{tmdb_info.last_season}季\"\n                f\"（建议季度偏移{suggested_season_offset}，无需调整集数）\"\n            )\n\n        logger.debug(\n            f\"[OffsetDetector] Season mismatch: parsed S{parsed_season}, \"\n            f\"TMDB has {tmdb_info.last_season} seasons, suggesting offset {suggested_season_offset}\"\n        )\n\n    # Check episode range for target season\n    target_season = parsed_season + suggested_season_offset\n    if tmdb_info.season_episode_counts:\n        season_ep_count = tmdb_info.season_episode_counts.get(target_season, 0)\n        adjusted_episode = parsed_episode + (suggested_episode_offset or 0)\n\n        if season_ep_count > 0 and adjusted_episode > season_ep_count:\n            # Episode exceeds the count for this season\n            if tmdb_info.series_status == \"Returning Series\":\n                confidence = \"medium\"\n                reasons.append(\n                    f\"调整后集数{adjusted_episode}超出TMDB该季的{season_ep_count}集\"\n                    f\"（正在放送中，TMDB可能未更新）\"\n                )\n            else:\n                reasons.append(\n                    f\"调整后集数{adjusted_episode}超出TMDB该季的{season_ep_count}集\"\n                )\n\n            logger.debug(\n                f\"[OffsetDetector] Episode range issue: adjusted E{adjusted_episode}, \"\n                f\"TMDB S{target_season} has {season_ep_count} episodes\"\n            )\n\n    # Only return suggestion if there's actually a mismatch\n    if reasons:\n        return OffsetSuggestion(\n            season_offset=suggested_season_offset,\n            episode_offset=suggested_episode_offset,\n            reason=\"; \".join(reasons),\n            confidence=confidence,\n        )\n\n    return None\n"
  },
  {
    "path": "backend/src/module/parser/analyser/openai.py",
    "content": "import json\nimport logging\nfrom concurrent.futures import ThreadPoolExecutor\nfrom typing import Any, Optional\n\nfrom openai import AzureOpenAI, OpenAI\nfrom pydantic import BaseModel\n\nfrom module.models import Bangumi\n\nlogger = logging.getLogger(__name__)\n\n\nclass Episode(BaseModel):\n    title_en: Optional[str]\n    title_zh: Optional[str]\n    title_jp: Optional[str]\n    season: str\n    season_raw: str\n    episode: str\n    sub: str\n    group: str\n    resolution: str\n    source: str\n\n\nDEFAULT_PROMPT = \"\"\"\\\nYou will now play the role of a super assistant. \nYour task is to extract structured data from unstructured text content and output it in JSON format. \nIf you are unable to extract any information, please keep all fields and leave the field empty or default value like `''`, `None`.\nBut Do not fabricate data!\n\"\"\"\n\n\nclass OpenAIParser:\n    def __init__(\n        self,\n        api_key: str,\n        api_base: str = \"https://api.openai.com/v1\",\n        model: str = \"gpt-4o-mini\",\n        api_type: str = \"openai\",\n        **kwargs,\n    ) -> None:\n        \"\"\"OpenAIParser is a class to parse text with openai\n\n        Args:\n            api_key (str): the OpenAI api key\n            api_base (str):\n                the OpenAI api base url, you can use custom url here. \\\n                Defaults to \"https://api.openai.com/v1\".\n            model (str):\n                the ChatGPT model parameter, you can get more details from \\\n                https://platform.openai.com/docs/api-reference/chat/create. \\\n                Defaults to \"gpt-4o-mini\".\n            kwargs (dict):\n                the OpenAI ChatGPT parameters, you can get more details from \\\n                https://platform.openai.com/docs/api-reference/chat/create.\n\n        Raises:\n            ValueError: if api_key is not provided.\n        \"\"\"\n        if not api_key:\n            raise ValueError(\"API key is required.\")\n        if api_type == \"azure\":\n            self.client = AzureOpenAI(\n                api_key=api_key,\n                base_url=api_base,\n                azure_deployment=kwargs.get(\"deployment_id\", \"\"),\n                api_version=kwargs.get(\"api_version\", \"2023-05-15\"),\n            )\n        else:\n            self.client = OpenAI(api_key=api_key, base_url=api_base)\n\n        self.model = model\n        self.openai_kwargs = kwargs\n\n    def parse(\n        self, text: str, prompt: str | None = None, asdict: bool = True\n    ) -> dict | str:\n        \"\"\"parse text with openai\n\n        Args:\n            text (str): the text to be parsed\n            prompt (str | None, optional):\n                the custom prompt. Built-in prompt will be used if no prompt is provided. \\\n                Defaults to None.\n            asdict (bool, optional):\n                whether to return the result as dict or not. \\\n                Defaults to True.\n\n        Returns:\n            dict | str: the parsed result.\n        \"\"\"\n        if not prompt:\n            prompt = DEFAULT_PROMPT\n\n        params = self._prepare_params(text, prompt)\n\n        with ThreadPoolExecutor(max_workers=1) as worker:\n            future = worker.submit(self.client.beta.chat.completions.parse, **params)\n            resp = future.result()\n\n            result = resp.choices[0].message.parsed\n\n        if asdict:\n            if hasattr(result, \"model_dump\"):\n                result = result.model_dump()\n            else:\n                try:\n                    result = json.loads(\n                        result[result.index(\"{\") : result.rindex(\"}\") + 1]\n                    )  # find the first { and last } for better compatibility\n                except (json.JSONDecodeError, ValueError):\n                    logger.warning(f\"Cannot parse result {result} as python dict.\")\n\n        logger.debug(\"the parsed result is: %s\", result)\n\n        return result\n\n    def _prepare_params(self, text: str, prompt: str) -> dict[str, Any]:\n        \"\"\"_prepare_params is a helper function to prepare params for openai library.\n        There are some differences between openai and azure openai api, so we need to\n        prepare params for them.\n\n        Args:\n            text (str): the text to be parsed\n            prompt (str): the custom prompt\n\n        Returns:\n            dict[str, Any]: the prepared key value pairs.\n        \"\"\"\n        params = dict(\n            model=self.model,\n            messages=[\n                dict(role=\"system\", content=prompt),\n                dict(role=\"user\", content=text),\n            ],\n            response_format=Episode,\n            # set temperature to 0 to make results be more stable and reproducible.\n            temperature=0,\n        )\n\n        api_type = self.openai_kwargs.get(\"api_type\", \"openai\")\n        if api_type == \"azure\":\n            params[\"deployment_id\"] = self.openai_kwargs.get(\"deployment_id\", \"\")\n            params[\"api_version\"] = self.openai_kwargs.get(\"api_version\", \"2023-05-15\")\n            params[\"api_type\"] = \"azure\"\n        else:\n            params[\"model\"] = self.model\n\n        return params\n"
  },
  {
    "path": "backend/src/module/parser/analyser/raw_parser.py",
    "content": "import logging\nimport re\n\nfrom module.models import Episode\n\nlogger = logging.getLogger(__name__)\n\nEPISODE_RE = re.compile(r\"\\d+\")\nTITLE_RE = re.compile(\n    r\"(.*?|\\[.*])((?: ?-) ?\\d+ |\\[\\d+]|\\[\\d+.?[vV]\\d]|第\\d+[话話集]|\\[第?\\d+[话話集]]|\\[\\d+.?END]|[Ee][Pp]?\\d+)(.*)\"\n)\nRESOLUTION_RE = re.compile(r\"1080|720|2160|4K\")\nSOURCE_RE = re.compile(r\"B-Global|[Bb]aha|[Bb]ilibili|AT-X|Web\")\nSUB_RE = re.compile(r\"[简繁日字幕]|CH|BIG5|GB\")\n\nFALLBACK_EP_PATTERNS = [\n    re.compile(r\" (\\d+) ?(?=\\[)\"),  # #876/#910: digits before [\n    re.compile(r\"\\[(\\d+)\\(\\d+\\)\\]\"),  # #773: [02(57)]\n]\n\nPREFIX_RE = re.compile(r\"[^\\w\\s\\u4e00-\\u9fff\\u3040-\\u309f\\u30a0-\\u30ff-]\")\n\n\ndef _fallback_parse(content_title: str) -> tuple | None:\n    \"\"\"Try fallback regex patterns when TITLE_RE fails.\"\"\"\n    for pattern in FALLBACK_EP_PATTERNS:\n        m = pattern.search(content_title)\n        if m:\n            season_info = content_title[: m.start()].strip()\n            episode_info = m.group(1)\n            other = content_title[m.end() :].strip()\n            return season_info, episode_info, other\n    return None\n\n\nCHINESE_NUMBER_MAP = {\n    \"一\": 1,\n    \"二\": 2,\n    \"三\": 3,\n    \"四\": 4,\n    \"五\": 5,\n    \"六\": 6,\n    \"七\": 7,\n    \"八\": 8,\n    \"九\": 9,\n    \"十\": 10,\n}\n\n\ndef get_group(name: str) -> str:\n    parts = re.split(r\"[\\[\\]]\", name)\n    if len(parts) > 1:\n        return parts[1]\n    return \"\"\n\n\ndef pre_process(raw_name: str) -> str:\n    return raw_name.replace(\"【\", \"[\").replace(\"】\", \"]\")\n\n\ndef prefix_process(raw: str, group: str) -> str:\n    raw = re.sub(f\".{re.escape(group)}.\", \"\", raw)\n    raw_process = PREFIX_RE.sub(\"/\", raw)\n    arg_group = raw_process.split(\"/\")\n    while \"\" in arg_group:\n        arg_group.remove(\"\")\n    if len(arg_group) == 1:\n        arg_group = arg_group[0].split(\" \")\n    for arg in arg_group:\n        if re.search(r\"新番|月?番\", arg) and len(arg) <= 5:\n            raw = re.sub(f\".{re.escape(arg)}.\", \"\", raw)\n        elif re.search(r\"港澳台地区\", arg):\n            raw = re.sub(f\".{re.escape(arg)}.\", \"\", raw)\n    return raw\n\n\ndef season_process(season_info: str):\n    name_season = season_info\n    # if re.search(r\"新番|月?番\", season_info):\n    #     name_season = re.sub(\".*新番.\", \"\", season_info)\n    #     # 去除「新番」信息\n    # name_season = re.sub(r\"^[^]】]*[]】]\", \"\", name_season).strip()\n    season_rule = r\"S\\d{1,2}|Season \\d{1,2}|[第].[季期]\"\n    name_season = re.sub(r\"[\\[\\]]\", \" \", name_season)\n    seasons = re.findall(season_rule, name_season)\n    if not seasons:\n        return name_season, \"\", 1\n    name = re.sub(season_rule, \"\", name_season)\n    for season in seasons:\n        season_raw = season\n        if re.search(r\"Season|S\", season) is not None:\n            season = int(re.sub(r\"Season|S\", \"\", season))\n            break\n        elif re.search(r\"[第 ].*[季期(部分)]|部分\", season) is not None:\n            season_pro = re.sub(r\"[第季期 ]\", \"\", season)\n            try:\n                season = int(season_pro)\n            except ValueError:\n                season = CHINESE_NUMBER_MAP[season_pro]\n            break\n    return name, season_raw, season\n\n\ndef name_process(name: str):\n    name_en, name_zh, name_jp = None, None, None\n    name = name.strip()\n    name = re.sub(r\"[(（]仅限港澳台地区[）)]\", \"\", name)\n    split = re.split(r\"/|\\s{2}|-\\s{2}\", name)\n    while \"\" in split:\n        split.remove(\"\")\n    if len(split) == 1:\n        if re.search(\"_{1}\", name) is not None:\n            split = re.split(\"_\", name)\n        elif re.search(\" - {1}\", name) is not None:\n            split = re.split(\"-\", name)\n    if len(split) == 1:\n        # Titles like \"29 岁单身...\" — digits + Chinese are one title\n        if re.match(r\"\\d+\\s[\\u4e00-\\u9fa5]\", split[0]):\n            name_zh = split[0].strip()\n            return name_en, name_zh, name_jp\n        split_space = split[0].split(\" \")\n        for idx in [0, -1]:\n            if re.search(r\"^[\\u4e00-\\u9fa5]{2,}\", split_space[idx]) is not None:\n                chs = split_space[idx]\n                split_space.remove(chs)\n                split = [chs, \" \".join(split_space)]\n                break\n    for item in split:\n        if re.search(r\"[\\u0800-\\u4e00]{2,}\", item) and not name_jp:\n            name_jp = item.strip()\n        elif re.search(r\"[\\u4e00-\\u9fa5]{2,}\", item) and not name_zh:\n            name_zh = item.strip()\n        elif re.search(r\"[a-zA-Z]{3,}\", item) and not name_en:\n            name_en = item.strip()\n    return name_en, name_zh, name_jp\n\n\ndef find_tags(other):\n    elements = re.sub(r\"[\\[\\]()（）]\", \" \", other).split(\" \")\n    # find CHT\n    sub, resolution, source = None, None, None\n    for element in filter(lambda x: x != \"\", elements):\n        if SUB_RE.search(element):\n            sub = element\n        elif RESOLUTION_RE.search(element):\n            resolution = element\n        elif SOURCE_RE.search(element):\n            source = element\n    return clean_sub(sub), resolution, source\n\n\ndef clean_sub(sub: str | None) -> str | None:\n    if sub is None:\n        return sub\n    return re.sub(r\"_MP4|_MKV\", \"\", sub)\n\n\ndef process(raw_title: str):\n    raw_title = raw_title.strip().replace(\"\\n\", \" \")\n    content_title = pre_process(raw_title)\n    # 预处理标题\n    group = get_group(content_title)\n    # 翻译组的名字\n    match_obj = TITLE_RE.match(content_title)\n    if match_obj is not None:\n        season_info, episode_info, other = [x.strip() for x in match_obj.groups()]\n    else:\n        fallback = _fallback_parse(content_title)\n        if fallback is None:\n            return None\n        season_info, episode_info, other = fallback\n    process_raw = prefix_process(season_info, group)\n    # 处理 前缀\n    raw_name, season_raw, season = season_process(process_raw)\n    # 处理 第n季\n    name_en, name_zh, name_jp = \"\", \"\", \"\"\n    try:\n        name_en, name_zh, name_jp = name_process(raw_name)\n        # 处理 名字\n    except ValueError:\n        pass\n    # 处理 集数\n    raw_episode = EPISODE_RE.search(episode_info)\n    episode = 0\n    if raw_episode is not None:\n        episode = int(raw_episode.group())\n    sub, dpi, source = find_tags(other)  # 剩余信息处理\n    return (\n        name_en,\n        name_zh,\n        name_jp,\n        season,\n        season_raw,\n        episode,\n        sub,\n        dpi,\n        source,\n        group,\n    )\n\n\ndef raw_parser(raw: str) -> Episode | None:\n    ret = process(raw)\n    if ret is None:\n        logger.info(f\"Detected non-episodic resource: {raw}, skipping.\")\n        return None\n    name_en, name_zh, name_jp, season, sr, episode, sub, dpi, source, group = ret\n    return Episode(\n        name_en, name_zh, name_jp, season, sr, episode, sub, group, dpi, source\n    )\n\n\nif __name__ == \"__main__\":\n    title = \"[动漫国字幕组&LoliHouse] THE MARGINAL SERVICE - 08 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\"\n    print(raw_parser(title))\n    title = \"[北宇治字幕组&LoliHouse] 地。-关于地球的运动- / Chi. Chikyuu no Undou ni Tsuite 03 [WebRip 1080p HEVC-10bit AAC ASSx2][简繁日内封字幕]\"\n    print(raw_parser(title))\n    title = \"[御坂字幕组] 男女之间存在纯友情吗？（不，不存在!!）-01 [WebRip 1080p HEVC10-bit AAC] [简繁日内封] [急招翻校轴]\"\n    print(raw_parser(title))\n"
  },
  {
    "path": "backend/src/module/parser/analyser/tmdb_parser.py",
    "content": "import asyncio\nimport logging\nimport re\nimport time\nfrom collections import OrderedDict\nfrom dataclasses import dataclass\n\nfrom module.conf import TMDB_API\nfrom module.network import RequestContent\nfrom module.utils import save_image\n\nlogger = logging.getLogger(__name__)\n\nTMDB_URL = \"https://api.themoviedb.org\"\n\n# In-memory cache for TMDB lookups to avoid repeated API calls\n_TMDB_CACHE_MAX = 512\n_tmdb_cache: OrderedDict[str, \"TMDBInfo | None\"] = OrderedDict()\n\n\n@dataclass\nclass TMDBInfo:\n    id: int\n    title: str\n    original_title: str\n    season: list[dict]\n    last_season: int\n    year: str\n    poster_link: str = None\n    series_status: str = None  # \"Ended\", \"Returning Series\", etc.\n    season_episode_counts: dict[int, int] = None  # {1: 13, 2: 12, ...}\n    virtual_season_starts: dict[int, list[int]] = (\n        None  # {1: [1, 29], ...} - episode numbers where virtual seasons start\n    )\n\n    def get_offset_for_season(self, season: int) -> int:\n        \"\"\"Calculate offset for a season (negative sum of all previous seasons' episodes).\n\n        Used when RSS episode numbers are absolute (e.g., S02E18 should be S02E05).\n        Returns the offset to subtract from the parsed episode number.\n        \"\"\"\n        if not self.season_episode_counts or season <= 1:\n            return 0\n        return -sum(self.season_episode_counts.get(s, 0) for s in range(1, season))\n\n\nLANGUAGE = {\"zh\": \"zh-CN\", \"jp\": \"ja-JP\", \"en\": \"en-US\"}\n\n\ndef search_url(e):\n    return f\"{TMDB_URL}/3/search/tv?api_key={TMDB_API}&page=1&query={e}&include_adult=false\"\n\n\ndef info_url(e, key):\n    return f\"{TMDB_URL}/3/tv/{e}?api_key={TMDB_API}&language={LANGUAGE[key]}\"\n\n\ndef season_url(tv_id, season_number, key):\n    return f\"{TMDB_URL}/3/tv/{tv_id}/season/{season_number}?api_key={TMDB_API}&language={LANGUAGE[key]}\"\n\n\nasync def is_animation(tv_id, language, req: RequestContent) -> bool:\n    url_info = info_url(tv_id, language)\n    type_id = await req.get_json(url_info)\n    if type_id:\n        for type in type_id.get(\"genres\", []):\n            if type.get(\"id\") == 16:\n                return True\n    return False\n\n\nasync def get_season_episode_air_dates(\n    tv_id: int, season_number: int, language: str, req: RequestContent\n) -> list[dict]:\n    \"\"\"Get episode air dates for a season.\n\n    Returns:\n        List of {episode_number, air_date} dicts, sorted by episode number\n    \"\"\"\n    import datetime\n\n    url = season_url(tv_id, season_number, language)\n    season_data = await req.get_json(url)\n    if not season_data:\n        return []\n\n    episodes = []\n    for ep in season_data.get(\"episodes\", []):\n        ep_num = ep.get(\"episode_number\")\n        air_date_str = ep.get(\"air_date\")\n        if ep_num and air_date_str:\n            try:\n                air_date = datetime.date.fromisoformat(air_date_str)\n                episodes.append({\"episode_number\": ep_num, \"air_date\": air_date})\n            except ValueError:\n                continue\n\n    return sorted(episodes, key=lambda x: x[\"episode_number\"])\n\n\ndef detect_virtual_seasons(episodes: list[dict], gap_months: int = 6) -> list[int]:\n    \"\"\"Detect virtual season breakpoints based on air date gaps.\n\n    When there's a gap > gap_months between consecutive episodes,\n    it indicates a \"cour break\" or \"virtual season\" boundary.\n\n    Args:\n        episodes: List of {episode_number, air_date} dicts\n        gap_months: Minimum gap in months to consider a season break (default 6)\n\n    Returns:\n        List of episode numbers where virtual seasons START (e.g., [1, 29] means S1 starts at ep1, S2 at ep29)\n    \"\"\"\n    import datetime\n\n    if len(episodes) < 2:\n        return [1] if episodes else []\n\n    virtual_season_starts = [1]  # First virtual season always starts at episode 1\n    gap_days = gap_months * 30  # Approximate months to days\n\n    for i in range(1, len(episodes)):\n        prev_ep = episodes[i - 1]\n        curr_ep = episodes[i]\n        days_diff = (curr_ep[\"air_date\"] - prev_ep[\"air_date\"]).days\n\n        if days_diff > gap_days:\n            virtual_season_starts.append(curr_ep[\"episode_number\"])\n            logger.debug(\n                \"[TMDB] Detected virtual season break: %s days gap \"\n                \"between ep%s and ep%s\",\n                days_diff,\n                prev_ep[\"episode_number\"],\n                curr_ep[\"episode_number\"],\n            )\n\n    return virtual_season_starts\n\n\nasync def get_aired_episode_count(\n    tv_id: int, season_number: int, language: str, req: RequestContent\n) -> int:\n    \"\"\"Get the count of episodes that have actually aired for a season.\n\n    Args:\n        tv_id: TMDB TV show ID\n        season_number: Season number\n        language: Language code\n        req: Request content instance\n\n    Returns:\n        Number of episodes that have aired (air_date <= today)\n    \"\"\"\n    import datetime\n\n    url = season_url(tv_id, season_number, language)\n    season_data = await req.get_json(url)\n    if not season_data:\n        return 0\n\n    episodes = season_data.get(\"episodes\", [])\n    today = datetime.date.today()\n    aired_count = 0\n\n    for ep in episodes:\n        air_date_str = ep.get(\"air_date\")\n        if air_date_str:\n            try:\n                air_date = datetime.date.fromisoformat(air_date_str)\n                if air_date <= today:\n                    aired_count += 1\n            except ValueError:\n                # Invalid date format, skip this episode\n                continue\n\n    logger.debug(\n        \"[TMDB] Season %s: %s aired of %s total episodes\",\n        season_number,\n        aired_count,\n        len(episodes),\n    )\n    return aired_count\n\n\ndef get_season(seasons: list) -> tuple[int, str]:\n    ss = [s for s in seasons if s[\"air_date\"] is not None and \"特别\" not in s[\"season\"]]\n    if not ss:\n        return 1, None\n    ss = sorted(ss, key=lambda e: e.get(\"air_date\"), reverse=True)\n    for season in ss:\n        if re.search(r\"第 \\d+ 季\", season.get(\"season\")) is not None:\n            date = season.get(\"air_date\").split(\"-\")\n            [year, _, _] = date\n            now_year = time.localtime().tm_year\n            if int(year) <= now_year:\n                return int(re.findall(r\"\\d+\", season.get(\"season\"))[0]), season.get(\n                    \"poster_path\"\n                )\n    return len(ss), ss[-1].get(\"poster_path\")\n\n\nasync def tmdb_parser(title, language, test: bool = False) -> TMDBInfo | None:\n    cache_key = f\"{title}:{language}\"\n    if cache_key in _tmdb_cache:\n        return _tmdb_cache[cache_key]\n\n    async with RequestContent() as req:\n        url = search_url(title)\n        contents = await req.get_json(url)\n        if not contents:\n            return None\n        contents = contents.get(\"results\")\n        if contents.__len__() == 0:\n            url = search_url(title.replace(\" \", \"\"))\n            contents_resp = await req.get_json(url)\n            if not contents_resp:\n                return None\n            contents = contents_resp.get(\"results\")\n        # 判断动画\n        if contents:\n            matched_id = None\n            for content in contents:\n                cid = content[\"id\"]\n                if await is_animation(cid, language, req):\n                    matched_id = cid\n                    break\n            if matched_id is None:\n                _tmdb_cache[cache_key] = None\n                return None\n            url_info = info_url(matched_id, language)\n            info_content = await req.get_json(url_info)\n            season = [\n                {\n                    \"season\": s.get(\"name\"),\n                    \"air_date\": s.get(\"air_date\"),\n                    \"poster_path\": s.get(\"poster_path\"),\n                }\n                for s in info_content.get(\"seasons\")\n            ]\n            last_season, poster_path = get_season(season)\n            # Extract series status (e.g., \"Ended\", \"Returning Series\")\n            series_status = info_content.get(\"status\")\n            # Extract episode counts per season (exclude specials at season 0)\n            # For ongoing series, we need to get actual aired episode counts\n            season_episode_counts = {}\n            virtual_season_starts = {}\n            season_nums = [\n                (s.get(\"season_number\", 0), s.get(\"episode_count\", 0))\n                for s in info_content.get(\"seasons\", [])\n                if s.get(\"season_number\", 0) > 0\n            ]\n            episode_results = await asyncio.gather(\n                *[\n                    get_season_episode_air_dates(matched_id, sn, language, req)\n                    for sn, _ in season_nums\n                ],\n                return_exceptions=True,\n            )\n            for (season_num, total_eps), episodes in zip(season_nums, episode_results):\n                if isinstance(episodes, Exception):\n                    logger.warning(\n                        \"[TMDB] Failed to get episodes for season %s: %s\",\n                        season_num,\n                        episodes,\n                    )\n                    season_episode_counts[season_num] = total_eps\n                    continue\n                if episodes:\n                    # Detect virtual seasons based on air date gaps\n                    vs_starts = detect_virtual_seasons(episodes)\n                    if len(vs_starts) > 1:\n                        virtual_season_starts[season_num] = vs_starts\n                        logger.debug(\n                            \"[TMDB] Season %s has virtual seasons starting at episodes: %s\",\n                            season_num,\n                            vs_starts,\n                        )\n                    # Count only aired episodes\n                    season_episode_counts[season_num] = len(episodes)\n                else:\n                    season_episode_counts[season_num] = total_eps\n            if poster_path is None:\n                poster_path = info_content.get(\"poster_path\")\n            original_title = info_content.get(\"original_name\")\n            official_title = info_content.get(\"name\")\n            year_number = (info_content.get(\"first_air_date\") or \"\").split(\"-\")[0]\n            if poster_path:\n                if not test:\n                    img = await req.get_content(\n                        f\"https://image.tmdb.org/t/p/w780{poster_path}\"\n                    )\n                    poster_link = save_image(img, \"jpg\")\n                else:\n                    poster_link = \"https://image.tmdb.org/t/p/w780\" + poster_path\n            else:\n                poster_link = None\n            result = TMDBInfo(\n                id=matched_id,\n                title=official_title,\n                original_title=original_title,\n                season=season,\n                last_season=last_season,\n                year=str(year_number),\n                poster_link=poster_link,\n                series_status=series_status,\n                season_episode_counts=season_episode_counts,\n                virtual_season_starts=(\n                    virtual_season_starts if virtual_season_starts else None\n                ),\n            )\n            if len(_tmdb_cache) >= _TMDB_CACHE_MAX:\n                _tmdb_cache.popitem(last=False)\n            _tmdb_cache[cache_key] = result\n            return result\n        else:\n            if len(_tmdb_cache) >= _TMDB_CACHE_MAX:\n                _tmdb_cache.popitem(last=False)\n            _tmdb_cache[cache_key] = None\n            return None\n\n\nif __name__ == \"__main__\":\n    import asyncio\n\n    print(asyncio.run(tmdb_parser(\"魔法禁书目录\", \"zh\")))\n"
  },
  {
    "path": "backend/src/module/parser/analyser/torrent_parser.py",
    "content": "import logging\nimport re\nfrom collections import OrderedDict\nfrom pathlib import Path\n\nfrom module.models import EpisodeFile, SubtitleFile\n\nlogger = logging.getLogger(__name__)\n\n# LRU cache for torrent_parser results to avoid repeated regex parsing\n_PARSER_CACHE_MAX_SIZE = 512\n_parser_cache: OrderedDict[tuple, EpisodeFile | SubtitleFile | None] = OrderedDict()\n\nPLATFORM = \"Unix\"\n\nRULES = [\n    r\"(.*) - (\\d{1,4}(?:\\.\\d{1,2})?(?!\\d|p))(?:v\\d{1,2})?(?: )?(?:END)?(.*)\",\n    r\"(.*)[\\[\\ E](\\d{1,4}(?:\\.\\d{1,2})?)(?:v\\d{1,2})?(?: )?(?:END)?[\\]\\ ](.*)\",\n    r\"(.*)\\[(?:第)?(\\d{1,4}(?:\\.\\d{1,2})?)[话集話](?:END)?\\](.*)\",\n    r\"(.*)第?(\\d{1,4}(?:\\.\\d{1,2})?)[话話集](?:END)?(.*)\",\n    r\"(.*)(?:S\\d{2})?EP?(\\d{1,4}(?:\\.\\d{1,2})?)(.*)\",\n]\n\nCOMPILED_RULES = [re.compile(rule, re.I) for rule in RULES]\n\nSUBTITLE_LANG = {\n    \"zh-tw\": [\"tc\", \"cht\", \"繁\", \"zh-tw\"],\n    \"zh\": [\"sc\", \"chs\", \"简\", \"zh\"],\n}\n\n\ndef get_path_basename(torrent_path: str) -> str:\n    \"\"\"\n    Returns the basename of a path string.\n\n    :param torrent_path: A string representing a path to a file.\n    :type torrent_path: str\n    :return: A string representing the basename of the given path.\n    :rtype: str\n    \"\"\"\n    return Path(torrent_path).name\n\n\n_GROUP_SPLIT_RE = re.compile(r\"[\\[\\]()【】（）]\")\n\n\ndef get_group(group_and_title) -> tuple[str | None, str]:\n    n = [x for x in _GROUP_SPLIT_RE.split(group_and_title) if x]\n    if len(n) > 1:\n        if re.match(r\"\\d+\", n[1]):\n            return None, group_and_title\n        return n[0], n[1]\n    else:\n        return None, n[0]\n\n\ndef get_season_and_title(season_and_title) -> tuple[str, int]:\n    title = re.sub(r\"([Ss]|Season )\\d{1,3}\", \"\", season_and_title).strip()\n    try:\n        season = re.search(r\"([Ss]|Season )(\\d{1,3})\", season_and_title, re.I).group(2)\n    except AttributeError:\n        season = 1\n    return title, int(season)\n\n\ndef get_subtitle_lang(subtitle_name: str) -> str:\n    for key, value in SUBTITLE_LANG.items():\n        for v in value:\n            if v in subtitle_name.lower():\n                return key\n\n\ndef torrent_parser(\n    torrent_path: str,\n    torrent_name: str | None = None,\n    season: int | None = None,\n    file_type: str = \"media\",\n) -> EpisodeFile | SubtitleFile | None:\n    # Check cache first to avoid repeated regex parsing\n    cache_key = (torrent_path, torrent_name, season, file_type)\n    if cache_key in _parser_cache:\n        # Move to end to mark as recently used\n        _parser_cache.move_to_end(cache_key)\n        return _parser_cache[cache_key]\n\n    result = _torrent_parser_impl(torrent_path, torrent_name, season, file_type)\n\n    # Store in cache with LRU eviction\n    _parser_cache[cache_key] = result\n    if len(_parser_cache) > _PARSER_CACHE_MAX_SIZE:\n        _parser_cache.popitem(last=False)  # Remove oldest item\n\n    return result\n\n\ndef _torrent_parser_impl(\n    torrent_path: str,\n    torrent_name: str | None = None,\n    season: int | None = None,\n    file_type: str = \"media\",\n) -> EpisodeFile | SubtitleFile | None:\n    \"\"\"Internal implementation of torrent_parser without caching.\"\"\"\n    media_path = get_path_basename(torrent_path)\n    match_names = [torrent_name, media_path]\n    if torrent_name is None:\n        match_names = match_names[1:]\n    for match_name in match_names:\n        for compiled_rule in COMPILED_RULES:\n            match_obj = compiled_rule.match(match_name)\n            if match_obj:\n                group, title = get_group(match_obj.group(1))\n                if not season:\n                    title, season = get_season_and_title(title)\n                else:\n                    title, _ = get_season_and_title(title)\n                episode = match_obj.group(2)\n                suffix = Path(torrent_path).suffix\n                if file_type == \"media\":\n                    return EpisodeFile(\n                        media_path=torrent_path,\n                        group=group,\n                        title=title,\n                        season=season,\n                        episode=episode,\n                        suffix=suffix,\n                    )\n                elif file_type == \"subtitle\":\n                    language = get_subtitle_lang(media_path)\n                    return SubtitleFile(\n                        media_path=torrent_path,\n                        group=group,\n                        title=title,\n                        season=season,\n                        language=language,\n                        episode=episode,\n                        suffix=suffix,\n                    )\n    return None\n\n\nif __name__ == \"__main__\":\n    ep = torrent_parser(\n        \"/不时用俄语小声说真心话的邻桌艾莉同学/Season 1/不时用俄语小声说真心话的邻桌艾莉同学 S01E02.mp4\"\n    )\n    print(ep)\n\n    ep = torrent_parser(\n        \"/downloads/Bangumi/关于我转生变成史莱姆这档事 (2018)/Season 3/[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4\"\n    )\n    print(ep)\n\n    ep = torrent_parser(\n        \"/downloads/Bangumi/关于我转生变成史莱姆这档事 (2018)/Season 3/[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].srt\",\n        file_type=\"subtitle\",\n    )\n    print(ep)\n"
  },
  {
    "path": "backend/src/module/parser/title_parser.py",
    "content": "import logging\n\nfrom module.conf import settings\nfrom module.models import Bangumi\nfrom module.models.bangumi import Episode\nfrom module.parser.analyser import (\n    OpenAIParser,\n    mikan_parser,\n    raw_parser,\n    tmdb_parser,\n    torrent_parser,\n)\n\nlogger = logging.getLogger(__name__)\n\n\nclass TitleParser:\n    def __init__(self):\n        pass\n\n    @staticmethod\n    def torrent_parser(\n        torrent_path: str,\n        torrent_name: str | None = None,\n        season: int | None = None,\n        file_type: str = \"media\",\n    ):\n        try:\n            return torrent_parser(torrent_path, torrent_name, season, file_type)\n        except Exception as e:\n            logger.warning(f\"Cannot parse {torrent_path} with error {e}\")\n\n    @staticmethod\n    async def tmdb_parser(title: str, season: int, language: str):\n        tmdb_info = await tmdb_parser(title, language)\n        if tmdb_info:\n            logger.debug(\"TMDB Matched, official title is %s\", tmdb_info.title)\n            tmdb_season = tmdb_info.last_season if tmdb_info.last_season else season\n            return tmdb_info.title, tmdb_season, tmdb_info.year, tmdb_info.poster_link\n        else:\n            logger.warning(f\"Cannot match {title} in TMDB. Use raw title instead.\")\n            logger.warning(\"Please change bangumi info manually.\")\n            return title, season, None, None\n\n    @staticmethod\n    async def tmdb_poster_parser(bangumi: Bangumi):\n        tmdb_info = await tmdb_parser(\n            bangumi.official_title, settings.rss_parser.language\n        )\n        if tmdb_info:\n            logger.debug(\"TMDB Matched, official title is %s\", tmdb_info.title)\n            bangumi.poster_link = tmdb_info.poster_link\n        else:\n            logger.warning(\n                f\"Cannot match {bangumi.official_title} in TMDB. Use raw title instead.\"\n            )\n            logger.warning(\"Please change bangumi info manually.\")\n\n    @staticmethod\n    def raw_parser(raw: str) -> Bangumi | None:\n        language = settings.rss_parser.language\n        try:\n            # use OpenAI ChatGPT to parse raw title and get structured data\n            if settings.experimental_openai.enable:\n                kwargs = settings.experimental_openai.dict(exclude={\"enable\"})\n                gpt = OpenAIParser(**kwargs)\n                episode_dict = gpt.parse(raw, asdict=True)\n                episode = Episode(**episode_dict)\n            else:\n                episode = raw_parser(raw)\n                if episode is None:\n                    return None\n\n            titles = {\n                \"zh\": episode.title_zh,\n                \"en\": episode.title_en,\n                \"jp\": episode.title_jp,\n            }\n            title_raw = episode.title_en or episode.title_zh or episode.title_jp\n            if titles[language]:\n                official_title = titles[language]\n            elif titles[\"zh\"]:\n                official_title = titles[\"zh\"]\n            elif titles[\"en\"]:\n                official_title = titles[\"en\"]\n            elif titles[\"jp\"]:\n                official_title = titles[\"jp\"]\n            else:\n                official_title = title_raw\n            if not title_raw:\n                logger.warning(\"Cannot extract title_raw from '%s', skipping\", raw)\n                return None\n            _season = episode.season\n            logger.debug(\"RAW:%s >> %s\", raw, title_raw)\n            return Bangumi(\n                official_title=official_title,\n                title_raw=title_raw,\n                season=_season,\n                season_raw=episode.season_raw,\n                group_name=episode.group,\n                dpi=episode.resolution,\n                source=episode.source,\n                subtitle=episode.sub,\n                eps_collect=False if episode.episode > 1 else True,\n                offset=0,\n                filter=\",\".join(settings.rss_parser.filter),\n            )\n        except (ValueError, AttributeError, TypeError) as e:\n            logger.warning(f\"Cannot parse '{raw}': {type(e).__name__}: {e}\")\n            return None\n\n    @staticmethod\n    async def mikan_parser(homepage: str) -> tuple[str, str]:\n        return await mikan_parser(homepage)\n"
  },
  {
    "path": "backend/src/module/rss/__init__.py",
    "content": "from .analyser import RSSAnalyser\nfrom .engine import RSSEngine\n"
  },
  {
    "path": "backend/src/module/rss/analyser.py",
    "content": "import logging\nimport re\n\nfrom module.conf import settings\nfrom module.models import Bangumi, ResponseModel, RSSItem, Torrent\nfrom module.network import RequestContent\nfrom module.parser import TitleParser\n\nfrom .engine import RSSEngine\n\nlogger = logging.getLogger(__name__)\n\n\nclass RSSAnalyser(TitleParser):\n    async def official_title_parser(self, bangumi: Bangumi, rss: RSSItem, torrent: Torrent):\n        if rss.parser == \"mikan\":\n            try:\n                bangumi.poster_link, bangumi.official_title = await self.mikan_parser(\n                    torrent.homepage\n                )\n            except AttributeError:\n                logger.warning(\"[Parser] Mikan torrent has no homepage info.\")\n                pass\n        elif rss.parser == \"tmdb\":\n            tmdb_title, season, year, poster_link = await self.tmdb_parser(\n                bangumi.official_title, bangumi.season, settings.rss_parser.language\n            )\n            bangumi.official_title = tmdb_title\n            bangumi.year = year\n            bangumi.season = season\n            bangumi.poster_link = poster_link\n        else:\n            pass\n        if bangumi.official_title:\n            bangumi.official_title = re.sub(r\"[/:.\\\\]\", \" \", bangumi.official_title)\n\n    @staticmethod\n    async def get_rss_torrents(rss_link: str, full_parse: bool = True) -> list[Torrent]:\n        async with RequestContent() as req:\n            if full_parse:\n                rss_torrents = await req.get_torrents(rss_link)\n            else:\n                rss_torrents = await req.get_torrents(rss_link, \"\\\\d+-\\\\d+\")\n        return rss_torrents\n\n    async def torrents_to_data(\n        self, torrents: list[Torrent], rss: RSSItem, full_parse: bool = True\n    ) -> list:\n        new_data = []\n        seen_titles: set[str] = set()\n        for torrent in torrents:\n            bangumi = self.raw_parser(raw=torrent.name)\n            if bangumi and bangumi.title_raw not in seen_titles:\n                await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent)\n                if not full_parse:\n                    return [bangumi]\n                seen_titles.add(bangumi.title_raw)\n                new_data.append(bangumi)\n                logger.info(f\"[RSS] New bangumi founded: {bangumi.official_title}\")\n        return new_data\n\n    async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi:\n        bangumi = self.raw_parser(raw=torrent.name)\n        if bangumi:\n            await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent)\n            bangumi.rss_link = rss.url\n            return bangumi\n\n    async def rss_to_data(\n        self, rss: RSSItem, engine: RSSEngine, full_parse: bool = True\n    ) -> list[Bangumi]:\n        rss_torrents = await self.get_rss_torrents(rss.url, full_parse)\n        torrents_to_add = engine.bangumi.match_list(rss_torrents, rss.url)\n        if not torrents_to_add:\n            logger.debug(\"[RSS] No new title has been found.\")\n            return []\n        # New List\n        new_data = await self.torrents_to_data(torrents_to_add, rss, full_parse)\n        if new_data:\n            # Add to database\n            engine.bangumi.add_all(new_data)\n            return new_data\n        else:\n            return []\n\n    async def link_to_data(self, rss: RSSItem) -> Bangumi | ResponseModel:\n        torrents = await self.get_rss_torrents(rss.url, False)\n        if not torrents:\n            return ResponseModel(\n                status=False,\n                status_code=406,\n                msg_en=\"Cannot find any torrent.\",\n                msg_zh=\"无法找到种子。\",\n            )\n        for torrent in torrents:\n            data = await self.torrent_to_data(torrent, rss)\n            if data:\n                return data\n        return ResponseModel(\n            status=False,\n            status_code=406,\n            msg_en=\"Cannot parse this link.\",\n            msg_zh=\"无法解析此链接。\",\n        )\n"
  },
  {
    "path": "backend/src/module/rss/engine.py",
    "content": "import asyncio\nimport logging\nimport re\nfrom datetime import datetime, timezone\nfrom typing import Optional\n\nfrom module.database import Database, engine\nfrom module.downloader import DownloadClient\nfrom module.models import Bangumi, ResponseModel, RSSItem, Torrent\nfrom module.network import RequestContent\n\nlogger = logging.getLogger(__name__)\n\n\nclass RSSEngine(Database):\n    def __init__(self, _engine=engine):\n        super().__init__(_engine)\n        self._to_refresh = False\n        self._filter_cache: dict[str, re.Pattern] = {}\n\n    @staticmethod\n    async def _get_torrents(rss: RSSItem) -> list[Torrent]:\n        async with RequestContent() as req:\n            torrents = await req.get_torrents(rss.url)\n            # Add RSS ID\n            for torrent in torrents:\n                torrent.rss_id = rss.id\n        return torrents\n\n    def get_rss_torrents(self, rss_id: int) -> list[Torrent]:\n        rss = self.rss.search_id(rss_id)\n        if rss:\n            return self.torrent.search_rss(rss_id)\n        else:\n            return []\n\n    async def add_rss(\n        self,\n        rss_link: str,\n        name: str | None = None,\n        aggregate: bool = True,\n        parser: str = \"mikan\",\n    ):\n        if not name:\n            async with RequestContent() as req:\n                name = await req.get_rss_title(rss_link)\n                if not name:\n                    return ResponseModel(\n                        status=False,\n                        status_code=406,\n                        msg_en=\"Failed to get RSS title.\",\n                        msg_zh=\"无法获取 RSS 标题。\",\n                    )\n        rss_data = RSSItem(name=name, url=rss_link, aggregate=aggregate, parser=parser)\n        if self.rss.add(rss_data):\n            return ResponseModel(\n                status=True,\n                status_code=200,\n                msg_en=\"RSS added successfully.\",\n                msg_zh=\"RSS 添加成功。\",\n            )\n        else:\n            return ResponseModel(\n                status=False,\n                status_code=406,\n                msg_en=\"RSS added failed.\",\n                msg_zh=\"RSS 添加失败。\",\n            )\n\n    def disable_list(self, rss_id_list: list[int]):\n        self.rss.disable_batch(rss_id_list)\n        return ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"Disable RSS successfully.\",\n            msg_zh=\"禁用 RSS 成功。\",\n        )\n\n    def enable_list(self, rss_id_list: list[int]):\n        self.rss.enable_batch(rss_id_list)\n        return ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"Enable RSS successfully.\",\n            msg_zh=\"启用 RSS 成功。\",\n        )\n\n    def delete_list(self, rss_id_list: list[int]):\n        for rss_id in rss_id_list:\n            self.rss.delete(rss_id)\n        return ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"Delete RSS successfully.\",\n            msg_zh=\"删除 RSS 成功。\",\n        )\n\n    async def pull_rss(self, rss_item: RSSItem) -> list[Torrent]:\n        torrents = await self._get_torrents(rss_item)\n        new_torrents = self.torrent.check_new(torrents)\n        return new_torrents\n\n    async def _pull_rss_with_status(\n        self, rss_item: RSSItem\n    ) -> tuple[list[Torrent], Optional[str]]:\n        try:\n            torrents = await self.pull_rss(rss_item)\n            return torrents, None\n        except Exception as e:\n            logger.warning(f\"[Engine] Failed to fetch RSS {rss_item.name}: {e}\")\n            return [], str(e)\n\n    def _get_filter_pattern(self, filter_str: str) -> re.Pattern:\n        if filter_str not in self._filter_cache:\n            raw_pattern = filter_str.replace(\",\", \"|\")\n            try:\n                self._filter_cache[filter_str] = re.compile(\n                    raw_pattern, re.IGNORECASE\n                )\n            except re.error:\n                # Filter contains invalid regex chars (e.g. unmatched '[')\n                # Fall back to escaping each term for literal matching\n                terms = filter_str.split(\",\")\n                escaped = \"|\".join(re.escape(t) for t in terms)\n                self._filter_cache[filter_str] = re.compile(\n                    escaped, re.IGNORECASE\n                )\n                logger.warning(\n                    f\"[Engine] Filter '{filter_str}' contains invalid regex, \"\n                    f\"using literal matching\"\n                )\n        return self._filter_cache[filter_str]\n\n    def match_torrent(self, torrent: Torrent) -> Optional[Bangumi]:\n        matched: Bangumi = self.bangumi.match_torrent(torrent.name)\n        if matched:\n            if matched.filter == \"\":\n                return matched\n            pattern = self._get_filter_pattern(matched.filter)\n            if not pattern.search(torrent.name):\n                torrent.bangumi_id = matched.id\n                return matched\n        return None\n\n    async def refresh_rss(self, client: DownloadClient, rss_id: Optional[int] = None):\n        # Get All RSS Items\n        if not rss_id:\n            rss_items: list[RSSItem] = self.rss.search_active()\n        else:\n            rss_item = self.rss.search_id(rss_id)\n            rss_items = [rss_item] if rss_item else []\n        # From RSS Items, fetch all torrents concurrently\n        logger.debug(\"[Engine] Get %s RSS items\", len(rss_items))\n        results = await asyncio.gather(\n            *[self._pull_rss_with_status(rss_item) for rss_item in rss_items]\n        )\n        now = datetime.now(timezone.utc).isoformat()\n        # Process results sequentially (DB operations)\n        for rss_item, (new_torrents, error) in zip(rss_items, results):\n            # Update connection status\n            rss_item.connection_status = \"error\" if error else \"healthy\"\n            rss_item.last_checked_at = now\n            rss_item.last_error = error\n            self.add(rss_item)\n            for torrent in new_torrents:\n                matched_data = self.match_torrent(torrent)\n                if matched_data:\n                    if await client.add_torrent(torrent, matched_data):\n                        logger.debug(\"[Engine] Add torrent %s to client\", torrent.name)\n                    torrent.downloaded = True\n            # Add all torrents to database\n            self.torrent.add_all(new_torrents)\n        self.commit()\n\n    async def download_bangumi(self, bangumi: Bangumi):\n        async with RequestContent() as req:\n            torrents = await req.get_torrents(\n                bangumi.rss_link, bangumi.filter.replace(\",\", \"|\")\n            )\n            if torrents:\n                async with DownloadClient() as client:\n                    await client.add_torrent(torrents, bangumi)\n                    self.torrent.add_all(torrents)\n                    return ResponseModel(\n                        status=True,\n                        status_code=200,\n                        msg_en=f\"[Engine] Download {bangumi.official_title} successfully.\",\n                        msg_zh=f\"下载 {bangumi.official_title} 成功。\",\n                    )\n            else:\n                return ResponseModel(\n                    status=False,\n                    status_code=406,\n                    msg_en=f\"[Engine] Download {bangumi.official_title} failed.\",\n                    msg_zh=f\"[Engine] 下载 {bangumi.official_title} 失败。\",\n                )\n"
  },
  {
    "path": "backend/src/module/searcher/__init__.py",
    "content": "from .provider import SEARCH_CONFIG\nfrom .searcher import SearchTorrent\n"
  },
  {
    "path": "backend/src/module/searcher/provider.py",
    "content": "import re\n\nfrom module.conf import SEARCH_CONFIG\nfrom module.models import RSSItem\n\n\ndef search_url(site: str, keywords: list[str]) -> RSSItem:\n    keyword = \"+\".join(keywords)\n    search_str = re.sub(r\"[\\W_ ]\", \"+\", keyword)\n    if site in SEARCH_CONFIG.keys():\n        url = re.sub(r\"%s\", search_str, SEARCH_CONFIG[site])\n        parser = \"mikan\" if site == \"mikan\" else \"tmdb\"\n        rss_item = RSSItem(\n            url=url,\n            aggregate=False,\n            parser=parser,\n        )\n        return rss_item\n    else:\n        raise ValueError(f\"Site {site} is not supported\")\n"
  },
  {
    "path": "backend/src/module/searcher/searcher.py",
    "content": "import json\nimport logging\nfrom typing import TypeAlias\n\nfrom module.models import Bangumi, RSSItem, Torrent\nfrom module.network import RequestContent\nfrom module.parser.analyser.tmdb_parser import tmdb_parser\nfrom module.rss import RSSAnalyser\n\nfrom .provider import search_url\n\nlogger = logging.getLogger(__name__)\n\nSEARCH_KEY = [\n    \"group_name\",\n    \"title_raw\",\n    \"season_raw\",\n    \"subtitle\",\n    \"source\",\n    \"dpi\",\n]\n\nBangumiJSON: TypeAlias = str\n\n# Cache for TMDB poster lookups by official_title\n_poster_cache: dict[str, str | None] = {}\n\n\nclass SearchTorrent(RequestContent, RSSAnalyser):\n    async def search_torrents(self, rss_item: RSSItem) -> list[Torrent]:\n        return await self.get_torrents(rss_item.url)\n\n    async def _fetch_tmdb_poster(self, title: str) -> str | None:\n        \"\"\"Fetch poster from TMDB if not in cache.\"\"\"\n        if title in _poster_cache:\n            return _poster_cache[title]\n\n        try:\n            tmdb_info = await tmdb_parser(title, \"zh\", test=True)\n            if tmdb_info and tmdb_info.poster_link:\n                _poster_cache[title] = tmdb_info.poster_link\n                return tmdb_info.poster_link\n        except Exception as e:\n            logger.debug(\"[Searcher] Failed to fetch TMDB poster for %s: %s\", title, e)\n\n        _poster_cache[title] = None\n        return None\n\n    async def analyse_keyword(\n        self, keywords: list[str], site: str = \"mikan\", limit: int = 100\n    ):\n        rss_item = search_url(site, keywords)\n        torrents = await self.search_torrents(rss_item)\n        # yield for EventSourceResponse (Server Send)\n        exist_list = []\n        for torrent in torrents:\n            if len(exist_list) >= limit:\n                break\n            bangumi = await self.torrent_to_data(torrent=torrent, rss=rss_item)\n            if bangumi:\n                special_link = self.special_url(bangumi, site).url\n                if special_link not in exist_list:\n                    bangumi.rss_link = special_link\n                    exist_list.append(special_link)\n                    # Fetch poster from TMDB if missing\n                    if not bangumi.poster_link and bangumi.official_title:\n                        tmdb_poster = await self._fetch_tmdb_poster(bangumi.official_title)\n                        if tmdb_poster:\n                            bangumi.poster_link = tmdb_poster\n                    yield json.dumps(bangumi.dict(), separators=(\",\", \":\"))\n\n    @staticmethod\n    def special_url(data: Bangumi, site: str) -> RSSItem:\n        keywords = [getattr(data, key) for key in SEARCH_KEY if getattr(data, key)]\n        url = search_url(site, keywords)\n        return url\n\n    async def search_season(self, data: Bangumi, site: str = \"mikan\") -> list[Torrent]:\n        rss_item = self.special_url(data, site)\n        torrents = await self.search_torrents(rss_item)\n        return [torrent for torrent in torrents if data.title_raw in torrent.name]\n"
  },
  {
    "path": "backend/src/module/security/__init__.py",
    "content": ""
  },
  {
    "path": "backend/src/module/security/api.py",
    "content": "from datetime import datetime\n\nfrom fastapi import Cookie, Depends, HTTPException, Request, status\nfrom fastapi.security import OAuth2PasswordBearer\n\nfrom module.conf import settings\nfrom module.database import Database\nfrom module.mcp.security import _is_allowed\nfrom module.models.user import User, UserUpdate\n\nfrom .jwt import verify_token\n\noauth2_scheme = OAuth2PasswordBearer(tokenUrl=\"/api/v1/auth/login\")\n\nactive_user: dict[str, datetime] = {}\n\ntry:\n    from module.__version__ import VERSION\nexcept ImportError:\n    VERSION = \"DEV_VERSION\"\n\nDEV_AUTH_BYPASS = VERSION == \"DEV_VERSION\"\n\n\ndef check_login_ip(request: Request):\n    \"\"\"Dependency that enforces login IP whitelist.\n\n    If ``settings.security.login_whitelist`` is empty, all IPs are allowed.\n    \"\"\"\n    whitelist = settings.security.login_whitelist\n    if not whitelist:\n        return\n    client_host = request.client.host if request.client else None\n    if not client_host or not _is_allowed(client_host, whitelist):\n        raise HTTPException(\n            status_code=status.HTTP_403_FORBIDDEN,\n            detail=\"IP not in login whitelist\",\n        )\n\n\nasync def get_current_user(request: Request, token: str = Cookie(None)):\n    \"\"\"FastAPI dependency that validates the current session.\n\n    Accepts authentication via (in order of precedence):\n    1. DEV_AUTH_BYPASS when running as DEV_VERSION.\n    2. ``Authorization: Bearer <token>`` header matching ``login_tokens``.\n    3. HttpOnly ``token`` cookie containing a valid JWT with an active session.\n    \"\"\"\n    if DEV_AUTH_BYPASS:\n        return \"dev_user\"\n    # Check bearer token bypass\n    auth_header = request.headers.get(\"authorization\", \"\")\n    if auth_header.startswith(\"Bearer \"):\n        api_token = auth_header[7:]\n        if api_token and api_token in settings.security.login_tokens:\n            return \"api_token_user\"\n    if not token:\n        raise UNAUTHORIZED\n    payload = verify_token(token)\n    username = payload.get(\"sub\") if payload else None\n    if not username or username not in active_user:\n        raise UNAUTHORIZED\n    return username\n\n\nasync def get_token_data(token: str = Depends(oauth2_scheme)):\n    \"\"\"FastAPI dependency that decodes and returns the OAuth2 bearer token payload.\"\"\"\n    payload = verify_token(token)\n    if not payload:\n        raise HTTPException(\n            status_code=status.HTTP_401_UNAUTHORIZED, detail=\"invalid token\"\n        )\n    return payload\n\n\ndef update_user_info(user_data: UserUpdate, current_user):\n    \"\"\"Persist updated credentials for *current_user* to the database.\"\"\"\n    try:\n        with Database() as db:\n            db.user.update_user(current_user, user_data)\n        return True\n    except Exception as e:\n        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))\n\n\ndef auth_user(user: User):\n    \"\"\"Verify credentials and register the user in ``active_user`` on success.\"\"\"\n    with Database() as db:\n        resp = db.user.auth_user(user)\n        if resp.status:\n            active_user[user.username] = datetime.now()\n        return resp\n\n\nUNAUTHORIZED = HTTPException(\n    status_code=status.HTTP_401_UNAUTHORIZED, detail=\"Unauthorized\"\n)\n"
  },
  {
    "path": "backend/src/module/security/auth_strategy.py",
    "content": "\"\"\"\n认证策略抽象层\n将密码认证和 Passkey 认证统一为策略模式\n\"\"\"\nfrom abc import ABC, abstractmethod\n\nfrom sqlmodel import select\n\nfrom module.database.engine import async_session_factory\nfrom module.database.passkey import PasskeyDatabase\nfrom module.models import ResponseModel\nfrom module.models.user import User\n\n\nclass AuthStrategy(ABC):\n    \"\"\"认证策略基类\"\"\"\n\n    @abstractmethod\n    async def authenticate(\n        self, username: str | None, credential: dict\n    ) -> ResponseModel:\n        \"\"\"\n        执行认证\n\n        Args:\n            username: 用户名（可选，用于可发现凭证模式）\n            credential: 认证凭证（密码或 WebAuthn 响应）\n\n        Returns:\n            ResponseModel with status and user info\n        \"\"\"\n        pass\n\n\nclass PasskeyAuthStrategy(AuthStrategy):\n    \"\"\"Passkey 认证策略\"\"\"\n\n    def __init__(self, webauthn_service):\n        self.webauthn_service = webauthn_service\n\n    async def authenticate(\n        self, username: str | None, credential: dict\n    ) -> ResponseModel:\n        \"\"\"\n        使用 WebAuthn Passkey 认证\n\n        Args:\n            username: 用户名（可选）。如果为 None，使用可发现凭证模式\n            credential: WebAuthn 凭证响应\n        \"\"\"\n        async with async_session_factory() as session:\n            passkey_db = PasskeyDatabase(session)\n\n            # 1. 提取 credential_id\n            try:\n                raw_id = credential.get(\"rawId\")\n                if not raw_id:\n                    raise ValueError(\"Missing credential ID\")\n\n                credential_id_str = self.webauthn_service.base64url_encode(\n                    self.webauthn_service.base64url_decode(raw_id)\n                )\n            except Exception:\n                return ResponseModel(\n                    status_code=401,\n                    status=False,\n                    msg_en=\"Invalid passkey credential\",\n                    msg_zh=\"Passkey 凭证无效\",\n                )\n\n            # 2. 查找 passkey\n            passkey = await passkey_db.get_passkey_by_credential_id(credential_id_str)\n            if not passkey:\n                return ResponseModel(\n                    status_code=401,\n                    status=False,\n                    msg_en=\"Passkey not found\",\n                    msg_zh=\"未找到 Passkey\",\n                )\n\n            # 3. 获取用户\n            result = await session.execute(\n                select(User).where(User.id == passkey.user_id)\n            )\n            user = result.scalar_one_or_none()\n            if not user:\n                return ResponseModel(\n                    status_code=401,\n                    status=False,\n                    msg_en=\"User not found\",\n                    msg_zh=\"用户不存在\",\n                )\n\n            # 4. 如果提供了 username，验证一致性\n            if username and user.username != username:\n                return ResponseModel(\n                    status_code=401,\n                    status=False,\n                    msg_en=\"Passkey does not belong to specified user\",\n                    msg_zh=\"Passkey 不属于指定用户\",\n                )\n\n            # 5. 验证 WebAuthn 签名\n            try:\n                if username:\n                    # Username-based mode\n                    new_sign_count = self.webauthn_service.verify_authentication(\n                        username, credential, passkey\n                    )\n                else:\n                    # Discoverable credentials mode\n                    new_sign_count = (\n                        self.webauthn_service.verify_discoverable_authentication(\n                            credential, passkey\n                        )\n                    )\n\n                # 6. 更新使用记录\n                await passkey_db.update_passkey_usage(passkey, new_sign_count)\n\n                return ResponseModel(\n                    status_code=200,\n                    status=True,\n                    msg_en=\"Login successfully with passkey\",\n                    msg_zh=\"通过 Passkey 登录成功\",\n                    data={\"username\": user.username},\n                )\n\n            except ValueError as e:\n                return ResponseModel(\n                    status_code=401,\n                    status=False,\n                    msg_en=f\"Passkey verification failed: {str(e)}\",\n                    msg_zh=f\"Passkey 验证失败: {str(e)}\",\n                )\n"
  },
  {
    "path": "backend/src/module/security/jwt.py",
    "content": "import secrets\nfrom datetime import datetime, timedelta, timezone\nfrom pathlib import Path\n\nfrom jose import JWTError, jwt\nfrom passlib.context import CryptContext\n\n_SECRET_PATH = Path(\"config/.jwt_secret\")\n\n\ndef _load_or_create_secret() -> str:\n    if _SECRET_PATH.exists():\n        return _SECRET_PATH.read_text().strip()\n    secret = secrets.token_hex(32)\n    _SECRET_PATH.parent.mkdir(parents=True, exist_ok=True)\n    _SECRET_PATH.write_text(secret)\n    return secret\n\n\napp_pwd_key = _load_or_create_secret()\napp_pwd_algorithm = \"HS256\"\n\n# Hashing 密码\napp_pwd_context = CryptContext(schemes=[\"bcrypt\"], deprecated=\"auto\")\n\n\n# 创建 JWT Token\ndef create_access_token(data: dict, expires_delta: timedelta | None = None):\n    to_encode = data.copy()\n    if expires_delta:\n        expire = datetime.now(timezone.utc) + expires_delta\n    else:\n        expire = datetime.now(timezone.utc) + timedelta(minutes=1440)\n    to_encode.update({\"exp\": expire})\n    encoded_jwt = jwt.encode(to_encode, app_pwd_key, algorithm=app_pwd_algorithm)\n    return encoded_jwt\n\n\n# 解码 Token\ndef decode_token(token: str):\n    try:\n        payload = jwt.decode(token, app_pwd_key, algorithms=[app_pwd_algorithm])\n        username = payload.get(\"sub\")\n        if username is None:\n            return None\n        return payload\n    except JWTError:\n        return None\n\n\ndef verify_token(token: str):\n    token_data = decode_token(token)\n    if token_data is None:\n        return None\n    expires = token_data.get(\"exp\")\n    if datetime.now(timezone.utc) >= datetime.fromtimestamp(expires, tz=timezone.utc):\n        raise JWTError(\"Token expired\")\n    return token_data\n\n\n# 密码加密&验证\ndef verify_password(plain_password, hashed_password):\n    return app_pwd_context.verify(plain_password, hashed_password)\n\n\ndef get_password_hash(password):\n    return app_pwd_context.hash(password)\n"
  },
  {
    "path": "backend/src/module/security/webauthn.py",
    "content": "\"\"\"\nWebAuthn 认证服务层\n封装 py_webauthn 库的复杂性，提供清晰的注册和认证接口\n\"\"\"\n\nimport base64\nimport json\nimport logging\nimport time\nfrom typing import List, Optional\n\nfrom webauthn import (\n    generate_authentication_options,\n    generate_registration_options,\n    options_to_json,\n    verify_authentication_response,\n    verify_registration_response,\n)\nfrom webauthn.helpers.cose import COSEAlgorithmIdentifier\nfrom webauthn.helpers.structs import (\n    AuthenticatorSelectionCriteria,\n    AuthenticatorTransport,\n    CredentialDeviceType,\n    PublicKeyCredentialDescriptor,\n    PublicKeyCredentialType,\n    ResidentKeyRequirement,\n    UserVerificationRequirement,\n)\n\nfrom module.models.passkey import Passkey\n\nlogger = logging.getLogger(__name__)\n\n\nclass WebAuthnService:\n    \"\"\"WebAuthn 核心业务逻辑\"\"\"\n\n    def __init__(self, rp_id: str, rp_name: str, origin: str):\n        \"\"\"\n        Args:\n            rp_id: 依赖方 ID (e.g., \"localhost\" or \"autobangumi.example.com\")\n            rp_name: 依赖方名称 (e.g., \"AutoBangumi\")\n            origin: 前端 origin (e.g., \"http://localhost:5173\")\n        \"\"\"\n        self.rp_id = rp_id\n        self.rp_name = rp_name\n        self.origin = origin\n\n        self._CHALLENGE_TTL = 300\n        self._CHALLENGE_MAX = 100\n        # Keyed by base64url-encoded challenge value -> (challenge_bytes, created_at, logical_key)\n        self._challenges: dict[str, tuple[bytes, float, str]] = {}\n\n    def _cleanup_expired(self) -> None:\n        now = time.time()\n        expired = [\n            k\n            for k, (_, ts, _) in self._challenges.items()\n            if now - ts > self._CHALLENGE_TTL\n        ]\n        for k in expired:\n            del self._challenges[k]\n\n    def _store_challenge(self, logical_key: str, challenge: bytes) -> None:\n        self._cleanup_expired()\n        if len(self._challenges) >= self._CHALLENGE_MAX:\n            oldest = min(self._challenges, key=lambda k: self._challenges[k][1])\n            del self._challenges[oldest]\n        b64key = self.base64url_encode(challenge)\n        self._challenges[b64key] = (challenge, time.time(), logical_key)\n\n    def _pop_challenge_by_key(self, logical_key: str) -> bytes | None:\n        self._cleanup_expired()\n        for b64key, (challenge, _, lk) in list(self._challenges.items()):\n            if lk == logical_key:\n                del self._challenges[b64key]\n                return challenge\n        return None\n\n    def _pop_challenge_by_value(self, challenge: bytes) -> bytes | None:\n        self._cleanup_expired()\n        b64key = self.base64url_encode(challenge)\n        entry = self._challenges.pop(b64key, None)\n        if entry:\n            return entry[0]\n        return None\n\n    # ============ 注册流程 ============\n\n    def generate_registration_options(\n        self, username: str, user_id: int, existing_passkeys: List[Passkey]\n    ) -> dict:\n        \"\"\"\n        生成 WebAuthn 注册选项\n\n        Args:\n            username: 用户名\n            user_id: 用户 ID（转为 bytes）\n            existing_passkeys: 用户已有的 Passkey（用于排除）\n\n        Returns:\n            JSON-serializable registration options\n        \"\"\"\n        # 将已有凭证转为排除列表\n        exclude_credentials = [\n            PublicKeyCredentialDescriptor(\n                id=self.base64url_decode(pk.credential_id),\n                type=PublicKeyCredentialType.PUBLIC_KEY,\n                transports=self._parse_transports(pk.transports),\n            )\n            for pk in existing_passkeys\n        ]\n\n        options = generate_registration_options(\n            rp_id=self.rp_id,\n            rp_name=self.rp_name,\n            user_id=str(user_id).encode(\"utf-8\"),\n            user_name=username,\n            user_display_name=username,\n            exclude_credentials=exclude_credentials if exclude_credentials else None,\n            authenticator_selection=AuthenticatorSelectionCriteria(\n                resident_key=ResidentKeyRequirement.REQUIRED,  # Required for usernameless login\n                user_verification=UserVerificationRequirement.PREFERRED,\n            ),\n            supported_pub_key_algs=[\n                COSEAlgorithmIdentifier.ECDSA_SHA_256,  # -7: ES256\n                COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256,  # -257: RS256\n            ],\n        )\n\n        self._store_challenge(f\"reg_{username}\", options.challenge)\n        logger.debug(\"Generated registration challenge for %s\", username)\n\n        return json.loads(options_to_json(options))\n\n    def verify_registration(\n        self, username: str, credential: dict, device_name: str\n    ) -> Passkey:\n        \"\"\"\n        验证注册响应并创建 Passkey 对象\n\n        Args:\n            username: 用户名\n            credential: 来自前端的 credential 响应\n            device_name: 用户输入的设备名称\n\n        Returns:\n            Passkey 对象（未保存到数据库）\n\n        Raises:\n            ValueError: 验证失败\n        \"\"\"\n        expected_challenge = self._pop_challenge_by_key(f\"reg_{username}\")\n        if not expected_challenge:\n            raise ValueError(\"Challenge not found or expired\")\n\n        try:\n            verification = verify_registration_response(\n                credential=credential,\n                expected_challenge=expected_challenge,\n                expected_rp_id=self.rp_id,\n                expected_origin=self.origin,\n            )\n\n            # 构造 Passkey 对象\n            passkey = Passkey(\n                user_id=0,  # 调用方设置\n                name=device_name,\n                credential_id=self.base64url_encode(verification.credential_id),\n                public_key=base64.b64encode(verification.credential_public_key).decode(\n                    \"utf-8\"\n                ),\n                sign_count=verification.sign_count,\n                aaguid=verification.aaguid if verification.aaguid else None,\n                backup_eligible=verification.credential_device_type\n                == CredentialDeviceType.MULTI_DEVICE,\n                backup_state=verification.credential_backed_up,\n            )\n\n            logger.info(\n                f\"Successfully verified registration for {username}, device: {device_name}\"\n            )\n            return passkey\n\n        except Exception as e:\n            logger.error(f\"Registration verification failed: {e}\")\n            raise ValueError(f\"Invalid registration response: {str(e)}\")\n\n    # ============ 认证流程 ============\n\n    def generate_authentication_options(\n        self, username: str, passkeys: List[Passkey]\n    ) -> dict:\n        \"\"\"\n        生成 WebAuthn 认证选项\n\n        Args:\n            username: 用户名\n            passkeys: 用户的 Passkey 列表（限定可用凭证）\n\n        Returns:\n            JSON-serializable authentication options\n        \"\"\"\n        allow_credentials = [\n            PublicKeyCredentialDescriptor(\n                id=self.base64url_decode(pk.credential_id),\n                type=PublicKeyCredentialType.PUBLIC_KEY,\n                transports=self._parse_transports(pk.transports),\n            )\n            for pk in passkeys\n        ]\n\n        options = generate_authentication_options(\n            rp_id=self.rp_id,\n            allow_credentials=allow_credentials if allow_credentials else None,\n            user_verification=UserVerificationRequirement.PREFERRED,\n        )\n\n        self._store_challenge(f\"auth_{username}\", options.challenge)\n        logger.debug(\"Generated authentication challenge for %s\", username)\n\n        return json.loads(options_to_json(options))\n\n    def generate_discoverable_authentication_options(self) -> dict:\n        \"\"\"\n        生成可发现凭证的认证选项（无需用户名）\n\n        Returns:\n            JSON-serializable authentication options without allowCredentials\n        \"\"\"\n        options = generate_authentication_options(\n            rp_id=self.rp_id,\n            allow_credentials=None,  # Empty = discoverable credentials mode\n            user_verification=UserVerificationRequirement.PREFERRED,\n        )\n\n        self._store_challenge(\n            f\"auth_discoverable_{self.base64url_encode(options.challenge)[:16]}\",\n            options.challenge,\n        )\n        logger.debug(\"Generated discoverable authentication challenge\")\n\n        return json.loads(options_to_json(options))\n\n    def verify_authentication(\n        self, username: str, credential: dict, passkey: Passkey\n    ) -> int:\n        \"\"\"\n        验证认证响应\n\n        Args:\n            username: 用户名\n            credential: 来自前端的 credential 响应\n            passkey: 对应的 Passkey 对象\n\n        Returns:\n            新的 sign_count（用于更新数据库）\n\n        Raises:\n            ValueError: 验证失败\n        \"\"\"\n        expected_challenge = self._pop_challenge_by_key(f\"auth_{username}\")\n        if not expected_challenge:\n            raise ValueError(\"Challenge not found or expired\")\n\n        try:\n            credential_public_key = base64.b64decode(passkey.public_key)\n\n            verification = verify_authentication_response(\n                credential=credential,\n                expected_challenge=expected_challenge,\n                expected_rp_id=self.rp_id,\n                expected_origin=self.origin,\n                credential_public_key=credential_public_key,\n                credential_current_sign_count=passkey.sign_count,\n            )\n\n            logger.info(f\"Successfully verified authentication for {username}\")\n            return verification.new_sign_count\n\n        except Exception as e:\n            logger.error(f\"Authentication verification failed: {e}\")\n            raise ValueError(f\"Invalid authentication response: {str(e)}\")\n\n    def verify_discoverable_authentication(\n        self, credential: dict, passkey: Passkey\n    ) -> int:\n        \"\"\"\n        验证可发现凭证的认证响应（无需用户名）\n\n        Args:\n            credential: 来自前端的 credential 响应\n            passkey: 通过 credential_id 查找到的 Passkey 对象\n\n        Returns:\n            新的 sign_count\n\n        Raises:\n            ValueError: 验证失败\n        \"\"\"\n        # Try all discoverable challenges to find the matching one\n        expected_challenge = None\n        for b64key, (challenge, _, lk) in list(self._challenges.items()):\n            if lk.startswith(\"auth_discoverable_\"):\n                expected_challenge = challenge\n                del self._challenges[b64key]\n                break\n\n        if not expected_challenge:\n            raise ValueError(\"Challenge not found or expired\")\n\n        try:\n            credential_public_key = base64.b64decode(passkey.public_key)\n\n            verification = verify_authentication_response(\n                credential=credential,\n                expected_challenge=expected_challenge,\n                expected_rp_id=self.rp_id,\n                expected_origin=self.origin,\n                credential_public_key=credential_public_key,\n                credential_current_sign_count=passkey.sign_count,\n            )\n\n            logger.info(\"Successfully verified discoverable authentication\")\n            return verification.new_sign_count\n\n        except Exception as e:\n            logger.error(f\"Discoverable authentication verification failed: {e}\")\n            raise ValueError(f\"Invalid authentication response: {str(e)}\")\n\n    # ============ 辅助方法 ============\n\n    def _parse_transports(\n        self, transports_json: Optional[str]\n    ) -> List[AuthenticatorTransport]:\n        \"\"\"解析存储的 transports JSON\"\"\"\n        if not transports_json:\n            return []\n        try:\n            transport_strings = json.loads(transports_json)\n            return [AuthenticatorTransport(t) for t in transport_strings]\n        except Exception:\n            return []\n\n    def base64url_encode(self, data: bytes) -> str:\n        \"\"\"Base64URL 编码（无 padding）\"\"\"\n        return base64.urlsafe_b64encode(data).decode(\"utf-8\").rstrip(\"=\")\n\n    def base64url_decode(self, data: str) -> bytes:\n        \"\"\"Base64URL 解码（补齐 padding）\"\"\"\n        padding = 4 - len(data) % 4\n        if padding != 4:\n            data += \"=\" * padding\n        return base64.urlsafe_b64decode(data)\n\n\n# 全局 WebAuthn 服务实例存储\n_webauthn_services: dict[str, WebAuthnService] = {}\n\n\ndef get_webauthn_service(rp_id: str, rp_name: str, origin: str) -> WebAuthnService:\n    \"\"\"\n    获取或创建 WebAuthnService 实例\n    使用缓存以保持 challenge 状态\n    \"\"\"\n    key = f\"{rp_id}:{origin}\"\n    if key not in _webauthn_services:\n        _webauthn_services[key] = WebAuthnService(rp_id, rp_name, origin)\n    return _webauthn_services[key]\n"
  },
  {
    "path": "backend/src/module/update/__init__.py",
    "content": "from .cross_version import cache_image, from_30_to_31, from_31_to_32, run_migrations\nfrom .data_migration import data_migration\nfrom .startup import first_run, start_up\nfrom .version_check import version_check\n"
  },
  {
    "path": "backend/src/module/update/cross_version.py",
    "content": "import logging\nimport re\n\nfrom urllib3.util import parse_url\n\nfrom module.network import RequestContent\nfrom module.rss import RSSEngine\nfrom module.utils import save_image\n\nlogger = logging.getLogger(__name__)\n\n\nasync def from_30_to_31():\n    with RSSEngine() as db:\n        db.migrate()\n        # Update poster link\n        bangumis = db.bangumi.search_all()\n        rss_pool = []\n        for bangumi in bangumis:\n            if bangumi.poster_link:\n                rss_link = bangumi.rss_link.split(\",\")[-1]\n                if rss_link not in rss_pool and not re.search(\n                    r\"\\d+.\\d+.\\d+.\\d+\", rss_link\n                ):\n                    rss_pool.append(rss_link)\n                root_path = parse_url(rss_link).host\n                if \"://\" not in bangumi.poster_link:\n                    bangumi.poster_link = f\"https://{root_path}{bangumi.poster_link}\"\n        db.bangumi.update_all(bangumis)\n        for rss in rss_pool:\n            if \"mybangumi\" in rss.lower():\n                aggregate = True\n            else:\n                aggregate = False\n            await db.add_rss(rss_link=rss, aggregate=aggregate)\n\n\nasync def from_31_to_32():\n    \"\"\"Migrate database schema from 3.1.x to 3.2.x.\"\"\"\n    with RSSEngine() as db:\n        db.create_table()\n        db.run_migrations()\n    logger.info(\"[Migration] 3.1 -> 3.2 migration completed.\")\n\n\ndef run_migrations():\n    \"\"\"Check schema version and run any pending migrations.\"\"\"\n    with RSSEngine() as db:\n        db.run_migrations()\n\n\nasync def cache_image():\n    with RSSEngine() as db:\n        bangumis = db.bangumi.search_all()\n        async with RequestContent() as req:\n            for bangumi in bangumis:\n                if bangumi.poster_link:\n                    # Hash local path\n                    img = await req.get_content(bangumi.poster_link)\n                    suffix = bangumi.poster_link.split(\".\")[-1]\n                    img_path = save_image(img, suffix)\n                    bangumi.poster_link = img_path\n        db.bangumi.update_all(bangumis)\n"
  },
  {
    "path": "backend/src/module/update/data_migration.py",
    "content": "from module.conf import LEGACY_DATA_PATH\nfrom module.models import Bangumi\nfrom module.rss import RSSEngine\nfrom module.utils import json_config\n\n\ndef data_migration():\n    if not LEGACY_DATA_PATH.exists():\n        return False\n    old_data = json_config.load(LEGACY_DATA_PATH)\n    infos = old_data[\"bangumi_info\"]\n    rss_link = old_data[\"rss_link\"]\n    new_data = []\n    for info in infos:\n        new_data.append(Bangumi(**info, rss_link=rss_link))\n    with RSSEngine() as engine:\n        engine.bangumi.add_all(new_data)\n        engine.add_rss(rss_link)\n    LEGACY_DATA_PATH.unlink(missing_ok=True)\n\n\ndef database_migration():\n    with RSSEngine() as engine:\n        engine.migrate()\n"
  },
  {
    "path": "backend/src/module/update/rss.py",
    "content": "from module.rss import RSSEngine\n\n\ndef update_main_rss(rss_link: str):\n    with RSSEngine() as engine:\n        engine.add_rss(rss_link, \"main\", True)\n"
  },
  {
    "path": "backend/src/module/update/startup.py",
    "content": "import logging\n\nfrom module.conf import POSTERS_PATH\nfrom module.rss import RSSEngine\n\nlogger = logging.getLogger(__name__)\n\n\ndef start_up():\n    with RSSEngine() as engine:\n        engine.create_table()\n        engine.run_migrations()\n        engine.user.add_default_user()\n\n\ndef first_run():\n    with RSSEngine() as engine:\n        engine.create_table()\n        engine.run_migrations()\n        engine.user.add_default_user()\n    POSTERS_PATH.mkdir(parents=True, exist_ok=True)\n"
  },
  {
    "path": "backend/src/module/update/version_check.py",
    "content": "import semver\n\nfrom module.conf import VERSION, VERSION_PATH\n\n\ndef version_check() -> tuple[bool, int | None]:\n    \"\"\"Check if version has changed.\n\n    Returns:\n        A tuple of (is_same_version, last_minor_version).\n        last_minor_version is None if no upgrade is needed.\n    \"\"\"\n    if VERSION == \"DEV_VERSION\":\n        return True, None\n    if VERSION == \"local\":\n        return True, None\n    if not VERSION_PATH.exists():\n        with open(VERSION_PATH, \"w\") as f:\n            f.write(VERSION + \"\\n\")\n        return False, None\n    else:\n        with open(VERSION_PATH, \"r+\") as f:\n            # Read last version\n            versions = f.readlines()\n            last_version = versions[-1].strip()\n            last_ver = semver.VersionInfo.parse(last_version)\n            now_ver = semver.VersionInfo.parse(VERSION)\n            if now_ver.minor == last_ver.minor:\n                return True, None\n            else:\n                if now_ver.minor > last_ver.minor:\n                    f.write(VERSION + \"\\n\")\n                    return False, last_ver.minor\n                else:\n                    return True, None\n"
  },
  {
    "path": "backend/src/module/utils/__init__.py",
    "content": "from .cache_image import save_image, load_image"
  },
  {
    "path": "backend/src/module/utils/bangumi_data.py",
    "content": "import logging\n\nlogger = logging.getLogger(__name__)\n"
  },
  {
    "path": "backend/src/module/utils/cache_image.py",
    "content": "import hashlib\n\n\ndef save_image(img, suffix):\n    img_hash = hashlib.md5(img).hexdigest()[0:8]\n    image_path = f\"data/posters/{img_hash}.{suffix}\"\n    with open(image_path, \"wb\") as f:\n        f.write(img)\n    return f\"posters/{img_hash}.{suffix}\"\n\n\ndef load_image(img_path):\n    if img_path:\n        with open(f\"data/{img_path}\", \"rb\") as f:\n            return f.read()\n    else:\n        return None\n"
  },
  {
    "path": "backend/src/module/utils/json_config.py",
    "content": "import json\n\nimport httpx\n\n\ndef load(filename):\n    with open(filename, \"r\", encoding=\"utf-8\") as f:\n        return json.load(f)\n\n\ndef save(filename, obj):\n    with open(filename, \"w\", encoding=\"utf-8\") as f:\n        json.dump(obj, f, indent=4, separators=(\",\", \": \"), ensure_ascii=False)\n\n\nasync def get(url):\n    async with httpx.AsyncClient() as client:\n        req = await client.get(url)\n        return req.json()\n"
  },
  {
    "path": "backend/src/test/__init__.py",
    "content": "import module\n"
  },
  {
    "path": "backend/src/test/conftest.py",
    "content": "\"\"\"Shared test fixtures for AutoBangumi test suite.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\nfrom sqlmodel import Session, SQLModel, create_engine\n\nfrom module.api import v1\nfrom module.database.bangumi import _invalidate_bangumi_cache\nfrom module.models.config import Config\nfrom module.models import ResponseModel\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Database Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(autouse=True)\ndef _clear_bangumi_cache():\n    \"\"\"Invalidate the module-level bangumi cache before each test.\n\n    The BangumiDatabase.search_all() uses a module-level TTL cache that\n    persists across tests using different in-memory databases, causing\n    stale results.\n    \"\"\"\n    _invalidate_bangumi_cache()\n    yield\n    _invalidate_bangumi_cache()\n\n\n@pytest.fixture\ndef db_engine():\n    \"\"\"Create an in-memory SQLite engine for testing.\"\"\"\n    engine = create_engine(\"sqlite://\", echo=False)\n    SQLModel.metadata.create_all(engine)\n    yield engine\n    SQLModel.metadata.drop_all(engine)\n\n\n@pytest.fixture\ndef db_session(db_engine):\n    \"\"\"Provide a fresh database session per test.\"\"\"\n    with Session(db_engine) as session:\n        yield session\n\n\n# ---------------------------------------------------------------------------\n# Settings Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef test_settings():\n    \"\"\"Provide a Config object with predictable test defaults.\"\"\"\n    return Config()\n\n\n@pytest.fixture\ndef mock_settings(test_settings):\n    \"\"\"Patch module.conf.settings globally with test defaults.\"\"\"\n    with patch(\"module.conf.settings\", test_settings):\n        with patch(\"module.conf.config.settings\", test_settings):\n            yield test_settings\n\n\n# ---------------------------------------------------------------------------\n# Download Client Mock\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef mock_qb_client():\n    \"\"\"Mock QbDownloader that simulates qBittorrent API responses.\"\"\"\n    client = AsyncMock()\n    client.auth.return_value = True\n    client.logout.return_value = None\n    client.check_host.return_value = True\n    client.torrents_info.return_value = []\n    client.torrents_files.return_value = []\n    client.torrents_rename_file.return_value = True\n    client.add_torrents.return_value = True\n    client.torrents_delete.return_value = None\n    client.torrents_pause.return_value = None\n    client.torrents_resume.return_value = None\n    client.rss_set_rule.return_value = None\n    client.prefs_init.return_value = None\n    client.add_category.return_value = None\n    client.get_app_prefs.return_value = {\"save_path\": \"/downloads\"}\n    client.move_torrent.return_value = None\n    client.rss_add_feed.return_value = None\n    client.rss_remove_item.return_value = None\n    client.rss_get_feeds.return_value = {}\n    client.get_download_rule.return_value = {}\n    client.get_torrent_path.return_value = \"/downloads/Bangumi\"\n    client.set_category.return_value = None\n    client.remove_rule.return_value = None\n    return client\n\n\n# ---------------------------------------------------------------------------\n# FastAPI App & Client Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Program Mock\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef mock_program():\n    \"\"\"Mock Program instance for program API tests.\"\"\"\n    program = MagicMock()\n    program.is_running = True\n    program.first_run = False\n    program.startup = AsyncMock(return_value=None)\n    program.start = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Started.\", msg_zh=\"已启动。\"\n        )\n    )\n    program.stop = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Stopped.\", msg_zh=\"已停止。\"\n        )\n    )\n    program.restart = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Restarted.\", msg_zh=\"已重启。\"\n        )\n    )\n    program.check_downloader = AsyncMock(return_value=True)\n    return program\n\n\n# ---------------------------------------------------------------------------\n# WebAuthn Mock\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef mock_webauthn():\n    \"\"\"Mock WebAuthn service for passkey tests.\"\"\"\n    service = MagicMock()\n    service.generate_registration_options.return_value = {\n        \"challenge\": \"test_challenge\",\n        \"rp\": {\"name\": \"AutoBangumi\", \"id\": \"localhost\"},\n        \"user\": {\"id\": \"user_id\", \"name\": \"testuser\", \"displayName\": \"testuser\"},\n        \"pubKeyCredParams\": [{\"type\": \"public-key\", \"alg\": -7}],\n        \"timeout\": 60000,\n        \"attestation\": \"none\",\n    }\n    service.generate_authentication_options.return_value = {\n        \"challenge\": \"test_challenge\",\n        \"timeout\": 60000,\n        \"rpId\": \"localhost\",\n        \"allowCredentials\": [],\n    }\n    service.generate_discoverable_authentication_options.return_value = {\n        \"challenge\": \"test_challenge\",\n        \"timeout\": 60000,\n        \"rpId\": \"localhost\",\n    }\n    service.verify_registration.return_value = MagicMock(\n        credential_id=\"cred_id\",\n        public_key=\"public_key\",\n        sign_count=0,\n        name=\"Test Passkey\",\n        user_id=1,\n    )\n    service.verify_authentication.return_value = (True, 1)\n    return service\n\n\n# ---------------------------------------------------------------------------\n# Download Client Mock (async context manager version)\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef mock_download_client():\n    \"\"\"Mock DownloadClient as async context manager.\"\"\"\n    client = AsyncMock()\n    client.get_torrent_info.return_value = [\n        {\n            \"hash\": \"abc123\",\n            \"name\": \"[TestGroup] Test Anime - 01.mkv\",\n            \"state\": \"downloading\",\n            \"progress\": 0.5,\n        }\n    ]\n    client.pause_torrent.return_value = None\n    client.resume_torrent.return_value = None\n    client.delete_torrent.return_value = None\n    return client\n"
  },
  {
    "path": "backend/src/test/e2e/Dockerfile.mock-rss",
    "content": "FROM python:3.11-slim\nRUN pip install --no-cache-dir aiohttp\nCOPY mock_rss_server.py /app/\nCOPY fixtures/ /app/fixtures/\nWORKDIR /app\nEXPOSE 18888\nCMD [\"python\", \"mock_rss_server.py\"]\n"
  },
  {
    "path": "backend/src/test/e2e/__init__.py",
    "content": ""
  },
  {
    "path": "backend/src/test/e2e/conftest.py",
    "content": "\"\"\"Shared fixtures for E2E integration tests.\n\nThese tests require Docker (qBittorrent + mock RSS server) and run\nAutoBangumi as a real subprocess with isolated config/data directories.\n\nRun with:  cd backend && uv run pytest -m e2e -v\n\"\"\"\n\nimport subprocess\nimport sys\nimport time\nfrom pathlib import Path\n\nimport httpx\nimport pytest\n\n# ---------------------------------------------------------------------------\n# Auto-skip E2E tests unless explicitly selected\n# ---------------------------------------------------------------------------\n\nE2E_DIR = Path(__file__).parent\n\n\ndef pytest_collection_modifyitems(config, items):\n    \"\"\"Skip E2E tests unless -m e2e is specified.\"\"\"\n    marker_expr = config.getoption(\"-m\", default=\"\")\n    if \"e2e\" in marker_expr:\n        return\n    skip = pytest.mark.skip(reason=\"E2E tests require: pytest -m e2e\")\n    for item in items:\n        if \"e2e\" in item.keywords:\n            item.add_marker(skip)\n\n\n# ---------------------------------------------------------------------------\n# Test credentials (used in setup and login)\n# ---------------------------------------------------------------------------\n\nE2E_USERNAME = \"testadmin\"\nE2E_PASSWORD = \"testpassword123\"\n\n# ---------------------------------------------------------------------------\n# Session-scoped fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture(scope=\"session\")\ndef e2e_tmpdir(tmp_path_factory):\n    \"\"\"Session-scoped temp directory for AB config/data isolation.\"\"\"\n    return tmp_path_factory.mktemp(\"e2e\")\n\n\n@pytest.fixture(scope=\"session\")\ndef docker_services():\n    \"\"\"Start and stop Docker Compose test infrastructure.\"\"\"\n    compose_file = E2E_DIR / \"docker-compose.test.yml\"\n\n    # Build mock RSS image\n    subprocess.run(\n        [\"docker\", \"compose\", \"-f\", str(compose_file), \"build\"],\n        check=True,\n        capture_output=True,\n    )\n\n    # Start services and wait for health checks\n    subprocess.run(\n        [\"docker\", \"compose\", \"-f\", str(compose_file), \"up\", \"-d\", \"--wait\"],\n        check=True,\n        timeout=120,\n    )\n\n    yield\n\n    # Teardown\n    subprocess.run(\n        [\"docker\", \"compose\", \"-f\", str(compose_file), \"down\", \"-v\"],\n        check=True,\n        capture_output=True,\n    )\n\n\n@pytest.fixture(scope=\"session\")\ndef qb_password(docker_services):\n    \"\"\"Extract the auto-generated password from qBittorrent container logs.\"\"\"\n    for _ in range(30):\n        result = subprocess.run(\n            [\"docker\", \"logs\", \"ab-test-qbittorrent\"],\n            capture_output=True,\n            text=True,\n        )\n        for line in result.stdout.splitlines() + result.stderr.splitlines():\n            if \"temporary password\" in line.lower():\n                return line.split(\":\")[-1].strip()\n        time.sleep(2)\n    pytest.fail(\"Could not extract qBittorrent temporary password from Docker logs\")\n\n\n@pytest.fixture(scope=\"session\")\ndef ab_process(e2e_tmpdir, docker_services):\n    \"\"\"Start AutoBangumi as a subprocess with isolated config/data dirs.\n\n    Uses CWD-based isolation: main.py resolves config/ and data/ relative\n    to the working directory, so we create those dirs in a temp location\n    and run the process from there.\n    \"\"\"\n    work_dir = e2e_tmpdir / \"ab_workdir\"\n    work_dir.mkdir()\n    (work_dir / \"config\").mkdir()\n    (work_dir / \"data\").mkdir()\n\n    # main.py mounts StaticFiles for dist/assets and dist/images when\n    # VERSION != \"DEV_VERSION\".  Create dummy dirs so the mounts succeed\n    # (the E2E tests only exercise the API, not the frontend).\n    dist_dir = work_dir / \"dist\"\n    dist_dir.mkdir()\n    (dist_dir / \"assets\").mkdir()\n    (dist_dir / \"images\").mkdir()\n    # Jinja2Templates requires at least one template file\n    (dist_dir / \"index.html\").write_text(\n        \"<html><body>e2e stub</body></html>\"\n    )\n\n    # backend/src/ is the directory containing main.py and module/\n    src_dir = Path(__file__).resolve().parents[2]\n\n    proc = subprocess.Popen(\n        [sys.executable, str(src_dir / \"main.py\")],\n        cwd=str(work_dir),\n        stdout=subprocess.PIPE,\n        stderr=subprocess.PIPE,\n    )\n\n    # Wait for AutoBangumi to be ready (poll setup status endpoint)\n    ready = False\n    for _ in range(30):\n        try:\n            resp = httpx.get(\n                \"http://localhost:7892/api/v1/setup/status\", timeout=3.0\n            )\n            if resp.status_code == 200:\n                ready = True\n                break\n        except (httpx.ConnectError, httpx.ReadTimeout):\n            pass\n        time.sleep(1)\n\n    if not ready:\n        proc.terminate()\n        stdout, stderr = proc.communicate(timeout=5)\n        pytest.fail(\n            f\"AutoBangumi did not start within 30s.\\n\"\n            f\"stdout: {stdout.decode(errors='replace')[-2000:]}\\n\"\n            f\"stderr: {stderr.decode(errors='replace')[-2000:]}\"\n        )\n\n    yield proc\n\n    proc.terminate()\n    try:\n        proc.wait(timeout=10)\n    except subprocess.TimeoutExpired:\n        proc.kill()\n        proc.wait(timeout=5)\n\n\n@pytest.fixture(scope=\"session\")\ndef api_client(ab_process):\n    \"\"\"HTTP client pointing at the running AutoBangumi instance.\n\n    Maintains cookies across requests so that the auth token (set via\n    Set-Cookie on login) is automatically included in subsequent calls.\n    \"\"\"\n    with httpx.Client(base_url=\"http://localhost:7892\", timeout=10.0) as client:\n        yield client\n\n\n@pytest.fixture(scope=\"session\")\ndef e2e_state():\n    \"\"\"Mutable dict for sharing state across ordered E2E tests.\"\"\"\n    return {}\n"
  },
  {
    "path": "backend/src/test/e2e/docker-compose.test.yml",
    "content": "services:\n  qbittorrent:\n    image: linuxserver/qbittorrent:latest\n    container_name: ab-test-qbittorrent\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=UTC\n      - WEBUI_PORT=18080\n    ports:\n      - \"18080:18080\"\n    healthcheck:\n      test: [\"CMD\", \"curl\", \"-f\", \"http://localhost:18080\"]\n      interval: 5s\n      timeout: 3s\n      retries: 15\n      start_period: 10s\n    tmpfs:\n      - /config\n      - /downloads\n\n  mock-rss:\n    build:\n      context: .\n      dockerfile: Dockerfile.mock-rss\n    container_name: ab-test-mock-rss\n    ports:\n      - \"18888:18888\"\n    healthcheck:\n      test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:18888/health')\"]\n      interval: 3s\n      timeout: 2s\n      retries: 5\n      start_period: 3s\n"
  },
  {
    "path": "backend/src/test/e2e/fixtures/mikan.xml",
    "content": "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<rss version=\"2.0\">\n  <channel>\n    <title>Mikan Project - E2E Test Feed</title>\n    <link>https://mikanani.me</link>\n    <description>E2E test RSS feed for AutoBangumi</description>\n    <item>\n      <title>[Lilith-Raws] Sousou no Frieren - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]</title>\n      <link>https://mikanani.me/Home/Episode/abc001</link>\n      <enclosure url=\"magnet:?xt=urn:btih:aaaa1111bbbb2222cccc3333dddd4444eeee5555&amp;dn=Frieren+01\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-06T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[Lilith-Raws] Sousou no Frieren - 02 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]</title>\n      <link>https://mikanani.me/Home/Episode/abc002</link>\n      <enclosure url=\"magnet:?xt=urn:btih:aaaa1111bbbb2222cccc3333dddd4444eeee6666&amp;dn=Frieren+02\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-13T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[Lilith-Raws] Sousou no Frieren - 03 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]</title>\n      <link>https://mikanani.me/Home/Episode/abc003</link>\n      <enclosure url=\"magnet:?xt=urn:btih:aaaa1111bbbb2222cccc3333dddd4444eeee7777&amp;dn=Frieren+03\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-20T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[SubsPlease] Jujutsu Kaisen - 01 (1080p) [ABCD1234].mkv</title>\n      <link>https://mikanani.me/Home/Episode/def001</link>\n      <enclosure url=\"magnet:?xt=urn:btih:bbbb2222cccc3333dddd4444eeee5555ffff6666&amp;dn=JJK+01\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-07T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[SubsPlease] Jujutsu Kaisen - 02 (1080p) [EFGH5678].mkv</title>\n      <link>https://mikanani.me/Home/Episode/def002</link>\n      <enclosure url=\"magnet:?xt=urn:btih:bbbb2222cccc3333dddd4444eeee5555ffff7777&amp;dn=JJK+02\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-14T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[ANi] Spy x Family Season 2 - 01 [1080p][Baha][WEB-DL][AAC AVC][CHT]</title>\n      <link>https://mikanani.me/Home/Episode/ghi001</link>\n      <enclosure url=\"magnet:?xt=urn:btih:cccc3333dddd4444eeee5555ffff6666aaaa7777&amp;dn=SpyFamily+01\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-07T18:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[Nekomoe kissaten] Kusuriya no Hitorigoto - 01 [BDRip 1080p HEVC-10bit FLAC]</title>\n      <link>https://mikanani.me/Home/Episode/jkl001</link>\n      <enclosure url=\"magnet:?xt=urn:btih:dddd4444eeee5555ffff6666aaaa7777bbbb8888&amp;dn=Kusuriya+01\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-21T12:00:00</pubDate>\n      </torrent>\n    </item>\n    <item>\n      <title>[Nekomoe kissaten] Kusuriya no Hitorigoto - 02 [BDRip 1080p HEVC-10bit FLAC]</title>\n      <link>https://mikanani.me/Home/Episode/jkl002</link>\n      <enclosure url=\"magnet:?xt=urn:btih:dddd4444eeee5555ffff6666aaaa7777bbbb9999&amp;dn=Kusuriya+02\" type=\"application/x-bittorrent\" length=\"0\"/>\n      <torrent xmlns=\"https://mikan.ani.rip/0.1/\">\n        <pubDate>2025-10-28T12:00:00</pubDate>\n      </torrent>\n    </item>\n  </channel>\n</rss>\n"
  },
  {
    "path": "backend/src/test/e2e/mock_rss_server.py",
    "content": "\"\"\"Minimal HTTP server that serves static RSS XML fixtures.\"\"\"\n\nimport asyncio\nfrom pathlib import Path\n\nfrom aiohttp import web\n\nFIXTURES_DIR = Path(__file__).parent / \"fixtures\"\n\n\nasync def handle_rss(request: web.Request) -> web.Response:\n    feed_name = request.match_info[\"feed_name\"]\n    xml_path = FIXTURES_DIR / f\"{feed_name}.xml\"\n    if not xml_path.exists():\n        return web.Response(status=404, text=f\"Feed not found: {feed_name}\")\n    return web.Response(\n        text=xml_path.read_text(encoding=\"utf-8\"),\n        content_type=\"application/xml\",\n    )\n\n\nasync def handle_health(request: web.Request) -> web.Response:\n    return web.Response(text=\"OK\")\n\n\ndef create_app() -> web.Application:\n    app = web.Application()\n    app.router.add_get(\"/health\", handle_health)\n    app.router.add_get(\"/rss/{feed_name}.xml\", handle_rss)\n    return app\n\n\nif __name__ == \"__main__\":\n    web.run_app(create_app(), host=\"0.0.0.0\", port=18888)\n"
  },
  {
    "path": "backend/src/test/e2e/test_e2e_workflow.py",
    "content": "\"\"\"E2E integration tests for the full AutoBangumi workflow.\n\nTests are executed in definition order within the class.  Each phase\nbuilds on state created by earlier phases (setup wizard -> auth ->\nconfig -> RSS -> bangumi -> downloader -> program -> log -> search ->\nnotification -> credential update -> cleanup).\n\nPrerequisites:\n    - Docker running (qBittorrent + mock RSS containers)\n    - Port 7892 free (AutoBangumi)\n    - Port 18080 free (qBittorrent)\n    - Port 18888 free (mock RSS server)\n\nRun:\n    cd backend && uv run pytest -m e2e -v --tb=long\n\"\"\"\n\nimport httpx\nimport pytest\n\nfrom .conftest import E2E_PASSWORD, E2E_USERNAME\n\n\n@pytest.mark.e2e\nclass TestE2EWorkflow:\n    \"\"\"Full workflow test against real qBittorrent and mock RSS server.\"\"\"\n\n    # ===================================================================\n    # Phase 1: Setup Wizard\n    # ===================================================================\n\n    def test_01_setup_status_needs_setup(self, api_client):\n        \"\"\"Fresh instance should require setup.\"\"\"\n        resp = api_client.get(\"/api/v1/setup/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"need_setup\"] is True\n        assert \"version\" in data\n\n    def test_02_verify_infrastructure(self, api_client, qb_password):\n        \"\"\"Verify Docker test infrastructure is reachable.\"\"\"\n        # qBittorrent WebUI\n        qb_resp = httpx.get(\"http://localhost:18080\", timeout=5.0)\n        assert qb_resp.status_code == 200\n\n        # Mock RSS server\n        rss_resp = httpx.get(\"http://localhost:18888/health\", timeout=5.0)\n        assert rss_resp.status_code == 200\n\n        # Mock RSS feed content\n        xml_resp = httpx.get(\"http://localhost:18888/rss/mikan.xml\", timeout=5.0)\n        assert xml_resp.status_code == 200\n        assert \"<rss\" in xml_resp.text\n        assert \"Frieren\" in xml_resp.text\n\n        # qBittorrent password was extracted\n        assert qb_password, \"qBittorrent password should not be empty\"\n\n    def test_03_mock_rss_nonexistent_feed(self):\n        \"\"\"Mock RSS server returns 404 for unknown feeds.\"\"\"\n        resp = httpx.get(\"http://localhost:18888/rss/nonexistent.xml\", timeout=5.0)\n        assert resp.status_code == 404\n\n    def test_04_test_mock_downloader(self, api_client):\n        \"\"\"Setup wizard test-downloader endpoint accepts mock type.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/test-downloader\",\n            json={\n                \"type\": \"mock\",\n                \"host\": \"localhost\",\n                \"username\": \"admin\",\n                \"password\": \"admin\",\n            },\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"success\"] is True\n\n    def test_05_setup_validation_username_too_short(self, api_client):\n        \"\"\"Username < 4 chars triggers Pydantic 422.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"ab\",\n                \"password\": \"validpassword\",\n                \"downloader_type\": \"mock\",\n                \"downloader_host\": \"localhost\",\n                \"downloader_username\": \"x\",\n                \"downloader_password\": \"x\",\n                \"downloader_path\": \"/tmp\",\n            },\n        )\n        assert resp.status_code == 422\n\n    def test_06_setup_validation_password_too_short(self, api_client):\n        \"\"\"Password < 8 chars triggers Pydantic 422.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"validuser\",\n                \"password\": \"short\",\n                \"downloader_type\": \"mock\",\n                \"downloader_host\": \"localhost\",\n                \"downloader_username\": \"x\",\n                \"downloader_password\": \"x\",\n                \"downloader_path\": \"/tmp\",\n            },\n        )\n        assert resp.status_code == 422\n\n    def test_07_complete_setup(self, api_client, e2e_state):\n        \"\"\"Complete the setup wizard with mock downloader and test RSS URL.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": E2E_USERNAME,\n                \"password\": E2E_PASSWORD,\n                \"downloader_type\": \"mock\",\n                \"downloader_host\": \"localhost:18080\",\n                \"downloader_username\": \"admin\",\n                \"downloader_password\": \"admin\",\n                \"downloader_path\": \"/downloads/Bangumi\",\n                \"downloader_ssl\": False,\n                \"rss_url\": \"http://localhost:18888/rss/mikan.xml\",\n                \"rss_name\": \"Test Mikan Feed\",\n            },\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"status\"] is True\n        e2e_state[\"setup_complete\"] = True\n\n    def test_08_setup_status_complete(self, api_client):\n        \"\"\"After setup, need_setup should be False.\"\"\"\n        resp = api_client.get(\"/api/v1/setup/status\")\n        assert resp.status_code == 200\n        assert resp.json()[\"need_setup\"] is False\n\n    def test_09_setup_complete_blocked(self, api_client):\n        \"\"\"POST /setup/complete returns 403 after setup is done.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"another\",\n                \"password\": \"anotherpassword\",\n                \"downloader_type\": \"mock\",\n                \"downloader_host\": \"localhost\",\n                \"downloader_username\": \"x\",\n                \"downloader_password\": \"x\",\n                \"downloader_path\": \"/tmp\",\n            },\n        )\n        assert resp.status_code == 403\n\n    def test_09b_test_downloader_blocked(self, api_client):\n        \"\"\"POST /setup/test-downloader returns 403 after setup is done.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/test-downloader\",\n            json={\"type\": \"mock\", \"host\": \"x\", \"username\": \"x\", \"password\": \"x\"},\n        )\n        assert resp.status_code == 403\n\n    def test_09c_test_rss_blocked(self, api_client):\n        \"\"\"POST /setup/test-rss returns 403 after setup is done.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/setup/test-rss\",\n            json={\"url\": \"http://example.com/rss.xml\"},\n        )\n        assert resp.status_code == 403\n\n    # ===================================================================\n    # Phase 2: Authentication\n    # ===================================================================\n\n    def test_10_login(self, api_client, e2e_state):\n        \"\"\"Login with credentials created during setup.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/login\",\n            data={\"username\": E2E_USERNAME, \"password\": E2E_PASSWORD},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"access_token\" in data\n        assert data[\"token_type\"] == \"bearer\"\n        e2e_state[\"token\"] = data[\"access_token\"]\n\n    def test_11_login_cookie_set(self, api_client):\n        \"\"\"After login, the 'token' cookie should be set on the client.\"\"\"\n        assert \"token\" in api_client.cookies\n\n    def test_12_access_protected_endpoint(self, api_client):\n        \"\"\"Authenticated client can access protected endpoints.\"\"\"\n        resp = api_client.get(\"/api/v1/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"status\" in data\n        assert \"version\" in data\n        assert \"first_run\" in data\n\n    def test_13_refresh_token(self, api_client, e2e_state):\n        \"\"\"Token refresh returns a new access token and updates cookie.\"\"\"\n        resp = api_client.get(\"/api/v1/auth/refresh_token\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"access_token\" in data\n        assert data[\"token_type\"] == \"bearer\"\n        e2e_state[\"token\"] = data[\"access_token\"]\n        # NOTE: Tokens may be identical if login+refresh happen within the\n        # same second (JWT exp uses second-level granularity).\n\n    def test_14_login_wrong_password(self, api_client):\n        \"\"\"Login with incorrect password returns 401.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/login\",\n            data={\"username\": E2E_USERNAME, \"password\": \"wrong_password\"},\n        )\n        assert resp.status_code == 401\n\n    def test_15_login_nonexistent_user(self, api_client):\n        \"\"\"Login with a user that doesn't exist returns 401.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/login\",\n            data={\"username\": \"no_such_user\", \"password\": \"irrelevant\"},\n        )\n        assert resp.status_code == 401\n\n    def test_16_unauthenticated_client(self):\n        \"\"\"A fresh client with no cookies.\n\n        NOTE: In DEV_VERSION, auth is bypassed so this returns 200.\n        In production builds this would return 401.\n        \"\"\"\n        with httpx.Client(base_url=\"http://localhost:7892\", timeout=5.0) as fresh:\n            resp = fresh.get(\"/api/v1/status\")\n            assert resp.status_code in (200, 401)\n\n    # ===================================================================\n    # Phase 3: Configuration\n    # ===================================================================\n\n    def test_20_get_config(self, api_client):\n        \"\"\"Retrieve current configuration with all top-level sections.\"\"\"\n        resp = api_client.get(\"/api/v1/config/get\")\n        assert resp.status_code == 200\n        config = resp.json()\n        for section in (\n            \"program\",\n            \"downloader\",\n            \"rss_parser\",\n            \"bangumi_manage\",\n            \"log\",\n            \"proxy\",\n            \"notification\",\n            \"experimental_openai\",\n        ):\n            assert section in config, f\"Missing config section: {section}\"\n        assert config[\"downloader\"][\"type\"] == \"mock\"\n\n    def test_21_config_passwords_masked(self, api_client):\n        \"\"\"Sensitive fields are masked as '********' in GET /config/get.\"\"\"\n        resp = api_client.get(\"/api/v1/config/get\")\n        config = resp.json()\n        # downloader password\n        assert config[\"downloader\"][\"password\"] == \"********\"\n        # proxy password (even if empty, still masked since key contains 'password')\n        assert config[\"proxy\"][\"password\"] == \"********\"\n\n    def test_22_update_config(self, api_client):\n        \"\"\"Update a non-sensitive config field via PATCH.\"\"\"\n        get_resp = api_client.get(\"/api/v1/config/get\")\n        config = get_resp.json()\n\n        config[\"program\"][\"rss_time\"] = 600\n        # Re-supply masked passwords with actual values\n        config[\"downloader\"][\"password\"] = \"admin\"\n        config[\"proxy\"][\"password\"] = \"\"\n        config[\"proxy\"][\"username\"] = \"\"\n\n        resp = api_client.patch(\"/api/v1/config/update\", json=config)\n        assert resp.status_code == 200\n\n    def test_23_config_update_persisted(self, api_client):\n        \"\"\"Verify the config update from previous test is persisted.\"\"\"\n        resp = api_client.get(\"/api/v1/config/get\")\n        assert resp.json()[\"program\"][\"rss_time\"] == 600\n\n    # ===================================================================\n    # Phase 4: RSS Management\n    # ===================================================================\n\n    def test_30_list_rss_initial(self, api_client, e2e_state):\n        \"\"\"One RSS feed should exist from setup wizard.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        assert resp.status_code == 200\n        feeds = resp.json()\n        assert isinstance(feeds, list)\n        assert len(feeds) == 1\n        assert feeds[0][\"name\"] == \"Test Mikan Feed\"\n        e2e_state[\"initial_rss_id\"] = feeds[0][\"id\"]\n\n    def test_31_add_rss_feed(self, api_client, e2e_state):\n        \"\"\"Add a second RSS feed with unique URL.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/rss/add\",\n            json={\n                \"url\": \"http://localhost:18888/rss/mikan.xml?tag=e2e\",\n                \"name\": \"E2E Second Feed\",\n                \"aggregate\": False,\n                \"parser\": \"mikan\",\n            },\n        )\n        assert resp.status_code == 200\n\n    def test_32_add_rss_duplicate_url(self, api_client):\n        \"\"\"Adding RSS with an existing URL returns 406 (duplicate).\"\"\"\n        resp = api_client.post(\n            \"/api/v1/rss/add\",\n            json={\n                \"url\": \"http://localhost:18888/rss/mikan.xml\",\n                \"name\": \"Duplicate Feed\",\n                \"aggregate\": False,\n                \"parser\": \"mikan\",\n            },\n        )\n        # u_response returns the status_code from ResponseModel (406 for failed add)\n        assert resp.status_code == 406\n\n    def test_33_list_rss_after_add(self, api_client, e2e_state):\n        \"\"\"Two feeds should now exist.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        feeds = resp.json()\n        assert len(feeds) == 2\n        names = {f[\"name\"] for f in feeds}\n        assert \"Test Mikan Feed\" in names\n        assert \"E2E Second Feed\" in names\n        for feed in feeds:\n            if feed[\"name\"] == \"E2E Second Feed\":\n                e2e_state[\"second_rss_id\"] = feed[\"id\"]\n                break\n\n    def test_34_disable_rss(self, api_client, e2e_state):\n        \"\"\"Disable the second RSS feed.\"\"\"\n        rss_id = e2e_state[\"second_rss_id\"]\n        resp = api_client.patch(f\"/api/v1/rss/disable/{rss_id}\")\n        assert resp.status_code == 200\n\n    def test_35_verify_rss_disabled(self, api_client, e2e_state):\n        \"\"\"Disabled feed should have enabled=False.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        for feed in resp.json():\n            if feed[\"id\"] == e2e_state[\"second_rss_id\"]:\n                assert feed[\"enabled\"] is False\n                break\n        else:\n            pytest.fail(\"Second RSS feed not found\")\n\n    def test_36_enable_rss(self, api_client, e2e_state):\n        \"\"\"Re-enable the RSS feed via enable/many.\"\"\"\n        rss_id = e2e_state[\"second_rss_id\"]\n        resp = api_client.post(\"/api/v1/rss/enable/many\", json=[rss_id])\n        assert resp.status_code == 200\n\n    def test_37_verify_rss_enabled(self, api_client, e2e_state):\n        \"\"\"Feed should be enabled again.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        for feed in resp.json():\n            if feed[\"id\"] == e2e_state[\"second_rss_id\"]:\n                assert feed[\"enabled\"] is True\n                break\n\n    def test_38_update_rss(self, api_client, e2e_state):\n        \"\"\"Update RSS feed name.\"\"\"\n        rss_id = e2e_state[\"second_rss_id\"]\n        resp = api_client.patch(\n            f\"/api/v1/rss/update/{rss_id}\",\n            json={\"name\": \"Renamed Feed\"},\n        )\n        assert resp.status_code == 200\n\n    def test_39_verify_rss_updated(self, api_client, e2e_state):\n        \"\"\"Verify the rename persisted.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        for feed in resp.json():\n            if feed[\"id\"] == e2e_state[\"second_rss_id\"]:\n                assert feed[\"name\"] == \"Renamed Feed\"\n                break\n\n    def test_39b_delete_nonexistent_rss(self, api_client):\n        \"\"\"Deleting a non-existent RSS ID returns 200.\n\n        The database DELETE WHERE id=X succeeds even when no rows match\n        (no exception raised), so the endpoint returns 200.\n        \"\"\"\n        resp = api_client.delete(\"/api/v1/rss/delete/99999\")\n        assert resp.status_code == 200\n\n    def test_39c_disable_nonexistent_rss(self, api_client):\n        \"\"\"Disabling a non-existent RSS ID returns 406.\"\"\"\n        resp = api_client.patch(\"/api/v1/rss/disable/99999\")\n        assert resp.status_code == 406\n\n    def test_39d_delete_rss(self, api_client, e2e_state):\n        \"\"\"Delete the second RSS feed.\"\"\"\n        rss_id = e2e_state[\"second_rss_id\"]\n        resp = api_client.delete(f\"/api/v1/rss/delete/{rss_id}\")\n        assert resp.status_code == 200\n\n    def test_39e_verify_rss_deleted(self, api_client, e2e_state):\n        \"\"\"Only the initial feed should remain.\"\"\"\n        resp = api_client.get(\"/api/v1/rss\")\n        feeds = resp.json()\n        assert len(feeds) == 1\n        assert feeds[0][\"name\"] == \"Test Mikan Feed\"\n\n    # ===================================================================\n    # Phase 5: Bangumi\n    # ===================================================================\n\n    def test_40_bangumi_get_all_empty(self, api_client):\n        \"\"\"Bangumi list is empty until RSS refresh populates it.\"\"\"\n        resp = api_client.get(\"/api/v1/bangumi/get/all\")\n        assert resp.status_code == 200\n        assert isinstance(resp.json(), list)\n\n    def test_41_bangumi_needs_review_empty(self, api_client):\n        \"\"\"No bangumi should need review initially.\"\"\"\n        resp = api_client.get(\"/api/v1/bangumi/needs-review\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    def test_42_bangumi_dismiss_review_nonexistent(self, api_client):\n        \"\"\"Dismissing review for nonexistent bangumi returns 404.\"\"\"\n        resp = api_client.post(\"/api/v1/bangumi/dismiss-review/99999\")\n        assert resp.status_code == 404\n\n    def test_43_bangumi_reset_all(self, api_client):\n        \"\"\"Reset all bangumi (safe when list is empty).\"\"\"\n        resp = api_client.get(\"/api/v1/bangumi/reset/all\")\n        assert resp.status_code == 200\n\n    # ===================================================================\n    # Phase 6: Downloader\n    # ===================================================================\n\n    def test_50_downloader_check(self, api_client):\n        \"\"\"Mock downloader health check should succeed.\"\"\"\n        resp = api_client.get(\"/api/v1/check/downloader\")\n        assert resp.status_code == 200\n\n    def test_51_downloader_torrents_empty(self, api_client):\n        \"\"\"No torrents in mock downloader initially.\"\"\"\n        resp = api_client.get(\"/api/v1/downloader/torrents\")\n        assert resp.status_code == 200\n        assert isinstance(resp.json(), list)\n\n    def test_52_downloader_pause_empty(self, api_client):\n        \"\"\"Pausing with empty hash list should succeed (no-op).\"\"\"\n        resp = api_client.post(\n            \"/api/v1/downloader/torrents/pause\", json={\"hashes\": []}\n        )\n        assert resp.status_code == 200\n\n    def test_53_downloader_resume_empty(self, api_client):\n        \"\"\"Resuming with empty hash list should succeed (no-op).\"\"\"\n        resp = api_client.post(\n            \"/api/v1/downloader/torrents/resume\", json={\"hashes\": []}\n        )\n        assert resp.status_code == 200\n\n    def test_54_downloader_delete_empty(self, api_client):\n        \"\"\"Deleting with empty hash list should succeed (no-op).\"\"\"\n        resp = api_client.post(\n            \"/api/v1/downloader/torrents/delete\",\n            json={\"hashes\": [], \"delete_files\": False},\n        )\n        assert resp.status_code == 200\n\n    def test_55_downloader_tag_nonexistent_bangumi(self, api_client):\n        \"\"\"Tagging a torrent with nonexistent bangumi_id returns status=false.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/downloader/torrents/tag\",\n            json={\"hash\": \"abc123\", \"bangumi_id\": 99999},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"status\"] is False\n\n    def test_56_downloader_auto_tag(self, api_client):\n        \"\"\"Auto-tag with no torrents returns 0 tagged.\"\"\"\n        resp = api_client.post(\"/api/v1/downloader/torrents/tag/auto\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert data[\"tagged_count\"] == 0\n        assert data[\"unmatched_count\"] == 0\n\n    def test_57_qbittorrent_direct_connectivity(self, qb_password):\n        \"\"\"Verify direct connectivity to the real qBittorrent instance.\"\"\"\n        resp = httpx.post(\n            \"http://localhost:18080/api/v2/auth/login\",\n            data={\"username\": \"admin\", \"password\": qb_password},\n            timeout=5.0,\n        )\n        assert resp.status_code == 200\n        assert \"ok\" in resp.text.lower()\n\n    # ===================================================================\n    # Phase 7: Program Lifecycle\n    # ===================================================================\n\n    def test_60_program_status_not_running(self, api_client):\n        \"\"\"After first-run setup, program is NOT auto-started.\n\n        startup() detects first_run and returns early without calling start().\n        \"\"\"\n        resp = api_client.get(\"/api/v1/status\")\n        assert resp.status_code == 200\n        data = resp.json()\n        assert isinstance(data[\"status\"], bool)\n        assert isinstance(data[\"version\"], str)\n        assert isinstance(data[\"first_run\"], bool)\n\n    def test_61_program_stop_when_not_running(self, api_client):\n        \"\"\"Stopping a program that isn't running returns 406.\"\"\"\n        resp = api_client.get(\"/api/v1/stop\")\n        assert resp.status_code == 406\n\n    def test_62_program_start(self, api_client):\n        \"\"\"Explicitly start the program.\"\"\"\n        resp = api_client.get(\"/api/v1/start\")\n        assert resp.status_code == 200\n\n    def test_63_program_stop(self, api_client):\n        \"\"\"Stop the now-running program.\"\"\"\n        resp = api_client.get(\"/api/v1/stop\")\n        assert resp.status_code == 200\n\n    def test_64_program_stop_already_stopped(self, api_client):\n        \"\"\"Stopping again returns 406 (not running).\"\"\"\n        resp = api_client.get(\"/api/v1/stop\")\n        assert resp.status_code == 406\n\n    def test_65_program_restart(self, api_client):\n        \"\"\"Restart works even from a stopped state.\"\"\"\n        resp = api_client.get(\"/api/v1/restart\")\n        assert resp.status_code == 200\n\n    # ===================================================================\n    # Phase 8: Log\n    # ===================================================================\n\n    def test_70_get_log(self, api_client):\n        \"\"\"Retrieve application log (text/plain response).\"\"\"\n        resp = api_client.get(\"/api/v1/log\")\n        # Log file may or may not exist depending on AB startup behavior\n        assert resp.status_code in (200, 404)\n        if resp.status_code == 200:\n            assert \"text/plain\" in resp.headers.get(\"content-type\", \"\")\n\n    def test_71_clear_log(self, api_client):\n        \"\"\"Clear the log file.\"\"\"\n        resp = api_client.get(\"/api/v1/log/clear\")\n        # 200 if log exists, 406 if not found\n        assert resp.status_code in (200, 406)\n\n    def test_72_get_log_after_clear(self, api_client):\n        \"\"\"Log should be empty or very short after clear.\"\"\"\n        resp = api_client.get(\"/api/v1/log\")\n        if resp.status_code == 200:\n            # Log might have new entries from the clear request itself\n            assert len(resp.text) < 10000\n\n    # ===================================================================\n    # Phase 9: Search\n    # ===================================================================\n\n    def test_80_search_providers(self, api_client):\n        \"\"\"List available search providers.\"\"\"\n        resp = api_client.get(\"/api/v1/search/provider\")\n        assert resp.status_code == 200\n        providers = resp.json()\n        assert isinstance(providers, list)\n        assert len(providers) > 0\n\n    def test_81_search_provider_config(self, api_client):\n        \"\"\"Get search provider URL templates.\"\"\"\n        resp = api_client.get(\"/api/v1/search/provider/config\")\n        assert resp.status_code == 200\n        config = resp.json()\n        assert isinstance(config, dict)\n\n    def test_82_search_empty_keywords(self, api_client):\n        \"\"\"Search with no keywords returns empty list.\"\"\"\n        resp = api_client.get(\"/api/v1/search/bangumi\")\n        assert resp.status_code == 200\n        assert resp.json() == []\n\n    # ===================================================================\n    # Phase 10: Notification\n    # ===================================================================\n\n    def test_85_notification_test_invalid_index(self, api_client):\n        \"\"\"Test notification with out-of-range index returns success=false.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/notification/test\", json={\"provider_index\": 9999}\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"success\"] is False\n\n    def test_86_notification_test_config_unknown_type(self, api_client):\n        \"\"\"Test-config with unknown provider type returns success=false.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/notification/test-config\",\n            json={\"type\": \"nonexistent_provider\", \"enabled\": True},\n        )\n        assert resp.status_code == 200\n        assert resp.json()[\"success\"] is False\n\n    # ===================================================================\n    # Phase 11: Credential Update & Cleanup\n    # ===================================================================\n\n    def test_90_update_credentials(self, api_client, e2e_state):\n        \"\"\"Update user password via /auth/update.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/update\",\n            json={\"password\": \"newpassword123\"},\n        )\n        assert resp.status_code == 200\n        data = resp.json()\n        assert \"access_token\" in data\n        assert data[\"message\"] == \"update success\"\n        e2e_state[\"new_password\"] = \"newpassword123\"\n\n    def test_91_login_with_new_password(self, api_client, e2e_state):\n        \"\"\"Login works with the updated password.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/login\",\n            data={\n                \"username\": E2E_USERNAME,\n                \"password\": e2e_state[\"new_password\"],\n            },\n        )\n        assert resp.status_code == 200\n        assert \"access_token\" in resp.json()\n\n    def test_92_login_old_password_fails(self, api_client):\n        \"\"\"Old password should no longer work after credential update.\"\"\"\n        resp = api_client.post(\n            \"/api/v1/auth/login\",\n            data={\"username\": E2E_USERNAME, \"password\": E2E_PASSWORD},\n        )\n        assert resp.status_code == 401\n\n    def test_93_logout(self, api_client):\n        \"\"\"Logout clears the auth session and deletes cookie.\"\"\"\n        resp = api_client.get(\"/api/v1/auth/logout\")\n        assert resp.status_code == 200\n\n    def test_94_verify_logged_out(self, api_client):\n        \"\"\"After logout, the token cookie should be cleared.\n\n        NOTE: In DEV_VERSION, endpoints still work (auth bypass).\n        This test verifies the cookie was deleted.\n        \"\"\"\n        # httpx may still have a cookie if the server didn't properly\n        # delete it, but the logout response should have Set-Cookie\n        # with max-age=0 or explicit deletion.\n        resp = api_client.get(\"/api/v1/status\")\n        # DEV_VERSION: 200 (bypass), Production: 401 (no token)\n        assert resp.status_code in (200, 401)\n"
  },
  {
    "path": "backend/src/test/factories.py",
    "content": "\"\"\"Test data factories for creating model instances with sensible defaults.\"\"\"\n\nfrom datetime import datetime, timezone\n\nfrom module.models import Bangumi, RSSItem, Torrent\nfrom module.models.config import Config\nfrom module.models.passkey import Passkey\n\n\ndef make_bangumi(**overrides) -> Bangumi:\n    \"\"\"Create a Bangumi instance with sensible test defaults.\"\"\"\n    defaults = dict(\n        official_title=\"Test Anime\",\n        year=\"2024\",\n        title_raw=\"Test Anime Raw\",\n        season=1,\n        season_raw=\"\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        eps_collect=False,\n        offset=0,\n        filter=\"720\",\n        rss_link=\"https://mikanani.me/RSS/test\",\n        poster_link=\"/test/poster.jpg\",\n        added=True,\n        rule_name=\"[TestGroup] Test Anime S1\",\n        save_path=\"/downloads/Bangumi/Test Anime (2024)/Season 1\",\n        deleted=False,\n    )\n    defaults.update(overrides)\n    return Bangumi(**defaults)\n\n\ndef make_torrent(**overrides) -> Torrent:\n    \"\"\"Create a Torrent instance with sensible test defaults.\"\"\"\n    defaults = dict(\n        name=\"[TestGroup] Test Anime Raw - 01 [1080p].mkv\",\n        url=\"https://example.com/test.torrent\",\n        homepage=\"https://mikanani.me/Home/Episode/test\",\n        downloaded=False,\n    )\n    defaults.update(overrides)\n    return Torrent(**defaults)\n\n\ndef make_rss_item(**overrides) -> RSSItem:\n    \"\"\"Create an RSSItem instance with sensible test defaults.\"\"\"\n    defaults = dict(\n        name=\"Test RSS Feed\",\n        url=\"https://mikanani.me/RSS/MyBangumi?token=test\",\n        aggregate=True,\n        parser=\"mikan\",\n        enabled=True,\n    )\n    defaults.update(overrides)\n    return RSSItem(**defaults)\n\n\ndef make_config(**overrides) -> Config:\n    \"\"\"Create a Config instance with sensible test defaults.\"\"\"\n    config = Config()\n    for key, value in overrides.items():\n        if hasattr(config, key):\n            setattr(config, key, value)\n    return config\n\n\ndef make_passkey(**overrides) -> Passkey:\n    \"\"\"Create a Passkey instance with sensible test defaults.\"\"\"\n    defaults = dict(\n        id=1,\n        user_id=1,\n        name=\"Test Passkey\",\n        credential_id=\"test_credential_id_base64url\",\n        public_key=\"test_public_key_base64\",\n        sign_count=0,\n        aaguid=\"00000000-0000-0000-0000-000000000000\",\n        transports='[\"internal\"]',\n        created_at=datetime.now(timezone.utc),\n        last_used_at=None,\n        backup_eligible=False,\n        backup_state=False,\n    )\n    defaults.update(overrides)\n    return Passkey(**defaults)\n"
  },
  {
    "path": "backend/src/test/test_api_auth.py",
    "content": "\"\"\"Tests for Auth API endpoints.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import ResponseModel\nfrom module.security.api import active_user, get_current_user\nfrom module.security.jwt import create_access_token\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_refresh_token_unauthorized(self, unauthed_client):\n        \"\"\"GET /auth/refresh_token without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/auth/refresh_token\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_logout_unauthorized(self, unauthed_client):\n        \"\"\"GET /auth/logout without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/auth/logout\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_update_unauthorized(self, unauthed_client):\n        \"\"\"POST /auth/update without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/auth/update\",\n            json={\"old_password\": \"test\", \"new_password\": \"newtest\"},\n        )\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# POST /auth/login\n# ---------------------------------------------------------------------------\n\n\nclass TestLogin:\n    def test_login_success(self, unauthed_client):\n        \"\"\"POST /auth/login with valid credentials returns token.\"\"\"\n        mock_response = ResponseModel(\n            status=True, status_code=200, msg_en=\"OK\", msg_zh=\"成功\"\n        )\n        with patch(\"module.api.auth.auth_user\", return_value=mock_response):\n            response = unauthed_client.post(\n                \"/api/v1/auth/login\",\n                data={\"username\": \"admin\", \"password\": \"adminadmin\"},\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"access_token\" in data\n        assert data[\"token_type\"] == \"bearer\"\n\n    def test_login_failure(self, unauthed_client):\n        \"\"\"POST /auth/login with invalid credentials returns error.\"\"\"\n        mock_response = ResponseModel(\n            status=False, status_code=401, msg_en=\"Invalid\", msg_zh=\"无效\"\n        )\n        with patch(\"module.api.auth.auth_user\", return_value=mock_response):\n            response = unauthed_client.post(\n                \"/api/v1/auth/login\",\n                data={\"username\": \"admin\", \"password\": \"wrongpassword\"},\n            )\n\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /auth/refresh_token\n# ---------------------------------------------------------------------------\n\n\nclass TestRefreshToken:\n    def test_refresh_token_success(self, authed_client):\n        \"\"\"GET /auth/refresh_token returns new token.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {\"testuser\": datetime.now()}):\n            response = authed_client.get(\"/api/v1/auth/refresh_token\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"access_token\" in data\n        assert data[\"token_type\"] == \"bearer\"\n\n\n# ---------------------------------------------------------------------------\n# GET /auth/logout\n# ---------------------------------------------------------------------------\n\n\nclass TestLogout:\n    def test_logout_success(self, authed_client):\n        \"\"\"GET /auth/logout clears session and returns success.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {\"testuser\": datetime.now()}):\n            response = authed_client.get(\"/api/v1/auth/logout\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Logout successfully.\"\n\n\n# ---------------------------------------------------------------------------\n# POST /auth/update\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateCredentials:\n    def test_update_success(self, authed_client):\n        \"\"\"POST /auth/update with valid data updates credentials.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {\"testuser\": datetime.now()}):\n            with patch(\"module.api.auth.update_user_info\", return_value=True):\n                response = authed_client.post(\n                    \"/api/v1/auth/update\",\n                    json={\"old_password\": \"oldpass\", \"new_password\": \"newpass\"},\n                )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"access_token\" in data\n        assert data[\"message\"] == \"update success\"\n\n    def test_update_failure(self, authed_client):\n        \"\"\"POST /auth/update with invalid old password fails.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {\"testuser\": datetime.now()}):\n            with patch(\"module.api.auth.update_user_info\", return_value=False):\n                # When update_user_info returns False, the endpoint implicitly\n                # returns None which causes an error\n                try:\n                    response = authed_client.post(\n                        \"/api/v1/auth/update\",\n                        json={\"old_password\": \"wrongpass\", \"new_password\": \"newpass\"},\n                    )\n                    # If it doesn't raise, check for error status\n                    assert response.status_code in [200, 422, 500]\n                except Exception:\n                    # Expected - endpoint doesn't handle failure case properly\n                    pass\n\n\n# ---------------------------------------------------------------------------\n# Refresh token: cookie-based username resolution\n# ---------------------------------------------------------------------------\n\n\nclass TestRefreshTokenCookieBehavior:\n    def test_refresh_with_no_cookie_raises_401(self, authed_client):\n        \"\"\"GET /refresh_token with missing token cookie raises 401.\"\"\"\n        # Override auth to allow route but provide no cookie token\n        with patch(\"module.api.auth.decode_token\", return_value=None):\n            response = authed_client.get(\"/api/v1/auth/refresh_token\")\n        assert response.status_code == 401\n\n    def test_refresh_with_valid_cookie_updates_active_user(self, authed_client):\n        \"\"\"GET /refresh_token updates the active user timestamp.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        active_users: dict = {}\n        with patch(\"module.api.auth.active_user\", active_users):\n            response = authed_client.get(\"/api/v1/auth/refresh_token\")\n        assert response.status_code == 200\n        assert \"testuser\" in active_users\n\n    def test_refresh_returns_new_token(self, authed_client):\n        \"\"\"GET /refresh_token issues a valid JWT with bearer type.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {}):\n            response = authed_client.get(\"/api/v1/auth/refresh_token\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"token_type\"] == \"bearer\"\n        assert isinstance(data[\"access_token\"], str)\n        assert len(data[\"access_token\"]) > 0\n\n\n# ---------------------------------------------------------------------------\n# Logout: per-user removal\n# ---------------------------------------------------------------------------\n\n\nclass TestLogoutCookieBehavior:\n    def test_logout_removes_only_current_user(self, authed_client):\n        \"\"\"GET /logout removes the current user from active_user, not others.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        active_users = {\n            \"testuser\": datetime.now(),\n            \"otheruser\": datetime.now(),\n        }\n        with patch(\"module.api.auth.active_user\", active_users):\n            response = authed_client.get(\"/api/v1/auth/logout\")\n        assert response.status_code == 200\n        assert \"testuser\" not in active_users\n        assert \"otheruser\" in active_users\n\n    def test_logout_with_no_cookie_still_succeeds(self, authed_client):\n        \"\"\"GET /logout with no cookie clears nothing but returns success.\"\"\"\n        with patch(\"module.api.auth.decode_token\", return_value=None):\n            with patch(\"module.api.auth.active_user\", {}):\n                response = authed_client.get(\"/api/v1/auth/logout\")\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# Update: cookie-based user resolution\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateCookieBehavior:\n    def test_update_with_no_cookie_raises_401(self, authed_client):\n        \"\"\"POST /auth/update with no cookie raises 401.\"\"\"\n        with patch(\"module.api.auth.decode_token\", return_value=None):\n            response = authed_client.post(\n                \"/api/v1/auth/update\",\n                json={\"old_password\": \"old\", \"new_password\": \"new\"},\n            )\n        assert response.status_code == 401\n\n    def test_update_with_valid_cookie_succeeds(self, authed_client):\n        \"\"\"POST /auth/update resolves username from cookie and issues new token.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        authed_client.cookies.set(\"token\", token)\n        with patch(\"module.api.auth.active_user\", {\"testuser\": datetime.now()}):\n            with patch(\"module.api.auth.update_user_info\", return_value=True):\n                response = authed_client.post(\n                    \"/api/v1/auth/update\",\n                    json={\"old_password\": \"oldpass\", \"new_password\": \"newpass\"},\n                )\n        assert response.status_code == 200\n        data = response.json()\n        assert \"access_token\" in data\n        assert data[\"message\"] == \"update success\"\n"
  },
  {
    "path": "backend/src/test/test_api_bangumi.py",
    "content": "\"\"\"Tests for Bangumi API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\nfrom datetime import timedelta\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import Bangumi, BangumiUpdate, ResponseModel\nfrom module.security.api import get_current_user, active_user\nfrom module.security.jwt import create_access_token\n\nfrom test.factories import make_bangumi\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_all_unauthorized(self, unauthed_client):\n        \"\"\"GET /bangumi/get/all without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/bangumi/get/all\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_by_id_unauthorized(self, unauthed_client):\n        \"\"\"GET /bangumi/get/1 without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/bangumi/get/1\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_delete_unauthorized(self, unauthed_client):\n        \"\"\"DELETE /bangumi/delete/1 without auth returns 401.\"\"\"\n        response = unauthed_client.delete(\"/api/v1/bangumi/delete/1\")\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestGetBangumi:\n    def test_get_all(self, authed_client):\n        \"\"\"GET /bangumi/get/all returns list of Bangumi.\"\"\"\n        mock_bangumi = [make_bangumi(id=1), make_bangumi(id=2, title_raw=\"Other\")]\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.bangumi.search_all.return_value = mock_bangumi\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/get/all\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n\n    def test_get_by_id(self, authed_client):\n        \"\"\"GET /bangumi/get/{id} returns single Bangumi.\"\"\"\n        bangumi = make_bangumi(id=1, official_title=\"Found Anime\")\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.search_one.return_value = bangumi\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/get/1\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# PATCH/UPDATE endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateBangumi:\n    def test_update_success(self, authed_client):\n        \"\"\"PATCH /bangumi/update/{id} updates and returns success.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Updated.\", msg_zh=\"已更新。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.update_rule = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            # BangumiUpdate requires all fields\n            update_data = {\n                \"official_title\": \"New Title\",\n                \"title_raw\": \"new_raw\",\n                \"season\": 1,\n                \"year\": \"2024\",\n                \"season_raw\": \"\",\n                \"group_name\": \"Group\",\n                \"dpi\": \"1080p\",\n                \"source\": \"Web\",\n                \"subtitle\": \"CHT\",\n                \"eps_collect\": False,\n                \"offset\": 0,\n                \"filter\": \"720\",\n                \"rss_link\": \"https://test.com/rss\",\n                \"poster_link\": None,\n                \"added\": True,\n                \"rule_name\": None,\n                \"save_path\": None,\n                \"deleted\": False,\n            }\n            response = authed_client.patch(\n                \"/api/v1/bangumi/update/1\",\n                json=update_data,\n            )\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# DELETE endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestDeleteBangumi:\n    def test_delete_success(self, authed_client):\n        \"\"\"DELETE /bangumi/delete/{id} removes bangumi.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Deleted.\", msg_zh=\"已删除。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.delete_rule = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.delete(\"/api/v1/bangumi/delete/1\")\n\n        assert response.status_code == 200\n\n    def test_disable_rule(self, authed_client):\n        \"\"\"DELETE /bangumi/disable/{id} marks as deleted.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Disabled.\", msg_zh=\"已禁用。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.disable_rule = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.delete(\"/api/v1/bangumi/disable/1\")\n\n        assert response.status_code == 200\n\n    def test_enable_rule(self, authed_client):\n        \"\"\"GET /bangumi/enable/{id} re-enables rule.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Enabled.\", msg_zh=\"已启用。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.enable_rule.return_value = resp_model\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/enable/1\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# Reset\n# ---------------------------------------------------------------------------\n\n\nclass TestResetBangumi:\n    def test_reset_all(self, authed_client):\n        \"\"\"GET /bangumi/reset/all deletes all bangumi.\"\"\"\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.bangumi.delete_all.return_value = None\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/reset/all\")\n\n        assert response.status_code == 200\n"
  },
  {
    "path": "backend/src/test/test_api_bangumi_extended.py",
    "content": "\"\"\"Tests for extended Bangumi API endpoints (archive, refresh, offset, batch).\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import Bangumi, ResponseModel\nfrom module.security.api import get_current_user\n\nfrom test.factories import make_bangumi\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Archive endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestArchiveBangumi:\n    def test_archive_success(self, authed_client):\n        \"\"\"PATCH /bangumi/archive/{id} archives a bangumi.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Archived.\", msg_zh=\"已归档。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.archive_rule.return_value = resp_model\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.patch(\"/api/v1/bangumi/archive/1\")\n\n        assert response.status_code == 200\n\n    def test_unarchive_success(self, authed_client):\n        \"\"\"PATCH /bangumi/unarchive/{id} unarchives a bangumi.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Unarchived.\", msg_zh=\"已取消归档。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.unarchive_rule.return_value = resp_model\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.patch(\"/api/v1/bangumi/unarchive/1\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# Refresh endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestRefreshBangumi:\n    def test_refresh_poster_all(self, authed_client):\n        \"\"\"GET /bangumi/refresh/poster/all refreshes all posters.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Refreshed.\", msg_zh=\"已刷新。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.refresh_poster = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/refresh/poster/all\")\n\n        assert response.status_code == 200\n\n    def test_refresh_poster_one(self, authed_client):\n        \"\"\"GET /bangumi/refresh/poster/{id} refreshes single poster.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Refreshed.\", msg_zh=\"已刷新。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.refind_poster = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/refresh/poster/1\")\n\n        assert response.status_code == 200\n\n    def test_refresh_calendar(self, authed_client):\n        \"\"\"GET /bangumi/refresh/calendar refreshes calendar data.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Refreshed.\", msg_zh=\"已刷新。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.refresh_calendar = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/refresh/calendar\")\n\n        assert response.status_code == 200\n\n    def test_refresh_metadata(self, authed_client):\n        \"\"\"GET /bangumi/refresh/metadata refreshes TMDB metadata.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Refreshed.\", msg_zh=\"已刷新。\"\n        )\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.refresh_metadata = AsyncMock(return_value=resp_model)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/refresh/metadata\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# Offset endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestOffsetDetection:\n    def test_suggest_offset(self, authed_client):\n        \"\"\"GET /bangumi/suggest-offset/{id} returns offset suggestion.\"\"\"\n        suggestion = {\"suggested_offset\": 12, \"reason\": \"Season 2 starts at episode 13\"}\n        with patch(\"module.api.bangumi.TorrentManager\") as MockManager:\n            mock_mgr = MagicMock()\n            mock_mgr.suggest_offset = AsyncMock(return_value=suggestion)\n            MockManager.return_value.__enter__ = MagicMock(return_value=mock_mgr)\n            MockManager.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/suggest-offset/1\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"suggested_offset\"] == 12\n\n    def test_detect_offset_no_mismatch(self, authed_client):\n        \"\"\"POST /bangumi/detect-offset with no mismatch.\"\"\"\n        mock_tmdb_info = MagicMock()\n        mock_tmdb_info.title = \"Test Anime\"\n        mock_tmdb_info.last_season = 1\n        mock_tmdb_info.season_episode_counts = {1: 12}\n        mock_tmdb_info.series_status = \"Ended\"\n        mock_tmdb_info.virtual_season_starts = None\n\n        with patch(\"module.api.bangumi.tmdb_parser\", return_value=mock_tmdb_info):\n            with patch(\"module.api.bangumi.detect_offset_mismatch\", return_value=None):\n                response = authed_client.post(\n                    \"/api/v1/bangumi/detect-offset\",\n                    json={\n                        \"title\": \"Test Anime\",\n                        \"parsed_season\": 1,\n                        \"parsed_episode\": 5,\n                    },\n                )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"has_mismatch\"] is False\n        assert data[\"suggestion\"] is None\n\n    def test_detect_offset_with_mismatch(self, authed_client):\n        \"\"\"POST /bangumi/detect-offset with mismatch detected.\"\"\"\n        mock_tmdb_info = MagicMock()\n        mock_tmdb_info.title = \"Test Anime\"\n        mock_tmdb_info.last_season = 2\n        mock_tmdb_info.season_episode_counts = {1: 12, 2: 12}\n        mock_tmdb_info.series_status = \"Returning\"\n        mock_tmdb_info.virtual_season_starts = None\n\n        mock_suggestion = MagicMock()\n        mock_suggestion.season_offset = 1\n        mock_suggestion.episode_offset = 12\n        mock_suggestion.reason = \"Detected multi-season broadcast\"\n        mock_suggestion.confidence = \"high\"\n\n        with patch(\"module.api.bangumi.tmdb_parser\", return_value=mock_tmdb_info):\n            with patch(\n                \"module.api.bangumi.detect_offset_mismatch\",\n                return_value=mock_suggestion,\n            ):\n                response = authed_client.post(\n                    \"/api/v1/bangumi/detect-offset\",\n                    json={\n                        \"title\": \"Test Anime\",\n                        \"parsed_season\": 1,\n                        \"parsed_episode\": 25,\n                    },\n                )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"has_mismatch\"] is True\n        assert data[\"suggestion\"][\"episode_offset\"] == 12\n\n    def test_detect_offset_no_tmdb_data(self, authed_client):\n        \"\"\"POST /bangumi/detect-offset when TMDB has no data.\"\"\"\n        with patch(\"module.api.bangumi.tmdb_parser\", return_value=None):\n            response = authed_client.post(\n                \"/api/v1/bangumi/detect-offset\",\n                json={\n                    \"title\": \"Unknown Anime\",\n                    \"parsed_season\": 1,\n                    \"parsed_episode\": 5,\n                },\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"has_mismatch\"] is False\n        assert data[\"tmdb_info\"] is None\n\n\n# ---------------------------------------------------------------------------\n# Needs review endpoints\n# ---------------------------------------------------------------------------\n\n\nclass TestNeedsReview:\n    def test_get_needs_review(self, authed_client):\n        \"\"\"GET /bangumi/needs-review returns bangumi needing review.\"\"\"\n        bangumi_list = [\n            make_bangumi(id=1, official_title=\"Anime 1\"),\n            make_bangumi(id=2, official_title=\"Anime 2\"),\n        ]\n        with patch(\"module.api.bangumi.Database\") as MockDB:\n            mock_db = MagicMock()\n            mock_db.bangumi.get_needs_review.return_value = bangumi_list\n            MockDB.return_value.__enter__ = MagicMock(return_value=mock_db)\n            MockDB.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/bangumi/needs-review\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n\n    def test_dismiss_review_success(self, authed_client):\n        \"\"\"POST /bangumi/dismiss-review/{id} clears review flag.\"\"\"\n        with patch(\"module.api.bangumi.Database\") as MockDB:\n            mock_db = MagicMock()\n            mock_db.bangumi.clear_needs_review.return_value = True\n            MockDB.return_value.__enter__ = MagicMock(return_value=mock_db)\n            MockDB.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\"/api/v1/bangumi/dismiss-review/1\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is True\n\n    def test_dismiss_review_not_found(self, authed_client):\n        \"\"\"POST /bangumi/dismiss-review/{id} with non-existent bangumi.\"\"\"\n        with patch(\"module.api.bangumi.Database\") as MockDB:\n            mock_db = MagicMock()\n            mock_db.bangumi.clear_needs_review.return_value = False\n            MockDB.return_value.__enter__ = MagicMock(return_value=mock_db)\n            MockDB.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\"/api/v1/bangumi/dismiss-review/999\")\n\n        assert response.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# Batch operations\n# ---------------------------------------------------------------------------\n\n\nclass TestBatchOperations:\n    def test_delete_many_auth_required(self, unauthed_client):\n        \"\"\"DELETE /bangumi/delete/many/ requires authentication.\"\"\"\n        # Note: The batch endpoints accept list as body but FastAPI requires\n        # proper Query/Body annotations. Testing auth requirement only.\n        with patch(\"module.security.api.DEV_AUTH_BYPASS\", False):\n            response = unauthed_client.request(\n                \"DELETE\",\n                \"/api/v1/bangumi/delete/many/\",\n                json=[1, 2, 3],\n            )\n        assert response.status_code == 401\n\n    def test_disable_many_auth_required(self, unauthed_client):\n        \"\"\"DELETE /bangumi/disable/many/ requires authentication.\"\"\"\n        with patch(\"module.security.api.DEV_AUTH_BYPASS\", False):\n            response = unauthed_client.request(\n                \"DELETE\",\n                \"/api/v1/bangumi/disable/many/\",\n                json=[1, 2],\n            )\n        assert response.status_code == 401\n"
  },
  {
    "path": "backend/src/test/test_api_config.py",
    "content": "\"\"\"Tests for Config API endpoints and config sanitization.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.api.config import _sanitize_dict, _restore_masked\nfrom module.models.config import Config\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_settings():\n    \"\"\"Mock settings object.\"\"\"\n    settings = MagicMock(spec=Config)\n    settings.program = MagicMock()\n    settings.program.rss_time = 900\n    settings.program.rename_time = 60\n    settings.program.webui_port = 7892\n    settings.downloader = MagicMock()\n    settings.downloader.type = \"qbittorrent\"\n    settings.downloader.host = \"172.17.0.1:8080\"\n    settings.downloader.username = \"admin\"\n    settings.downloader.password = \"adminadmin\"\n    settings.downloader.path = \"/downloads/Bangumi\"\n    settings.downloader.ssl = False\n    settings.rss_parser = MagicMock()\n    settings.rss_parser.enable = True\n    settings.rss_parser.filter = [\"720\", r\"\\d+-\\d\"]\n    settings.rss_parser.language = \"zh\"\n    settings.bangumi_manage = MagicMock()\n    settings.bangumi_manage.enable = True\n    settings.bangumi_manage.eps_complete = False\n    settings.bangumi_manage.rename_method = \"pn\"\n    settings.bangumi_manage.group_tag = False\n    settings.bangumi_manage.remove_bad_torrent = False\n    settings.log = MagicMock()\n    settings.log.debug_enable = False\n    settings.proxy = MagicMock()\n    settings.proxy.enable = False\n    settings.notification = MagicMock()\n    settings.notification.enable = False\n    settings.experimental_openai = MagicMock()\n    settings.experimental_openai.enable = False\n    settings.save = MagicMock()\n    settings.load = MagicMock()\n    return settings\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_config_unauthorized(self, unauthed_client):\n        \"\"\"GET /config/get without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/config/get\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_update_config_unauthorized(self, unauthed_client):\n        \"\"\"PATCH /config/update without auth returns 401.\"\"\"\n        response = unauthed_client.patch(\"/api/v1/config/update\", json={})\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /config/get\n# ---------------------------------------------------------------------------\n\n\nclass TestGetConfig:\n    def test_get_config_success(self, authed_client):\n        \"\"\"GET /config/get returns current configuration.\"\"\"\n        test_config = Config()\n        with patch(\"module.api.config.settings\", test_config):\n            response = authed_client.get(\"/api/v1/config/get\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"program\" in data\n        assert \"downloader\" in data\n        assert \"rss_parser\" in data\n        assert data[\"program\"][\"rss_time\"] == 900\n        assert data[\"program\"][\"webui_port\"] == 7892\n\n\n# ---------------------------------------------------------------------------\n# PATCH /config/update\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateConfig:\n    def test_update_config_success(self, authed_client, mock_settings):\n        \"\"\"PATCH /config/update updates configuration successfully.\"\"\"\n        update_data = {\n            \"program\": {\n                \"rss_time\": 600,\n                \"rename_time\": 30,\n                \"webui_port\": 7892,\n            },\n            \"downloader\": {\n                \"type\": \"qbittorrent\",\n                \"host\": \"192.168.1.100:8080\",\n                \"username\": \"admin\",\n                \"password\": \"newpassword\",\n                \"path\": \"/downloads/Bangumi\",\n                \"ssl\": False,\n            },\n            \"rss_parser\": {\n                \"enable\": True,\n                \"filter\": [\"720\"],\n                \"language\": \"zh\",\n            },\n            \"bangumi_manage\": {\n                \"enable\": True,\n                \"eps_complete\": False,\n                \"rename_method\": \"pn\",\n                \"group_tag\": False,\n                \"remove_bad_torrent\": False,\n            },\n            \"log\": {\"debug_enable\": True},\n            \"proxy\": {\n                \"enable\": False,\n                \"type\": \"http\",\n                \"host\": \"\",\n                \"port\": 0,\n                \"username\": \"\",\n                \"password\": \"\",\n            },\n            \"notification\": {\n                \"enable\": False,\n                \"type\": \"telegram\",\n                \"token\": \"\",\n                \"chat_id\": \"\",\n            },\n            \"experimental_openai\": {\n                \"enable\": False,\n                \"api_key\": \"\",\n                \"api_base\": \"https://api.openai.com/v1\",\n                \"api_type\": \"openai\",\n                \"api_version\": \"2023-05-15\",\n                \"model\": \"gpt-3.5-turbo\",\n                \"deployment_id\": \"\",\n            },\n        }\n        with patch(\"module.api.config.settings\", mock_settings):\n            response = authed_client.patch(\"/api/v1/config/update\", json=update_data)\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Update config successfully.\"\n        mock_settings.save.assert_called_once()\n        mock_settings.load.assert_called_once()\n\n    def test_update_config_failure(self, authed_client, mock_settings):\n        \"\"\"PATCH /config/update handles save failure.\"\"\"\n        mock_settings.save.side_effect = Exception(\"Save failed\")\n        update_data = {\n            \"program\": {\n                \"rss_time\": 600,\n                \"rename_time\": 30,\n                \"webui_port\": 7892,\n            },\n            \"downloader\": {\n                \"type\": \"qbittorrent\",\n                \"host\": \"192.168.1.100:8080\",\n                \"username\": \"admin\",\n                \"password\": \"newpassword\",\n                \"path\": \"/downloads/Bangumi\",\n                \"ssl\": False,\n            },\n            \"rss_parser\": {\n                \"enable\": True,\n                \"filter\": [\"720\"],\n                \"language\": \"zh\",\n            },\n            \"bangumi_manage\": {\n                \"enable\": True,\n                \"eps_complete\": False,\n                \"rename_method\": \"pn\",\n                \"group_tag\": False,\n                \"remove_bad_torrent\": False,\n            },\n            \"log\": {\"debug_enable\": False},\n            \"proxy\": {\n                \"enable\": False,\n                \"type\": \"http\",\n                \"host\": \"\",\n                \"port\": 0,\n                \"username\": \"\",\n                \"password\": \"\",\n            },\n            \"notification\": {\n                \"enable\": False,\n                \"type\": \"telegram\",\n                \"token\": \"\",\n                \"chat_id\": \"\",\n            },\n            \"experimental_openai\": {\n                \"enable\": False,\n                \"api_key\": \"\",\n                \"api_base\": \"https://api.openai.com/v1\",\n                \"api_type\": \"openai\",\n                \"api_version\": \"2023-05-15\",\n                \"model\": \"gpt-3.5-turbo\",\n                \"deployment_id\": \"\",\n            },\n        }\n        with patch(\"module.api.config.settings\", mock_settings):\n            response = authed_client.patch(\"/api/v1/config/update\", json=update_data)\n\n        assert response.status_code == 406\n        data = response.json()\n        assert data[\"msg_en\"] == \"Update config failed.\"\n\n    def test_update_config_partial_validation_error(self, authed_client):\n        \"\"\"PATCH /config/update with invalid data returns 422.\"\"\"\n        # Invalid port (out of range)\n        invalid_data = {\n            \"program\": {\n                \"rss_time\": \"invalid\",  # Should be int\n                \"rename_time\": 60,\n                \"webui_port\": 7892,\n            }\n        }\n        response = authed_client.patch(\"/api/v1/config/update\", json=invalid_data)\n\n        assert response.status_code == 422\n\n\n# ---------------------------------------------------------------------------\n# _sanitize_dict unit tests\n# ---------------------------------------------------------------------------\n\n\nclass TestSanitizeDict:\n    def test_masks_password_key(self):\n        \"\"\"Keys containing 'password' are masked.\"\"\"\n        result = _sanitize_dict({\"password\": \"secret\"})\n        assert result[\"password\"] == \"********\"\n\n    def test_masks_api_key(self):\n        \"\"\"Keys containing 'api_key' are masked.\"\"\"\n        result = _sanitize_dict({\"api_key\": \"sk-abc123\"})\n        assert result[\"api_key\"] == \"********\"\n\n    def test_masks_token_key(self):\n        \"\"\"Keys containing 'token' are masked.\"\"\"\n        result = _sanitize_dict({\"token\": \"bearer-xyz\"})\n        assert result[\"token\"] == \"********\"\n\n    def test_masks_secret_key(self):\n        \"\"\"Keys containing 'secret' are masked.\"\"\"\n        result = _sanitize_dict({\"my_secret\": \"topsecret\"})\n        assert result[\"my_secret\"] == \"********\"\n\n    def test_case_insensitive_key_matching(self):\n        \"\"\"Sensitive key matching is case-insensitive.\"\"\"\n        result = _sanitize_dict({\"API_KEY\": \"abc\"})\n        assert result[\"API_KEY\"] == \"********\"\n\n    def test_non_sensitive_keys_pass_through(self):\n        \"\"\"Non-sensitive keys are returned unchanged.\"\"\"\n        result = _sanitize_dict({\"host\": \"localhost\", \"port\": 8080, \"enable\": True})\n        assert result[\"host\"] == \"localhost\"\n        assert result[\"port\"] == 8080\n        assert result[\"enable\"] is True\n\n    def test_nested_dict_recursed(self):\n        \"\"\"Nested dicts are processed recursively.\"\"\"\n        result = _sanitize_dict(\n            {\n                \"downloader\": {\n                    \"host\": \"localhost\",\n                    \"password\": \"secret\",\n                }\n            }\n        )\n        assert result[\"downloader\"][\"host\"] == \"localhost\"\n        assert result[\"downloader\"][\"password\"] == \"********\"\n\n    def test_deeply_nested_dict(self):\n        \"\"\"Deeply nested sensitive keys are masked.\"\"\"\n        result = _sanitize_dict({\"level1\": {\"level2\": {\"api_key\": \"deep-secret\"}}})\n        assert result[\"level1\"][\"level2\"][\"api_key\"] == \"********\"\n\n    def test_non_string_value_not_masked(self):\n        \"\"\"Non-string values with sensitive-looking keys are NOT masked.\"\"\"\n        result = _sanitize_dict({\"password\": 12345})\n        # Only string values are masked; integers pass through\n        assert result[\"password\"] == 12345\n\n    def test_empty_dict(self):\n        \"\"\"Empty dict returns empty dict.\"\"\"\n        assert _sanitize_dict({}) == {}\n\n    def test_mixed_sensitive_and_plain(self):\n        \"\"\"Mix of sensitive and plain keys handled correctly.\"\"\"\n        result = _sanitize_dict(\n            {\n                \"username\": \"admin\",\n                \"password\": \"secret\",\n                \"host\": \"10.0.0.1\",\n                \"token\": \"jwt-abc\",\n            }\n        )\n        assert result[\"username\"] == \"admin\"\n        assert result[\"host\"] == \"10.0.0.1\"\n        assert result[\"password\"] == \"********\"\n        assert result[\"token\"] == \"********\"\n\n    def test_sanitize_list_of_dicts(self):\n        \"\"\"Lists containing dicts are recursed into.\"\"\"\n        result = _sanitize_dict(\n            {\n                \"providers\": [\n                    {\"type\": \"telegram\", \"token\": \"secret-token\"},\n                    {\"type\": \"bark\", \"token\": \"another-secret\"},\n                ]\n            }\n        )\n        assert result[\"providers\"][0][\"token\"] == \"********\"\n        assert result[\"providers\"][1][\"token\"] == \"********\"\n        assert result[\"providers\"][0][\"type\"] == \"telegram\"\n\n    def test_get_config_masks_sensitive_fields(self, authed_client):\n        \"\"\"GET /config/get response masks password and api_key fields.\"\"\"\n        test_config = Config()\n        with patch(\"module.api.config.settings\", test_config):\n            response = authed_client.get(\"/api/v1/config/get\")\n        assert response.status_code == 200\n        data = response.json()\n        # Downloader password should be masked\n        assert data[\"downloader\"][\"password\"] == \"********\"\n        # OpenAI api_key should be masked (it's an empty string but still masked)\n        assert data[\"experimental_openai\"][\"api_key\"] == \"********\"\n\n\n# ---------------------------------------------------------------------------\n# _restore_masked unit tests (#995)\n# ---------------------------------------------------------------------------\n\n\nclass TestRestoreMasked:\n    \"\"\"Issue #995: Masked passwords must not overwrite real credentials.\"\"\"\n\n    def test_masked_password_restored(self):\n        \"\"\"Masked password is replaced with the real stored value.\"\"\"\n        incoming = {\"password\": \"********\"}\n        current = {\"password\": \"real_secret\"}\n        _restore_masked(incoming, current)\n        assert incoming[\"password\"] == \"real_secret\"\n\n    def test_new_password_preserved(self):\n        \"\"\"Non-masked password value is kept as-is.\"\"\"\n        incoming = {\"password\": \"new_password\"}\n        current = {\"password\": \"old_password\"}\n        _restore_masked(incoming, current)\n        assert incoming[\"password\"] == \"new_password\"\n\n    def test_nested_masked_password_restored(self):\n        \"\"\"Masked password inside nested dict is restored.\"\"\"\n        incoming = {\"downloader\": {\"host\": \"10.0.0.1\", \"password\": \"********\"}}\n        current = {\"downloader\": {\"host\": \"10.0.0.1\", \"password\": \"adminadmin\"}}\n        _restore_masked(incoming, current)\n        assert incoming[\"downloader\"][\"password\"] == \"adminadmin\"\n\n    def test_nested_new_password_preserved(self):\n        \"\"\"Non-masked password inside nested dict is kept.\"\"\"\n        incoming = {\"downloader\": {\"password\": \"changed\"}}\n        current = {\"downloader\": {\"password\": \"old\"}}\n        _restore_masked(incoming, current)\n        assert incoming[\"downloader\"][\"password\"] == \"changed\"\n\n    def test_multiple_sensitive_fields(self):\n        \"\"\"All sensitive fields are handled independently.\"\"\"\n        incoming = {\n            \"downloader\": {\"password\": \"********\"},\n            \"proxy\": {\"password\": \"new_proxy_pass\"},\n            \"experimental_openai\": {\"api_key\": \"********\"},\n        }\n        current = {\n            \"downloader\": {\"password\": \"qb_pass\"},\n            \"proxy\": {\"password\": \"old_proxy_pass\"},\n            \"experimental_openai\": {\"api_key\": \"sk-real-key\"},\n        }\n        _restore_masked(incoming, current)\n        assert incoming[\"downloader\"][\"password\"] == \"qb_pass\"\n        assert incoming[\"proxy\"][\"password\"] == \"new_proxy_pass\"\n        assert incoming[\"experimental_openai\"][\"api_key\"] == \"sk-real-key\"\n\n    def test_non_sensitive_mask_value_untouched(self):\n        \"\"\"A non-sensitive key with '********' value is not modified.\"\"\"\n        incoming = {\"host\": \"********\"}\n        current = {\"host\": \"10.0.0.1\"}\n        _restore_masked(incoming, current)\n        assert incoming[\"host\"] == \"********\"\n\n    def test_list_of_dicts_restored(self):\n        \"\"\"Masked tokens inside list items are restored.\"\"\"\n        incoming = {\n            \"providers\": [\n                {\"type\": \"telegram\", \"token\": \"********\"},\n                {\"type\": \"bark\", \"token\": \"new-bark-token\"},\n            ]\n        }\n        current = {\n            \"providers\": [\n                {\"type\": \"telegram\", \"token\": \"real-tg-token\"},\n                {\"type\": \"bark\", \"token\": \"old-bark-token\"},\n            ]\n        }\n        _restore_masked(incoming, current)\n        assert incoming[\"providers\"][0][\"token\"] == \"real-tg-token\"\n        assert incoming[\"providers\"][1][\"token\"] == \"new-bark-token\"\n\n    def test_empty_dicts(self):\n        \"\"\"Empty dicts don't cause errors.\"\"\"\n        _restore_masked({}, {})\n\n    def test_round_trip_preserves_credentials(self):\n        \"\"\"Full round-trip: sanitize then restore recovers original values.\"\"\"\n        original = {\n            \"downloader\": {\"host\": \"10.0.0.1\", \"password\": \"secret123\"},\n            \"experimental_openai\": {\"api_key\": \"sk-abc\", \"model\": \"gpt-4\"},\n        }\n        sanitized = _sanitize_dict(original)\n        assert sanitized[\"downloader\"][\"password\"] == \"********\"\n        assert sanitized[\"experimental_openai\"][\"api_key\"] == \"********\"\n\n        _restore_masked(sanitized, original)\n        assert sanitized[\"downloader\"][\"password\"] == \"secret123\"\n        assert sanitized[\"experimental_openai\"][\"api_key\"] == \"sk-abc\"\n        assert sanitized[\"downloader\"][\"host\"] == \"10.0.0.1\"\n        assert sanitized[\"experimental_openai\"][\"model\"] == \"gpt-4\"\n\n    def test_update_config_preserves_password_when_masked(\n        self, authed_client, mock_settings\n    ):\n        \"\"\"PATCH /config/update must not overwrite a real password with '********'.\"\"\"\n        mock_settings.dict.return_value = {\n            \"program\": {\"rss_time\": 900, \"rename_time\": 60, \"webui_port\": 7892},\n            \"downloader\": {\n                \"type\": \"qbittorrent\",\n                \"host\": \"192.168.1.1:8080\",\n                \"username\": \"admin\",\n                \"password\": \"realpassword\",\n                \"path\": \"/downloads\",\n                \"ssl\": True,\n            },\n            \"rss_parser\": {\"enable\": True, \"filter\": [], \"language\": \"zh\"},\n            \"bangumi_manage\": {\n                \"enable\": True,\n                \"eps_complete\": False,\n                \"rename_method\": \"pn\",\n                \"group_tag\": False,\n                \"remove_bad_torrent\": False,\n            },\n            \"log\": {\"debug_enable\": False},\n            \"proxy\": {\n                \"enable\": False,\n                \"type\": \"http\",\n                \"host\": \"\",\n                \"port\": 0,\n                \"username\": \"\",\n                \"password\": \"\",\n            },\n            \"notification\": {\n                \"enable\": False,\n                \"type\": \"telegram\",\n                \"token\": \"\",\n                \"chat_id\": \"\",\n            },\n            \"experimental_openai\": {\n                \"enable\": False,\n                \"api_key\": \"\",\n                \"api_base\": \"https://api.openai.com/v1\",\n                \"api_type\": \"openai\",\n                \"api_version\": \"2023-05-15\",\n                \"model\": \"gpt-3.5-turbo\",\n                \"deployment_id\": \"\",\n            },\n        }\n        payload = {\n            \"program\": {\"rss_time\": 900, \"rename_time\": 60, \"webui_port\": 7892},\n            \"downloader\": {\n                \"type\": \"qbittorrent\",\n                \"host\": \"192.168.1.1:8080\",\n                \"username\": \"admin\",\n                \"password\": \"********\",\n                \"path\": \"/downloads\",\n                \"ssl\": False,\n            },\n            \"rss_parser\": {\"enable\": True, \"filter\": [], \"language\": \"zh\"},\n            \"bangumi_manage\": {\n                \"enable\": True,\n                \"eps_complete\": False,\n                \"rename_method\": \"pn\",\n                \"group_tag\": False,\n                \"remove_bad_torrent\": False,\n            },\n            \"log\": {\"debug_enable\": False},\n            \"proxy\": {\n                \"enable\": False,\n                \"type\": \"http\",\n                \"host\": \"\",\n                \"port\": 0,\n                \"username\": \"\",\n                \"password\": \"\",\n            },\n            \"notification\": {\n                \"enable\": False,\n                \"type\": \"telegram\",\n                \"token\": \"\",\n                \"chat_id\": \"\",\n            },\n            \"experimental_openai\": {\n                \"enable\": False,\n                \"api_key\": \"\",\n                \"api_base\": \"https://api.openai.com/v1\",\n                \"api_type\": \"openai\",\n                \"api_version\": \"2023-05-15\",\n                \"model\": \"gpt-3.5-turbo\",\n                \"deployment_id\": \"\",\n            },\n        }\n        with patch(\"module.api.config.settings\", mock_settings):\n            response = authed_client.patch(\"/api/v1/config/update\", json=payload)\n\n        assert response.status_code == 200\n        saved = mock_settings.save.call_args[1][\"config_dict\"]\n        assert saved[\"downloader\"][\"password\"] == \"realpassword\"\n        assert saved[\"downloader\"][\"ssl\"] is False\n"
  },
  {
    "path": "backend/src/test/test_api_downloader.py",
    "content": "\"\"\"Tests for Downloader API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, AsyncMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_download_client():\n    \"\"\"Mock DownloadClient as async context manager.\"\"\"\n    client = AsyncMock()\n    client.get_torrent_info.return_value = [\n        {\n            \"hash\": \"abc123\",\n            \"name\": \"[TestGroup] Test Anime - 01.mkv\",\n            \"state\": \"downloading\",\n            \"progress\": 0.5,\n        },\n        {\n            \"hash\": \"def456\",\n            \"name\": \"[TestGroup] Test Anime - 02.mkv\",\n            \"state\": \"completed\",\n            \"progress\": 1.0,\n        },\n    ]\n    client.pause_torrent.return_value = None\n    client.resume_torrent.return_value = None\n    client.delete_torrent.return_value = None\n    return client\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_torrents_unauthorized(self, unauthed_client):\n        \"\"\"GET /downloader/torrents without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/downloader/torrents\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_pause_torrents_unauthorized(self, unauthed_client):\n        \"\"\"POST /downloader/torrents/pause without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/downloader/torrents/pause\", json={\"hashes\": [\"abc123\"]}\n        )\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_resume_torrents_unauthorized(self, unauthed_client):\n        \"\"\"POST /downloader/torrents/resume without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/downloader/torrents/resume\", json={\"hashes\": [\"abc123\"]}\n        )\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_delete_torrents_unauthorized(self, unauthed_client):\n        \"\"\"POST /downloader/torrents/delete without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/downloader/torrents/delete\",\n            json={\"hashes\": [\"abc123\"], \"delete_files\": False},\n        )\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /downloader/torrents\n# ---------------------------------------------------------------------------\n\n\nclass TestGetTorrents:\n    def test_get_torrents_success(self, authed_client, mock_download_client):\n        \"\"\"GET /downloader/torrents returns list of torrents.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/downloader/torrents\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n        assert data[0][\"hash\"] == \"abc123\"\n\n    def test_get_torrents_empty(self, authed_client, mock_download_client):\n        \"\"\"GET /downloader/torrents returns empty list when no torrents.\"\"\"\n        mock_download_client.get_torrent_info.return_value = []\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/downloader/torrents\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n\n# ---------------------------------------------------------------------------\n# POST /downloader/torrents/pause\n# ---------------------------------------------------------------------------\n\n\nclass TestPauseTorrents:\n    def test_pause_single_torrent(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/pause pauses a single torrent.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/pause\", json={\"hashes\": [\"abc123\"]}\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Torrents paused\"\n        mock_download_client.pause_torrent.assert_called_once_with(\"abc123\")\n\n    def test_pause_multiple_torrents(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/pause pauses multiple torrents.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/pause\",\n                json={\"hashes\": [\"abc123\", \"def456\"]},\n            )\n\n        assert response.status_code == 200\n        # Hashes are joined with |\n        mock_download_client.pause_torrent.assert_called_once_with(\"abc123|def456\")\n\n\n# ---------------------------------------------------------------------------\n# POST /downloader/torrents/resume\n# ---------------------------------------------------------------------------\n\n\nclass TestResumeTorrents:\n    def test_resume_single_torrent(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/resume resumes a single torrent.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/resume\", json={\"hashes\": [\"abc123\"]}\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Torrents resumed\"\n        mock_download_client.resume_torrent.assert_called_once_with(\"abc123\")\n\n    def test_resume_multiple_torrents(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/resume resumes multiple torrents.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/resume\",\n                json={\"hashes\": [\"abc123\", \"def456\"]},\n            )\n\n        assert response.status_code == 200\n        mock_download_client.resume_torrent.assert_called_once_with(\"abc123|def456\")\n\n\n# ---------------------------------------------------------------------------\n# POST /downloader/torrents/delete\n# ---------------------------------------------------------------------------\n\n\nclass TestDeleteTorrents:\n    def test_delete_single_torrent_keep_files(\n        self, authed_client, mock_download_client\n    ):\n        \"\"\"POST /downloader/torrents/delete deletes torrent, keeps files.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/delete\",\n                json={\"hashes\": [\"abc123\"], \"delete_files\": False},\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Torrents deleted\"\n        mock_download_client.delete_torrent.assert_called_once_with(\n            \"abc123\", delete_files=False\n        )\n\n    def test_delete_torrent_with_files(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/delete deletes torrent and files.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/delete\",\n                json={\"hashes\": [\"abc123\"], \"delete_files\": True},\n            )\n\n        assert response.status_code == 200\n        mock_download_client.delete_torrent.assert_called_once_with(\n            \"abc123\", delete_files=True\n        )\n\n    def test_delete_multiple_torrents(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/delete deletes multiple torrents.\"\"\"\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/delete\",\n                json={\"hashes\": [\"abc123\", \"def456\"], \"delete_files\": False},\n            )\n\n        assert response.status_code == 200\n        mock_download_client.delete_torrent.assert_called_once_with(\n            \"abc123|def456\", delete_files=False\n        )\n\n\n# ---------------------------------------------------------------------------\n# POST /downloader/torrents/tag\n# ---------------------------------------------------------------------------\n\n\nclass TestTagTorrent:\n    def test_tag_torrent_success(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/tag adds bangumi tag to torrent.\"\"\"\n        from module.models import Bangumi\n\n        mock_bangumi = Bangumi(\n            id=123,\n            official_title=\"Test Anime\",\n            title_raw=\"Test\",\n            season=1,\n            rss_link=\"\",\n            poster_link=\"\",\n            added=False,\n            deleted=False,\n        )\n\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\"module.api.downloader.Database\") as MockDB:\n                mock_db = MockDB.return_value.__enter__.return_value\n                mock_db.bangumi.search_id.return_value = mock_bangumi\n\n                response = authed_client.post(\n                    \"/api/v1/downloader/torrents/tag\",\n                    json={\"hash\": \"abc123\", \"bangumi_id\": 123},\n                )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is True\n        assert \"ab:123\" in data[\"msg_en\"]\n        mock_download_client.add_tag.assert_called_once_with(\"abc123\", \"ab:123\")\n\n    def test_tag_torrent_bangumi_not_found(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/tag fails if bangumi doesn't exist.\"\"\"\n        with patch(\"module.api.downloader.Database\") as MockDB:\n            mock_db = MockDB.return_value.__enter__.return_value\n            mock_db.bangumi.search_id.return_value = None\n\n            response = authed_client.post(\n                \"/api/v1/downloader/torrents/tag\",\n                json={\"hash\": \"abc123\", \"bangumi_id\": 999},\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is False\n        assert \"not found\" in data[\"msg_en\"]\n\n\n# ---------------------------------------------------------------------------\n# POST /downloader/torrents/tag/auto\n# ---------------------------------------------------------------------------\n\n\nclass TestAutoTagTorrents:\n    def test_auto_tag_success(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/tag/auto tags untagged torrents.\"\"\"\n        from module.models import Bangumi\n\n        mock_bangumi = Bangumi(\n            id=123,\n            official_title=\"Test Anime\",\n            title_raw=\"Test Anime\",\n            season=1,\n            rss_link=\"\",\n            poster_link=\"\",\n            added=False,\n            deleted=False,\n        )\n\n        # Mock torrents - one untagged, one already tagged\n        mock_download_client.get_torrent_info.return_value = [\n            {\n                \"hash\": \"abc123\",\n                \"name\": \"[TestGroup] Test Anime - 01.mkv\",\n                \"save_path\": \"/downloads/Test Anime/Season 1\",\n                \"tags\": \"\",\n            },\n            {\n                \"hash\": \"def456\",\n                \"name\": \"[TestGroup] Other Anime - 01.mkv\",\n                \"save_path\": \"/downloads/Other Anime/Season 1\",\n                \"tags\": \"ab:456\",  # Already tagged\n            },\n        ]\n\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\"module.api.downloader.Database\") as MockDB:\n                mock_db = MockDB.return_value.__enter__.return_value\n                mock_db.bangumi.match_torrent.return_value = mock_bangumi\n                mock_db.bangumi.match_by_save_path.return_value = None\n\n                response = authed_client.post(\"/api/v1/downloader/torrents/tag/auto\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is True\n        assert data[\"tagged_count\"] == 1\n        # Only the untagged torrent should be tagged\n        mock_download_client.add_tag.assert_called_once_with(\"abc123\", \"ab:123\")\n\n    def test_auto_tag_no_matches(self, authed_client, mock_download_client):\n        \"\"\"POST /downloader/torrents/tag/auto handles unmatched torrents.\"\"\"\n        mock_download_client.get_torrent_info.return_value = [\n            {\n                \"hash\": \"abc123\",\n                \"name\": \"[TestGroup] Unknown Anime - 01.mkv\",\n                \"save_path\": \"/downloads/Unknown/Season 1\",\n                \"tags\": \"\",\n            },\n        ]\n\n        with patch(\"module.api.downloader.DownloadClient\") as MockClient:\n            MockClient.return_value.__aenter__ = AsyncMock(\n                return_value=mock_download_client\n            )\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\"module.api.downloader.Database\") as MockDB:\n                mock_db = MockDB.return_value.__enter__.return_value\n                mock_db.bangumi.match_torrent.return_value = None\n                mock_db.bangumi.match_by_save_path.return_value = None\n\n                response = authed_client.post(\"/api/v1/downloader/torrents/tag/auto\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is True\n        assert data[\"tagged_count\"] == 0\n        assert data[\"unmatched_count\"] == 1\n        assert len(data[\"unmatched\"]) == 1\n        mock_download_client.add_tag.assert_not_called()\n"
  },
  {
    "path": "backend/src/test/test_api_log.py",
    "content": "\"\"\"Tests for Log API endpoints.\"\"\"\n\nimport pytest\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom unittest.mock import patch\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture\ndef temp_log_file():\n    \"\"\"Create a temporary log file for testing.\"\"\"\n    with TemporaryDirectory() as temp_dir:\n        log_path = Path(temp_dir) / \"app.log\"\n        log_path.write_text(\"2024-01-01 12:00:00 INFO Test log entry\\n\")\n        yield log_path\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_log_unauthorized(self, unauthed_client):\n        \"\"\"GET /log without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/log\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_clear_log_unauthorized(self, unauthed_client):\n        \"\"\"GET /log/clear without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/log/clear\")\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /log\n# ---------------------------------------------------------------------------\n\n\nclass TestGetLog:\n    def test_get_log_success(self, authed_client, temp_log_file):\n        \"\"\"GET /log returns log content.\"\"\"\n        with patch(\"module.api.log.LOG_PATH\", temp_log_file):\n            response = authed_client.get(\"/api/v1/log\")\n\n        assert response.status_code == 200\n        assert \"Test log entry\" in response.text\n\n    def test_get_log_not_found(self, authed_client):\n        \"\"\"GET /log returns 404 when log file doesn't exist.\"\"\"\n        non_existent_path = Path(\"/nonexistent/path/app.log\")\n        with patch(\"module.api.log.LOG_PATH\", non_existent_path):\n            response = authed_client.get(\"/api/v1/log\")\n\n        assert response.status_code == 404\n\n    def test_get_log_multiline(self, authed_client, temp_log_file):\n        \"\"\"GET /log returns multiple log lines.\"\"\"\n        temp_log_file.write_text(\n            \"2024-01-01 12:00:00 INFO First entry\\n\"\n            \"2024-01-01 12:00:01 WARNING Second entry\\n\"\n            \"2024-01-01 12:00:02 ERROR Third entry\\n\"\n        )\n        with patch(\"module.api.log.LOG_PATH\", temp_log_file):\n            response = authed_client.get(\"/api/v1/log\")\n\n        assert response.status_code == 200\n        assert \"First entry\" in response.text\n        assert \"Second entry\" in response.text\n        assert \"Third entry\" in response.text\n\n\n# ---------------------------------------------------------------------------\n# GET /log/clear\n# ---------------------------------------------------------------------------\n\n\nclass TestClearLog:\n    def test_clear_log_success(self, authed_client, temp_log_file):\n        \"\"\"GET /log/clear clears the log file.\"\"\"\n        # Ensure file has content\n        temp_log_file.write_text(\"Some log content\")\n        assert temp_log_file.read_text() != \"\"\n\n        with patch(\"module.api.log.LOG_PATH\", temp_log_file):\n            response = authed_client.get(\"/api/v1/log/clear\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"msg_en\"] == \"Log cleared successfully.\"\n        assert temp_log_file.read_text() == \"\"\n\n    def test_clear_log_not_found(self, authed_client):\n        \"\"\"GET /log/clear returns 406 when log file doesn't exist.\"\"\"\n        non_existent_path = Path(\"/nonexistent/path/app.log\")\n        with patch(\"module.api.log.LOG_PATH\", non_existent_path):\n            response = authed_client.get(\"/api/v1/log/clear\")\n\n        assert response.status_code == 406\n        data = response.json()\n        assert data[\"msg_en\"] == \"Log file not found.\"\n"
  },
  {
    "path": "backend/src/test/test_api_passkey.py",
    "content": "\"\"\"Tests for Passkey (WebAuthn) API endpoints.\"\"\"\n\nfrom datetime import datetime\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import ResponseModel\nfrom module.models.passkey import Passkey\nfrom module.security.api import get_current_user\nfrom test.factories import make_passkey\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_webauthn():\n    \"\"\"Mock WebAuthn service.\"\"\"\n    service = MagicMock()\n    service.generate_registration_options.return_value = {\n        \"challenge\": \"dGVzdF9jaGFsbGVuZ2U\",\n        \"rp\": {\"name\": \"AutoBangumi\", \"id\": \"localhost\"},\n        \"user\": {\"id\": \"dXNlcl9pZA\", \"name\": \"testuser\", \"displayName\": \"testuser\"},\n        \"pubKeyCredParams\": [{\"type\": \"public-key\", \"alg\": -7}],\n        \"timeout\": 60000,\n        \"attestation\": \"none\",\n    }\n    service.generate_authentication_options.return_value = {\n        \"challenge\": \"dGVzdF9jaGFsbGVuZ2U\",\n        \"timeout\": 60000,\n        \"rpId\": \"localhost\",\n        \"allowCredentials\": [{\"type\": \"public-key\", \"id\": \"Y3JlZF9pZA\"}],\n    }\n    service.generate_discoverable_authentication_options.return_value = {\n        \"challenge\": \"dGVzdF9jaGFsbGVuZ2U\",\n        \"timeout\": 60000,\n        \"rpId\": \"localhost\",\n    }\n    mock_passkey = MagicMock()\n    mock_passkey.credential_id = \"cred_id\"\n    mock_passkey.public_key = \"public_key\"\n    mock_passkey.sign_count = 0\n    mock_passkey.name = \"Test Passkey\"\n    mock_passkey.user_id = 1\n    service.verify_registration.return_value = mock_passkey\n    service.verify_authentication.return_value = (True, 1)\n    return service\n\n\n@pytest.fixture\ndef mock_user_model():\n    \"\"\"Mock User model.\"\"\"\n    user = MagicMock()\n    user.id = 1\n    user.username = \"testuser\"\n    return user\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_register_options_unauthorized(self, unauthed_client):\n        \"\"\"POST /passkey/register/options without auth returns 401.\"\"\"\n        response = unauthed_client.post(\"/api/v1/passkey/register/options\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_register_verify_unauthorized(self, unauthed_client):\n        \"\"\"POST /passkey/register/verify without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/passkey/register/verify\",\n            json={\"name\": \"Test\", \"attestation_response\": {}},\n        )\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_list_passkeys_unauthorized(self, unauthed_client):\n        \"\"\"GET /passkey/list without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/passkey/list\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_delete_passkey_unauthorized(self, unauthed_client):\n        \"\"\"POST /passkey/delete without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/passkey/delete\", json={\"passkey_id\": 1}\n        )\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# POST /passkey/register/options\n# ---------------------------------------------------------------------------\n\n\nclass TestRegisterOptions:\n    def test_get_registration_options_success(\n        self, authed_client, mock_webauthn, mock_user_model\n    ):\n        \"\"\"POST /passkey/register/options returns registration options.\"\"\"\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n                mock_session = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar_one_or_none.return_value = mock_user_model\n                mock_session.execute = AsyncMock(return_value=mock_result)\n\n                mock_passkey_db = MagicMock()\n                mock_passkey_db.get_passkeys_by_user_id = AsyncMock(return_value=[])\n\n                MockSession.return_value.__aenter__ = AsyncMock(\n                    return_value=mock_session\n                )\n                MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                with patch(\n                    \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n                ):\n                    response = authed_client.post(\"/api/v1/passkey/register/options\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"challenge\" in data\n        assert \"rp\" in data\n        assert \"user\" in data\n\n    def test_get_registration_options_user_not_found(\n        self, authed_client, mock_webauthn\n    ):\n        \"\"\"POST /passkey/register/options with non-existent user returns 404.\"\"\"\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n                mock_session = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar_one_or_none.return_value = None\n                mock_session.execute = AsyncMock(return_value=mock_result)\n\n                MockSession.return_value.__aenter__ = AsyncMock(\n                    return_value=mock_session\n                )\n                MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                response = authed_client.post(\"/api/v1/passkey/register/options\")\n\n        assert response.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# POST /passkey/register/verify\n# ---------------------------------------------------------------------------\n\n\nclass TestRegisterVerify:\n    def test_verify_registration_success(\n        self, authed_client, mock_webauthn, mock_user_model\n    ):\n        \"\"\"POST /passkey/register/verify successfully registers passkey.\"\"\"\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n                mock_session = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar_one_or_none.return_value = mock_user_model\n                mock_session.execute = AsyncMock(return_value=mock_result)\n\n                mock_passkey_db = MagicMock()\n                mock_passkey_db.create_passkey = AsyncMock()\n\n                MockSession.return_value.__aenter__ = AsyncMock(\n                    return_value=mock_session\n                )\n                MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                with patch(\n                    \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n                ):\n                    response = authed_client.post(\n                        \"/api/v1/passkey/register/verify\",\n                        json={\n                            \"name\": \"My iPhone\",\n                            \"attestation_response\": {\n                                \"id\": \"credential_id\",\n                                \"rawId\": \"raw_id\",\n                                \"response\": {\n                                    \"clientDataJSON\": \"data\",\n                                    \"attestationObject\": \"object\",\n                                },\n                                \"type\": \"public-key\",\n                            },\n                        },\n                    )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"msg_en\" in data\n        assert \"registered successfully\" in data[\"msg_en\"]\n\n\n# ---------------------------------------------------------------------------\n# POST /passkey/auth/options (no auth required)\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthOptions:\n    def test_get_auth_options_with_username(self, unauthed_client, mock_webauthn):\n        \"\"\"POST /passkey/auth/options with username returns auth options.\"\"\"\n        mock_user = MagicMock()\n        mock_user.id = 1\n\n        mock_passkeys = [make_passkey()]\n\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n                mock_session = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar_one_or_none.return_value = mock_user\n                mock_session.execute = AsyncMock(return_value=mock_result)\n\n                mock_passkey_db = MagicMock()\n                mock_passkey_db.get_passkeys_by_user_id = AsyncMock(\n                    return_value=mock_passkeys\n                )\n\n                MockSession.return_value.__aenter__ = AsyncMock(\n                    return_value=mock_session\n                )\n                MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                with patch(\n                    \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n                ):\n                    response = unauthed_client.post(\n                        \"/api/v1/passkey/auth/options\", json={\"username\": \"testuser\"}\n                    )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"challenge\" in data\n\n    def test_get_auth_options_discoverable(self, unauthed_client, mock_webauthn):\n        \"\"\"POST /passkey/auth/options without username returns discoverable options.\"\"\"\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            response = unauthed_client.post(\n                \"/api/v1/passkey/auth/options\", json={\"username\": None}\n            )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"challenge\" in data\n\n    def test_get_auth_options_user_not_found(self, unauthed_client, mock_webauthn):\n        \"\"\"POST /passkey/auth/options with non-existent user returns 404.\"\"\"\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n                mock_session = AsyncMock()\n                mock_result = MagicMock()\n                mock_result.scalar_one_or_none.return_value = None\n                mock_session.execute = AsyncMock(return_value=mock_result)\n\n                MockSession.return_value.__aenter__ = AsyncMock(\n                    return_value=mock_session\n                )\n                MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                response = unauthed_client.post(\n                    \"/api/v1/passkey/auth/options\", json={\"username\": \"nonexistent\"}\n                )\n\n        assert response.status_code == 404\n\n\n# ---------------------------------------------------------------------------\n# POST /passkey/auth/verify (no auth required)\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthVerify:\n    def test_login_with_passkey_success(self, unauthed_client, mock_webauthn):\n        \"\"\"POST /passkey/auth/verify with valid passkey logs in.\"\"\"\n        mock_response = ResponseModel(\n            status=True,\n            status_code=200,\n            msg_en=\"OK\",\n            msg_zh=\"成功\",\n            data={\"username\": \"testuser\"},\n        )\n        mock_strategy = MagicMock()\n        mock_strategy.authenticate = AsyncMock(return_value=mock_response)\n\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\n                \"module.api.passkey.PasskeyAuthStrategy\", return_value=mock_strategy\n            ):\n                with patch(\"module.api.passkey.active_user\", {}):\n                    response = unauthed_client.post(\n                        \"/api/v1/passkey/auth/verify\",\n                        json={\n                            \"username\": \"testuser\",\n                            \"credential\": {\n                                \"id\": \"cred_id\",\n                                \"rawId\": \"raw_id\",\n                                \"response\": {\n                                    \"clientDataJSON\": \"data\",\n                                    \"authenticatorData\": \"auth_data\",\n                                    \"signature\": \"sig\",\n                                },\n                                \"type\": \"public-key\",\n                            },\n                        },\n                    )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"access_token\" in data\n\n    def test_login_with_passkey_failure(self, unauthed_client, mock_webauthn):\n        \"\"\"POST /passkey/auth/verify with invalid passkey fails.\"\"\"\n        mock_response = ResponseModel(\n            status=False, status_code=401, msg_en=\"Invalid passkey\", msg_zh=\"无效的凭证\"\n        )\n        mock_strategy = MagicMock()\n        mock_strategy.authenticate = AsyncMock(return_value=mock_response)\n\n        with patch(\n            \"module.api.passkey._get_webauthn_from_request\", return_value=mock_webauthn\n        ):\n            with patch(\n                \"module.api.passkey.PasskeyAuthStrategy\", return_value=mock_strategy\n            ):\n                response = unauthed_client.post(\n                    \"/api/v1/passkey/auth/verify\",\n                    json={\n                        \"username\": \"testuser\",\n                        \"credential\": {\n                            \"id\": \"invalid_cred\",\n                            \"rawId\": \"raw_id\",\n                            \"response\": {\n                                \"clientDataJSON\": \"data\",\n                                \"authenticatorData\": \"auth_data\",\n                                \"signature\": \"invalid_sig\",\n                            },\n                            \"type\": \"public-key\",\n                        },\n                    },\n                )\n\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /passkey/list\n# ---------------------------------------------------------------------------\n\n\nclass TestListPasskeys:\n    def test_list_passkeys_success(self, authed_client, mock_user_model):\n        \"\"\"GET /passkey/list returns user's passkeys.\"\"\"\n        passkeys = [\n            make_passkey(id=1, name=\"iPhone\"),\n            make_passkey(id=2, name=\"MacBook\"),\n        ]\n\n        with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n            mock_session = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_user_model\n            mock_session.execute = AsyncMock(return_value=mock_result)\n\n            mock_passkey_db = MagicMock()\n            mock_passkey_db.get_passkeys_by_user_id = AsyncMock(return_value=passkeys)\n            mock_passkey_db.to_list_model = MagicMock(\n                side_effect=lambda pk: {\n                    \"id\": pk.id,\n                    \"name\": pk.name,\n                    \"created_at\": pk.created_at.isoformat(),\n                    \"last_used_at\": None,\n                    \"backup_eligible\": pk.backup_eligible,\n                    \"aaguid\": pk.aaguid,\n                }\n            )\n\n            MockSession.return_value.__aenter__ = AsyncMock(return_value=mock_session)\n            MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\n                \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n            ):\n                response = authed_client.get(\"/api/v1/passkey/list\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n\n    def test_list_passkeys_empty(self, authed_client, mock_user_model):\n        \"\"\"GET /passkey/list with no passkeys returns empty list.\"\"\"\n        with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n            mock_session = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_user_model\n            mock_session.execute = AsyncMock(return_value=mock_result)\n\n            mock_passkey_db = MagicMock()\n            mock_passkey_db.get_passkeys_by_user_id = AsyncMock(return_value=[])\n\n            MockSession.return_value.__aenter__ = AsyncMock(return_value=mock_session)\n            MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\n                \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n            ):\n                response = authed_client.get(\"/api/v1/passkey/list\")\n\n        assert response.status_code == 200\n        assert response.json() == []\n\n\n# ---------------------------------------------------------------------------\n# POST /passkey/delete\n# ---------------------------------------------------------------------------\n\n\nclass TestDeletePasskey:\n    def test_delete_passkey_success(self, authed_client, mock_user_model):\n        \"\"\"POST /passkey/delete successfully deletes passkey.\"\"\"\n        with patch(\"module.api.passkey.async_session_factory\") as MockSession:\n            mock_session = AsyncMock()\n            mock_result = MagicMock()\n            mock_result.scalar_one_or_none.return_value = mock_user_model\n            mock_session.execute = AsyncMock(return_value=mock_result)\n\n            mock_passkey_db = MagicMock()\n            mock_passkey_db.delete_passkey = AsyncMock()\n\n            MockSession.return_value.__aenter__ = AsyncMock(return_value=mock_session)\n            MockSession.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\n                \"module.api.passkey.PasskeyDatabase\", return_value=mock_passkey_db\n            ):\n                response = authed_client.post(\n                    \"/api/v1/passkey/delete\", json={\"passkey_id\": 1}\n                )\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"deleted successfully\" in data[\"msg_en\"]\n"
  },
  {
    "path": "backend/src/test/test_api_program.py",
    "content": "\"\"\"Tests for Program API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import ResponseModel\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_program():\n    \"\"\"Mock Program instance.\"\"\"\n    program = MagicMock()\n    program.is_running = True\n    program.first_run = False\n    program.start = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Started.\", msg_zh=\"已启动。\"\n        )\n    )\n    program.stop = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Stopped.\", msg_zh=\"已停止。\"\n        )\n    )\n    program.restart = AsyncMock(\n        return_value=ResponseModel(\n            status=True, status_code=200, msg_en=\"Restarted.\", msg_zh=\"已重启。\"\n        )\n    )\n    program.check_downloader = AsyncMock(return_value=True)\n    return program\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_restart_unauthorized(self, unauthed_client):\n        \"\"\"GET /restart without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/restart\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_start_unauthorized(self, unauthed_client):\n        \"\"\"GET /start without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/start\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_stop_unauthorized(self, unauthed_client):\n        \"\"\"GET /stop without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/stop\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_status_unauthorized(self, unauthed_client):\n        \"\"\"GET /status without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/status\")\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /start\n# ---------------------------------------------------------------------------\n\n\nclass TestStartProgram:\n    def test_start_success(self, authed_client, mock_program):\n        \"\"\"GET /start returns success response.\"\"\"\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/start\")\n\n        assert response.status_code == 200\n\n    def test_start_failure(self, authed_client, mock_program):\n        \"\"\"GET /start handles exceptions.\"\"\"\n        mock_program.start = AsyncMock(side_effect=Exception(\"Start failed\"))\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/start\")\n\n        assert response.status_code == 500\n\n\n# ---------------------------------------------------------------------------\n# GET /stop\n# ---------------------------------------------------------------------------\n\n\nclass TestStopProgram:\n    def test_stop_success(self, authed_client, mock_program):\n        \"\"\"GET /stop returns success response.\"\"\"\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/stop\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# GET /restart\n# ---------------------------------------------------------------------------\n\n\nclass TestRestartProgram:\n    def test_restart_success(self, authed_client, mock_program):\n        \"\"\"GET /restart returns success response.\"\"\"\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/restart\")\n\n        assert response.status_code == 200\n\n    def test_restart_failure(self, authed_client, mock_program):\n        \"\"\"GET /restart handles exceptions.\"\"\"\n        mock_program.restart = AsyncMock(side_effect=Exception(\"Restart failed\"))\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/restart\")\n\n        assert response.status_code == 500\n\n\n# ---------------------------------------------------------------------------\n# GET /status\n# ---------------------------------------------------------------------------\n\n\nclass TestProgramStatus:\n    def test_status_running(self, authed_client, mock_program):\n        \"\"\"GET /status returns running status.\"\"\"\n        mock_program.is_running = True\n        mock_program.first_run = False\n        with patch(\"module.api.program.program\", mock_program):\n            with patch(\"module.api.program.VERSION\", \"3.2.0\"):\n                response = authed_client.get(\"/api/v1/status\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is True\n        assert data[\"version\"] == \"3.2.0\"\n        assert data[\"first_run\"] is False\n\n    def test_status_stopped(self, authed_client, mock_program):\n        \"\"\"GET /status returns stopped status.\"\"\"\n        mock_program.is_running = False\n        mock_program.first_run = True\n        with patch(\"module.api.program.program\", mock_program):\n            with patch(\"module.api.program.VERSION\", \"3.2.0\"):\n                response = authed_client.get(\"/api/v1/status\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"status\"] is False\n        assert data[\"first_run\"] is True\n\n\n# ---------------------------------------------------------------------------\n# GET /check/downloader\n# ---------------------------------------------------------------------------\n\n\nclass TestCheckDownloader:\n    def test_check_downloader_connected(self, authed_client, mock_program):\n        \"\"\"GET /check/downloader returns True when connected.\"\"\"\n        mock_program.check_downloader = AsyncMock(return_value=True)\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/check/downloader\")\n\n        assert response.status_code == 200\n        assert response.json() is True\n\n    def test_check_downloader_disconnected(self, authed_client, mock_program):\n        \"\"\"GET /check/downloader returns False when disconnected.\"\"\"\n        mock_program.check_downloader = AsyncMock(return_value=False)\n        with patch(\"module.api.program.program\", mock_program):\n            response = authed_client.get(\"/api/v1/check/downloader\")\n\n        assert response.status_code == 200\n        assert response.json() is False\n"
  },
  {
    "path": "backend/src/test/test_api_rss.py",
    "content": "\"\"\"Tests for RSS API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.models import RSSItem, RSSUpdate, ResponseModel, Torrent\nfrom module.security.api import get_current_user\n\nfrom test.factories import make_rss_item, make_torrent\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_rss_unauthorized(self, unauthed_client):\n        \"\"\"GET /rss without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/rss\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_add_rss_unauthorized(self, unauthed_client):\n        \"\"\"POST /rss/add without auth returns 401.\"\"\"\n        response = unauthed_client.post(\n            \"/api/v1/rss/add\", json={\"url\": \"https://test.com\"}\n        )\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /rss\n# ---------------------------------------------------------------------------\n\n\nclass TestGetRss:\n    def test_get_all(self, authed_client):\n        \"\"\"GET /rss returns list of RSSItems.\"\"\"\n        items = [\n            make_rss_item(id=1, name=\"Feed 1\"),\n            make_rss_item(id=2, name=\"Feed 2\"),\n        ]\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.search_all.return_value = items\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/rss\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n\n\n# ---------------------------------------------------------------------------\n# POST /rss/add\n# ---------------------------------------------------------------------------\n\n\nclass TestAddRss:\n    def test_add_success(self, authed_client):\n        \"\"\"POST /rss/add creates a new RSS feed.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Added.\", msg_zh=\"添加成功。\"\n        )\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.add_rss = AsyncMock(return_value=resp_model)\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\n                \"/api/v1/rss/add\",\n                json={\n                    \"url\": \"https://mikanani.me/RSS/test\",\n                    \"name\": \"Test Feed\",\n                    \"aggregate\": True,\n                    \"parser\": \"mikan\",\n                },\n            )\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# DELETE /rss/delete/{id}\n# ---------------------------------------------------------------------------\n\n\nclass TestDeleteRss:\n    def test_delete_success(self, authed_client):\n        \"\"\"DELETE /rss/delete/{id} removes the feed.\"\"\"\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.delete.return_value = True\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.delete(\"/api/v1/rss/delete/1\")\n\n        assert response.status_code == 200\n\n    def test_delete_failure(self, authed_client):\n        \"\"\"DELETE /rss/delete/{id} returns 406 when feed not found.\"\"\"\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.delete.return_value = False\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.delete(\"/api/v1/rss/delete/999\")\n\n        assert response.status_code == 406\n\n\n# ---------------------------------------------------------------------------\n# PATCH /rss/disable/{id}\n# ---------------------------------------------------------------------------\n\n\nclass TestDisableRss:\n    def test_disable_success(self, authed_client):\n        \"\"\"PATCH /rss/disable/{id} disables the feed.\"\"\"\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.disable.return_value = True\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.patch(\"/api/v1/rss/disable/1\")\n\n        assert response.status_code == 200\n\n    def test_disable_failure(self, authed_client):\n        \"\"\"PATCH /rss/disable/{id} returns 406 when feed not found.\"\"\"\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.disable.return_value = False\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.patch(\"/api/v1/rss/disable/999\")\n\n        assert response.status_code == 406\n\n\n# ---------------------------------------------------------------------------\n# POST /rss/enable/many, /rss/disable/many, /rss/delete/many\n# ---------------------------------------------------------------------------\n\n\nclass TestBatchOperations:\n    def test_enable_many(self, authed_client):\n        \"\"\"POST /rss/enable/many enables multiple feeds.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Enabled.\", msg_zh=\"启用成功。\"\n        )\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.enable_list.return_value = resp_model\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\"/api/v1/rss/enable/many\", json=[1, 2, 3])\n\n        assert response.status_code == 200\n\n    def test_disable_many(self, authed_client):\n        \"\"\"POST /rss/disable/many disables multiple feeds.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Disabled.\", msg_zh=\"禁用成功。\"\n        )\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.disable_list.return_value = resp_model\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\"/api/v1/rss/disable/many\", json=[1, 2])\n\n        assert response.status_code == 200\n\n    def test_delete_many(self, authed_client):\n        \"\"\"POST /rss/delete/many deletes multiple feeds.\"\"\"\n        resp_model = ResponseModel(\n            status=True, status_code=200, msg_en=\"Deleted.\", msg_zh=\"删除成功。\"\n        )\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.delete_list.return_value = resp_model\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.post(\"/api/v1/rss/delete/many\", json=[1, 2])\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# PATCH /rss/update/{id}\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateRss:\n    def test_update_success(self, authed_client):\n        \"\"\"PATCH /rss/update/{id} updates feed.\"\"\"\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.rss.update.return_value = True\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.patch(\n                \"/api/v1/rss/update/1\",\n                json={\"name\": \"Updated Name\", \"aggregate\": False},\n            )\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# GET /rss/refresh/*\n# ---------------------------------------------------------------------------\n\n\nclass TestRefreshRss:\n    def test_refresh_all(self, authed_client):\n        \"\"\"GET /rss/refresh/all triggers engine.refresh_rss.\"\"\"\n        with patch(\"module.api.rss.DownloadClient\") as MockClient:\n            mock_client = AsyncMock()\n            MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n            with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n                mock_eng = MagicMock()\n                mock_eng.refresh_rss = AsyncMock()\n                MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n                MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n                response = authed_client.get(\"/api/v1/rss/refresh/all\")\n\n        assert response.status_code == 200\n\n    def test_refresh_single(self, authed_client):\n        \"\"\"GET /rss/refresh/{id} refreshes specific feed.\"\"\"\n        with patch(\"module.api.rss.DownloadClient\") as MockClient:\n            mock_client = AsyncMock()\n            MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_client)\n            MockClient.return_value.__aexit__ = AsyncMock(return_value=False)\n            with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n                mock_eng = MagicMock()\n                mock_eng.refresh_rss = AsyncMock()\n                MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n                MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n                response = authed_client.get(\"/api/v1/rss/refresh/1\")\n\n        assert response.status_code == 200\n\n\n# ---------------------------------------------------------------------------\n# GET /rss/torrent/{id}\n# ---------------------------------------------------------------------------\n\n\nclass TestGetRssTorrents:\n    def test_get_torrents(self, authed_client):\n        \"\"\"GET /rss/torrent/{id} returns torrents for that feed.\"\"\"\n        torrents = [make_torrent(id=1, rss_id=1), make_torrent(id=2, rss_id=1)]\n        with patch(\"module.api.rss.RSSEngine\") as MockEngine:\n            mock_eng = MagicMock()\n            mock_eng.get_rss_torrents.return_value = torrents\n            MockEngine.return_value.__enter__ = MagicMock(return_value=mock_eng)\n            MockEngine.return_value.__exit__ = MagicMock(return_value=False)\n\n            response = authed_client.get(\"/api/v1/rss/torrent/1\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert len(data) == 2\n"
  },
  {
    "path": "backend/src/test/test_api_search.py",
    "content": "\"\"\"Tests for Search API endpoints.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch, MagicMock, AsyncMock\n\nfrom fastapi import FastAPI\nfrom fastapi.testclient import TestClient\n\nfrom module.api import v1\nfrom module.security.api import get_current_user\n\n\n# ---------------------------------------------------------------------------\n# Fixtures\n# ---------------------------------------------------------------------------\n\n\n@pytest.fixture\ndef app():\n    \"\"\"Create a FastAPI app with v1 routes for testing.\"\"\"\n    app = FastAPI()\n    app.include_router(v1, prefix=\"/api\")\n    return app\n\n\n@pytest.fixture\ndef authed_client(app):\n    \"\"\"TestClient with auth dependency overridden.\"\"\"\n\n    async def mock_user():\n        return \"testuser\"\n\n    app.dependency_overrides[get_current_user] = mock_user\n    client = TestClient(app)\n    yield client\n    app.dependency_overrides.clear()\n\n\n@pytest.fixture\ndef unauthed_client(app):\n    \"\"\"TestClient without auth (no override).\"\"\"\n    return TestClient(app)\n\n\n# ---------------------------------------------------------------------------\n# Auth requirement\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthRequired:\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_search_bangumi_unauthorized(self, unauthed_client):\n        \"\"\"GET /search/bangumi without auth returns 401.\"\"\"\n        response = unauthed_client.get(\n            \"/api/v1/search/bangumi\", params={\"keywords\": \"test\"}\n        )\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_search_provider_unauthorized(self, unauthed_client):\n        \"\"\"GET /search/provider without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/search/provider\")\n        assert response.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_get_provider_config_unauthorized(self, unauthed_client):\n        \"\"\"GET /search/provider/config without auth returns 401.\"\"\"\n        response = unauthed_client.get(\"/api/v1/search/provider/config\")\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /search/bangumi (SSE endpoint)\n# ---------------------------------------------------------------------------\n\n\nclass TestSearchBangumi:\n    def test_search_no_keywords(self, authed_client):\n        \"\"\"GET /search/bangumi without keywords returns empty list.\"\"\"\n        response = authed_client.get(\"/api/v1/search/bangumi\")\n        # SSE endpoint returns EventSourceResponse for empty\n        assert response.status_code == 200\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    def test_search_with_keywords_auth_required(self, unauthed_client):\n        \"\"\"GET /search/bangumi requires authentication.\"\"\"\n        response = unauthed_client.get(\n            \"/api/v1/search/bangumi\",\n            params={\"site\": \"mikan\", \"keywords\": \"Test Anime\"},\n        )\n        assert response.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# GET /search/provider\n# ---------------------------------------------------------------------------\n\n\nclass TestSearchProvider:\n    def test_get_provider_list(self, authed_client):\n        \"\"\"GET /search/provider returns list of available providers.\"\"\"\n        mock_config = {\"mikan\": \"url1\", \"dmhy\": \"url2\", \"nyaa\": \"url3\"}\n        with patch(\"module.api.search.SEARCH_CONFIG\", mock_config):\n            response = authed_client.get(\"/api/v1/search/provider\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"mikan\" in data\n        assert \"dmhy\" in data\n        assert \"nyaa\" in data\n\n\n# ---------------------------------------------------------------------------\n# GET /search/provider/config\n# ---------------------------------------------------------------------------\n\n\nclass TestSearchProviderConfig:\n    def test_get_provider_config(self, authed_client):\n        \"\"\"GET /search/provider/config returns provider configurations.\"\"\"\n        mock_providers = {\n            \"mikan\": \"https://mikanani.me/RSS/Search?searchstr={keyword}\",\n            \"dmhy\": \"https://share.dmhy.org/search?keyword={keyword}\",\n        }\n        with patch(\"module.api.search.get_provider\", return_value=mock_providers):\n            response = authed_client.get(\"/api/v1/search/provider/config\")\n\n        assert response.status_code == 200\n        data = response.json()\n        assert \"mikan\" in data\n        assert \"dmhy\" in data\n\n\n# ---------------------------------------------------------------------------\n# PUT /search/provider/config\n# ---------------------------------------------------------------------------\n\n\nclass TestUpdateProviderConfig:\n    def test_update_provider_config_success(self, authed_client):\n        \"\"\"PUT /search/provider/config updates provider configurations.\"\"\"\n        new_config = {\n            \"mikan\": \"https://mikanani.me/RSS/Search?searchstr={keyword}\",\n            \"custom\": \"https://custom.site/search?q={keyword}\",\n        }\n        with patch(\"module.api.search.save_provider\") as mock_save:\n            with patch(\"module.api.search.get_provider\", return_value=new_config):\n                response = authed_client.put(\n                    \"/api/v1/search/provider/config\", json=new_config\n                )\n\n        assert response.status_code == 200\n        mock_save.assert_called_once_with(new_config)\n        data = response.json()\n        assert \"mikan\" in data\n        assert \"custom\" in data\n\n    def test_update_provider_config_empty(self, authed_client):\n        \"\"\"PUT /search/provider/config with empty config.\"\"\"\n        with patch(\"module.api.search.save_provider\") as mock_save:\n            with patch(\"module.api.search.get_provider\", return_value={}):\n                response = authed_client.put(\"/api/v1/search/provider/config\", json={})\n\n        assert response.status_code == 200\n        mock_save.assert_called_once_with({})\n"
  },
  {
    "path": "backend/src/test/test_auth.py",
    "content": "\"\"\"Tests for authentication: JWT tokens, password hashing, login flow.\"\"\"\n\nfrom datetime import timedelta\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\nfrom jose import JWTError\n\nfrom module.security.jwt import (\n    create_access_token,\n    decode_token,\n    get_password_hash,\n    verify_password,\n    verify_token,\n)\n\n# ---------------------------------------------------------------------------\n# JWT Token Creation\n# ---------------------------------------------------------------------------\n\n\nclass TestCreateAccessToken:\n    def test_creates_valid_token(self):\n        \"\"\"create_access_token returns a decodable JWT with sub claim.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        assert token is not None\n        assert isinstance(token, str)\n        assert len(token) > 0\n\n    def test_token_contains_sub_claim(self):\n        \"\"\"Decoded token contains the 'sub' field.\"\"\"\n        token = create_access_token(data={\"sub\": \"myuser\"})\n        payload = decode_token(token)\n        assert payload is not None\n        assert payload[\"sub\"] == \"myuser\"\n\n    def test_token_contains_exp_claim(self):\n        \"\"\"Decoded token contains 'exp' expiration field.\"\"\"\n        token = create_access_token(data={\"sub\": \"user\"})\n        payload = decode_token(token)\n        assert \"exp\" in payload\n\n    def test_custom_expiry(self):\n        \"\"\"Custom expires_delta is respected.\"\"\"\n        token = create_access_token(\n            data={\"sub\": \"user\"}, expires_delta=timedelta(hours=2)\n        )\n        payload = decode_token(token)\n        assert payload is not None\n\n\n# ---------------------------------------------------------------------------\n# Token Decoding\n# ---------------------------------------------------------------------------\n\n\nclass TestDecodeToken:\n    def test_valid_token(self):\n        \"\"\"decode_token returns payload for valid token.\"\"\"\n        token = create_access_token(data={\"sub\": \"testuser\"})\n        result = decode_token(token)\n        assert result is not None\n        assert result[\"sub\"] == \"testuser\"\n\n    def test_invalid_token(self):\n        \"\"\"decode_token returns None for invalid/garbage token.\"\"\"\n        result = decode_token(\"not.a.valid.jwt.token\")\n        assert result is None\n\n    def test_empty_token(self):\n        \"\"\"decode_token returns None for empty string.\"\"\"\n        result = decode_token(\"\")\n        assert result is None\n\n    def test_missing_sub_claim(self):\n        \"\"\"decode_token returns None when 'sub' claim is missing.\"\"\"\n        token = create_access_token(data={\"other\": \"data\"})\n        result = decode_token(token)\n        # sub is None so decode_token returns None\n        assert result is None\n\n\n# ---------------------------------------------------------------------------\n# Token Verification\n# ---------------------------------------------------------------------------\n\n\nclass TestVerifyToken:\n    def test_valid_fresh_token(self):\n        \"\"\"verify_token succeeds for a fresh token.\"\"\"\n        token = create_access_token(\n            data={\"sub\": \"user\"}, expires_delta=timedelta(hours=1)\n        )\n        result = verify_token(token)\n        assert result is not None\n        assert result[\"sub\"] == \"user\"\n\n    def test_expired_token_returns_none(self):\n        \"\"\"verify_token returns None for expired token (caught by decode_token).\"\"\"\n        token = create_access_token(\n            data={\"sub\": \"user\"}, expires_delta=timedelta(seconds=-10)\n        )\n        # python-jose catches expired tokens during decode, so decode_token\n        # returns None, and verify_token propagates that as None\n        result = verify_token(token)\n        assert result is None\n\n    def test_invalid_token_returns_none(self):\n        \"\"\"verify_token returns None for invalid token (decode fails).\"\"\"\n        result = verify_token(\"garbage.token.string\")\n        assert result is None\n\n\n# ---------------------------------------------------------------------------\n# Password Hashing\n# ---------------------------------------------------------------------------\n\n\nclass TestPasswordHashing:\n    def test_hash_and_verify_roundtrip(self):\n        \"\"\"get_password_hash then verify_password returns True.\"\"\"\n        password = \"my_secure_password\"\n        hashed = get_password_hash(password)\n        assert verify_password(password, hashed) is True\n\n    def test_wrong_password(self):\n        \"\"\"verify_password with wrong password returns False.\"\"\"\n        hashed = get_password_hash(\"correct_password\")\n        assert verify_password(\"wrong_password\", hashed) is False\n\n    def test_hash_is_not_plaintext(self):\n        \"\"\"Hash is not equal to the plaintext password.\"\"\"\n        password = \"my_password\"\n        hashed = get_password_hash(password)\n        assert hashed != password\n\n    def test_different_hashes_for_same_password(self):\n        \"\"\"Bcrypt produces different hashes for the same password (salt).\"\"\"\n        password = \"same_password\"\n        hash1 = get_password_hash(password)\n        hash2 = get_password_hash(password)\n        assert hash1 != hash2\n        # Both still verify correctly\n        assert verify_password(password, hash1) is True\n        assert verify_password(password, hash2) is True\n\n\n# ---------------------------------------------------------------------------\n# API Auth Flow (get_current_user)\n# ---------------------------------------------------------------------------\n\n\nclass TestGetCurrentUser:\n    @staticmethod\n    def _mock_request(authorization=\"\"):\n        \"\"\"Create a mock Request with the given Authorization header.\"\"\"\n        from unittest.mock import MagicMock\n\n        request = MagicMock()\n        request.headers = {\"authorization\": authorization}\n        return request\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_no_cookie_raises_401(self):\n        \"\"\"get_current_user raises 401 when no token cookie.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import get_current_user\n\n        with pytest.raises(HTTPException) as exc_info:\n            await get_current_user(request=self._mock_request(), token=None)\n        assert exc_info.value.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_invalid_token_raises_401(self):\n        \"\"\"get_current_user raises 401 for invalid token.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import get_current_user\n\n        with pytest.raises(HTTPException) as exc_info:\n            await get_current_user(request=self._mock_request(), token=\"invalid.jwt.token\")\n        assert exc_info.value.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_valid_token_user_not_active(self):\n        \"\"\"get_current_user raises 401 when user not in active_user list.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import active_user, get_current_user\n\n        token = create_access_token(\n            data={\"sub\": \"ghost_user\"}, expires_delta=timedelta(hours=1)\n        )\n        active_user.clear()\n\n        with pytest.raises(HTTPException) as exc_info:\n            await get_current_user(request=self._mock_request(), token=token)\n        assert exc_info.value.status_code == 401\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_valid_token_active_user_succeeds(self):\n        \"\"\"get_current_user returns username for valid token + active user.\"\"\"\n        from datetime import datetime\n\n        from module.security.api import active_user, get_current_user\n\n        token = create_access_token(\n            data={\"sub\": \"active_user\"}, expires_delta=timedelta(hours=1)\n        )\n        active_user.clear()\n        active_user[\"active_user\"] = datetime.now()\n\n        result = await get_current_user(request=self._mock_request(), token=token)\n        assert result == \"active_user\"\n\n        # Cleanup\n        active_user.clear()\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", True)\n    async def test_dev_bypass_skips_auth(self):\n        \"\"\"When DEV_AUTH_BYPASS is True, get_current_user returns 'dev_user' unconditionally.\"\"\"\n        from module.security.api import get_current_user\n\n        result = await get_current_user(request=self._mock_request(), token=None)\n        assert result == \"dev_user\"\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_bearer_token_bypass_valid(self):\n        \"\"\"A valid login_token in Authorization header returns 'api_token_user'.\"\"\"\n        from module.security.api import get_current_user\n\n        mock_request = self._mock_request(authorization=\"Bearer valid-api-token\")\n        mock_security = type(\"S\", (), {\"login_tokens\": [\"valid-api-token\"]})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            result = await get_current_user(request=mock_request, token=None)\n        assert result == \"api_token_user\"\n\n    @patch(\"module.security.api.DEV_AUTH_BYPASS\", False)\n    async def test_bearer_token_bypass_invalid(self):\n        \"\"\"An invalid login_token still falls through to cookie check.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import get_current_user\n\n        mock_request = self._mock_request(authorization=\"Bearer wrong-token\")\n        mock_security = type(\"S\", (), {\"login_tokens\": [\"correct-token\"]})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            with pytest.raises(HTTPException) as exc_info:\n                await get_current_user(request=mock_request, token=None)\n        assert exc_info.value.status_code == 401\n\n\n# ---------------------------------------------------------------------------\n# check_login_ip\n# ---------------------------------------------------------------------------\n\n\nclass TestCheckLoginIp:\n    @staticmethod\n    def _make_request(host: str | None):\n        from unittest.mock import MagicMock\n\n        request = MagicMock()\n        if host is None:\n            request.client = None\n        else:\n            request.client = MagicMock()\n            request.client.host = host\n        return request\n\n    def test_empty_whitelist_allows_all(self):\n        \"\"\"When login_whitelist is empty, all IPs pass.\"\"\"\n        from module.security.api import check_login_ip\n\n        mock_security = type(\"S\", (), {\"login_whitelist\": []})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            # Should not raise\n            check_login_ip(request=self._make_request(\"8.8.8.8\"))\n\n    def test_allowed_ip_passes(self):\n        \"\"\"IP in whitelist does not raise.\"\"\"\n        from module.security.api import check_login_ip\n\n        mock_security = type(\"S\", (), {\"login_whitelist\": [\"192.168.0.0/16\"]})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            check_login_ip(request=self._make_request(\"192.168.1.100\"))\n\n    def test_blocked_ip_raises_403(self):\n        \"\"\"IP outside whitelist raises 403.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import check_login_ip\n\n        mock_security = type(\"S\", (), {\"login_whitelist\": [\"192.168.0.0/16\"]})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            with pytest.raises(HTTPException) as exc_info:\n                check_login_ip(request=self._make_request(\"8.8.8.8\"))\n        assert exc_info.value.status_code == 403\n\n    def test_no_client_raises_403_when_whitelist_set(self):\n        \"\"\"Missing client info raises 403 when whitelist is non-empty.\"\"\"\n        from fastapi import HTTPException\n\n        from module.security.api import check_login_ip\n\n        mock_security = type(\"S\", (), {\"login_whitelist\": [\"192.168.0.0/16\"]})()\n        mock_settings = type(\"Settings\", (), {\"security\": mock_security})()\n\n        with patch(\"module.security.api.settings\", mock_settings):\n            with pytest.raises(HTTPException) as exc_info:\n                check_login_ip(request=self._make_request(None))\n        assert exc_info.value.status_code == 403\n"
  },
  {
    "path": "backend/src/test/test_config.py",
    "content": "\"\"\"Tests for configuration: loading, env overrides, defaults, migration.\"\"\"\n\nimport json\nimport os\nimport pytest\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nfrom module.models.config import (\n    Config,\n    Downloader,\n    Notification as NotificationConfig,\n    NotificationProvider,\n    Program,\n    Proxy,\n    RSSParser,\n    Security,\n)\nfrom module.conf.config import Settings\nfrom module.conf.const import BCOLORS, DEFAULT_SETTINGS\n\n\n# ---------------------------------------------------------------------------\n# Config model defaults\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigDefaults:\n    def test_program_defaults(self):\n        \"\"\"Program has correct default values.\"\"\"\n        config = Config()\n        assert config.program.rss_time == 900\n        assert config.program.rename_time == 60\n        assert config.program.webui_port == 7892\n\n    def test_downloader_defaults(self):\n        \"\"\"Downloader has correct default values.\"\"\"\n        config = Config()\n        assert config.downloader.type == \"qbittorrent\"\n        assert config.downloader.path == \"/downloads/Bangumi\"\n        assert config.downloader.ssl is False\n\n    def test_rss_parser_defaults(self):\n        \"\"\"RSSParser has correct default values.\"\"\"\n        config = Config()\n        assert config.rss_parser.enable is True\n        assert config.rss_parser.language == \"zh\"\n        assert \"720\" in config.rss_parser.filter\n\n    def test_bangumi_manage_defaults(self):\n        \"\"\"BangumiManage has correct default values.\"\"\"\n        config = Config()\n        assert config.bangumi_manage.enable is True\n        assert config.bangumi_manage.rename_method == \"pn\"\n        assert config.bangumi_manage.group_tag is False\n        assert config.bangumi_manage.remove_bad_torrent is False\n        assert config.bangumi_manage.eps_complete is False\n\n    def test_proxy_defaults(self):\n        \"\"\"Proxy is disabled by default.\"\"\"\n        config = Config()\n        assert config.proxy.enable is False\n        assert config.proxy.type == \"http\"\n\n    def test_notification_defaults(self):\n        \"\"\"Notification is disabled by default with empty providers.\"\"\"\n        config = Config()\n        assert config.notification.enable is False\n        assert config.notification.providers == []\n\n\n# ---------------------------------------------------------------------------\n# Config serialization\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigSerialization:\n    def test_dict_uses_alias(self):\n        \"\"\"Config.dict() uses field aliases (by_alias=True).\"\"\"\n        config = Config()\n        d = config.dict()\n        # Downloader uses alias 'host' not 'host_'\n        assert \"host\" in d[\"downloader\"]\n        assert \"host_\" not in d[\"downloader\"]\n\n    def test_roundtrip_json(self, tmp_path):\n        \"\"\"Config can be serialized to JSON and loaded back.\"\"\"\n        config = Config()\n        config_dict = config.dict()\n        json_path = tmp_path / \"config.json\"\n        with open(json_path, \"w\") as f:\n            json.dump(config_dict, f)\n\n        with open(json_path, \"r\") as f:\n            loaded = json.load(f)\n\n        loaded_config = Config.model_validate(loaded)\n        assert loaded_config.program.rss_time == config.program.rss_time\n        assert loaded_config.downloader.type == config.downloader.type\n\n\n# ---------------------------------------------------------------------------\n# Settings._migrate_old_config\n# ---------------------------------------------------------------------------\n\n\nclass TestMigrateOldConfig:\n    def test_sleep_time_to_rss_time(self):\n        \"\"\"Migrates sleep_time → rss_time.\"\"\"\n        old_config = {\n            \"program\": {\"sleep_time\": 1800},\n            \"rss_parser\": {},\n        }\n        result = Settings._migrate_old_config(old_config)\n        assert result[\"program\"][\"rss_time\"] == 1800\n        assert \"sleep_time\" not in result[\"program\"]\n\n    def test_times_to_rename_time(self):\n        \"\"\"Migrates times → rename_time.\"\"\"\n        old_config = {\n            \"program\": {\"times\": 120},\n            \"rss_parser\": {},\n        }\n        result = Settings._migrate_old_config(old_config)\n        assert result[\"program\"][\"rename_time\"] == 120\n        assert \"times\" not in result[\"program\"]\n\n    def test_removes_data_version(self):\n        \"\"\"Removes deprecated data_version field.\"\"\"\n        old_config = {\n            \"program\": {\"data_version\": 2},\n            \"rss_parser\": {},\n        }\n        result = Settings._migrate_old_config(old_config)\n        assert \"data_version\" not in result[\"program\"]\n\n    def test_removes_deprecated_rss_parser_fields(self):\n        \"\"\"Removes deprecated type, custom_url, token, enable_tmdb from rss_parser.\"\"\"\n        old_config = {\n            \"program\": {},\n            \"rss_parser\": {\n                \"type\": \"mikan\",\n                \"custom_url\": \"https://custom.url\",\n                \"token\": \"abc\",\n                \"enable_tmdb\": True,\n                \"enable\": True,\n            },\n        }\n        result = Settings._migrate_old_config(old_config)\n        assert \"type\" not in result[\"rss_parser\"]\n        assert \"custom_url\" not in result[\"rss_parser\"]\n        assert \"token\" not in result[\"rss_parser\"]\n        assert \"enable_tmdb\" not in result[\"rss_parser\"]\n        assert result[\"rss_parser\"][\"enable\"] is True\n\n    def test_no_migration_needed(self):\n        \"\"\"Already-current config passes through unchanged.\"\"\"\n        current_config = {\n            \"program\": {\"rss_time\": 900, \"rename_time\": 60},\n            \"rss_parser\": {\"enable\": True},\n        }\n        result = Settings._migrate_old_config(current_config)\n        assert result[\"program\"][\"rss_time\"] == 900\n        assert result[\"program\"][\"rename_time\"] == 60\n\n    def test_both_old_and_new_fields(self):\n        \"\"\"When both sleep_time and rss_time exist, removes sleep_time.\"\"\"\n        config = {\n            \"program\": {\"sleep_time\": 1800, \"rss_time\": 900},\n            \"rss_parser\": {},\n        }\n        result = Settings._migrate_old_config(config)\n        assert result[\"program\"][\"rss_time\"] == 900\n        assert \"sleep_time\" not in result[\"program\"]\n\n\n# ---------------------------------------------------------------------------\n# Settings.load from file\n# ---------------------------------------------------------------------------\n\n\nclass TestSettingsLoad:\n    def test_load_from_json_file(self, tmp_path):\n        \"\"\"Settings loads config from a JSON file when it exists.\"\"\"\n        config_data = Config().dict()\n        config_data[\"program\"][\"rss_time\"] = 1200  # Custom value\n        config_file = tmp_path / \"config.json\"\n        with open(config_file, \"w\") as f:\n            json.dump(config_data, f)\n\n        with patch(\"module.conf.config.CONFIG_PATH\", config_file):\n            with patch(\"module.conf.config.VERSION\", \"3.2.0\"):\n                s = Settings.__new__(Settings)\n                Config.__init__(s)\n                s.load()\n\n        assert s.program.rss_time == 1200\n\n    def test_save_writes_json(self, tmp_path):\n        \"\"\"settings.save() writes valid JSON to CONFIG_PATH.\"\"\"\n        config_file = tmp_path / \"config_out.json\"\n\n        with patch(\"module.conf.config.CONFIG_PATH\", config_file):\n            s = Settings.__new__(Settings)\n            Config.__init__(s)\n            s.save()\n\n        assert config_file.exists()\n        with open(config_file) as f:\n            data = json.load(f)\n        assert \"program\" in data\n        assert \"downloader\" in data\n\n\n# ---------------------------------------------------------------------------\n# Environment variable overrides\n# ---------------------------------------------------------------------------\n\n\nclass TestEnvOverrides:\n    def test_downloader_host_from_env(self, tmp_path):\n        \"\"\"AB_DOWNLOADER_HOST env var overrides downloader host.\"\"\"\n        config_file = tmp_path / \"config.json\"\n\n        env = {\"AB_DOWNLOADER_HOST\": \"192.168.1.100:9090\"}\n        with patch.dict(os.environ, env, clear=False):\n            with patch(\"module.conf.config.CONFIG_PATH\", config_file):\n                s = Settings.__new__(Settings)\n                Config.__init__(s)\n                s.init()\n\n        assert \"192.168.1.100:9090\" in s.downloader.host\n\n\n# ---------------------------------------------------------------------------\n# Security model\n# ---------------------------------------------------------------------------\n\n\nclass TestSecurityModel:\n    def test_security_defaults(self):\n        \"\"\"Security has empty whitelists and token lists by default.\"\"\"\n        sec = Security()\n        assert sec.login_whitelist == []\n        assert sec.login_tokens == []\n        assert sec.mcp_whitelist == []\n        assert sec.mcp_tokens == []\n\n    def test_security_in_config(self):\n        \"\"\"Config includes a Security section with correct defaults.\"\"\"\n        config = Config()\n        assert hasattr(config, \"security\")\n        assert isinstance(config.security, Security)\n        assert config.security.login_whitelist == []\n\n    def test_security_populated(self):\n        \"\"\"Security fields accept lists of CIDRs and tokens.\"\"\"\n        sec = Security(\n            login_whitelist=[\"192.168.0.0/16\"],\n            login_tokens=[\"token-abc\"],\n            mcp_whitelist=[\"10.0.0.0/8\"],\n            mcp_tokens=[\"mcp-secret\"],\n        )\n        assert \"192.168.0.0/16\" in sec.login_whitelist\n        assert \"token-abc\" in sec.login_tokens\n        assert \"10.0.0.0/8\" in sec.mcp_whitelist\n        assert \"mcp-secret\" in sec.mcp_tokens\n\n    def test_security_roundtrip_serialization(self):\n        \"\"\"Security serializes and deserializes correctly.\"\"\"\n        original = Security(\n            login_whitelist=[\"127.0.0.0/8\"],\n            mcp_tokens=[\"tok1\"],\n        )\n        data = original.model_dump()\n        restored = Security.model_validate(data)\n        assert restored.login_whitelist == [\"127.0.0.0/8\"]\n        assert restored.mcp_tokens == [\"tok1\"]\n\n\n# ---------------------------------------------------------------------------\n# NotificationProvider model\n# ---------------------------------------------------------------------------\n\n\nclass TestNotificationProvider:\n    def test_minimal_provider(self):\n        \"\"\"NotificationProvider requires only type.\"\"\"\n        p = NotificationProvider(type=\"telegram\")\n        assert p.type == \"telegram\"\n        assert p.enabled is True\n\n    def test_telegram_provider_fields(self):\n        \"\"\"Telegram provider stores token and chat_id.\"\"\"\n        p = NotificationProvider(type=\"telegram\", token=\"bot123\", chat_id=\"-100456\")\n        assert p.token == \"bot123\"\n        assert p.chat_id == \"-100456\"\n\n    def test_discord_provider_fields(self):\n        \"\"\"Discord provider stores webhook_url.\"\"\"\n        p = NotificationProvider(\n            type=\"discord\", webhook_url=\"https://discord.com/api/webhooks/123/abc\"\n        )\n        assert p.webhook_url == \"https://discord.com/api/webhooks/123/abc\"\n\n    def test_bark_provider_fields(self):\n        \"\"\"Bark provider stores server_url and device_key.\"\"\"\n        p = NotificationProvider(\n            type=\"bark\", server_url=\"https://api.day.app\", device_key=\"mykey\"\n        )\n        assert p.server_url == \"https://api.day.app\"\n        assert p.device_key == \"mykey\"\n\n    def test_pushover_provider_fields(self):\n        \"\"\"Pushover provider stores user_key and api_token.\"\"\"\n        p = NotificationProvider(type=\"pushover\", user_key=\"uk1\", api_token=\"at1\")\n        assert p.user_key == \"uk1\"\n        assert p.api_token == \"at1\"\n\n    def test_url_field_property(self):\n        \"\"\"Webhook provider stores url.\"\"\"\n        p = NotificationProvider(type=\"webhook\", url=\"https://example.com/hook\")\n        assert p.url == \"https://example.com/hook\"\n\n    def test_optional_fields_default_empty_string(self):\n        \"\"\"Unset optional properties return empty string, not None.\"\"\"\n        p = NotificationProvider(type=\"telegram\")\n        assert p.token == \"\"\n        assert p.chat_id == \"\"\n        assert p.webhook_url == \"\"\n\n    def test_provider_can_be_disabled(self):\n        \"\"\"Provider can be disabled without removing it.\"\"\"\n        p = NotificationProvider(type=\"telegram\", enabled=False)\n        assert p.enabled is False\n\n    def test_env_var_expansion_in_token(self, monkeypatch):\n        \"\"\"Token field expands shell environment variables.\"\"\"\n        monkeypatch.setenv(\"TEST_BOT_TOKEN\", \"real-token-value\")\n        p = NotificationProvider(type=\"telegram\", token=\"$TEST_BOT_TOKEN\")\n        assert p.token == \"real-token-value\"\n\n\n# ---------------------------------------------------------------------------\n# Notification model - legacy migration\n# ---------------------------------------------------------------------------\n\n\nclass TestNotificationLegacyMigration:\n    def test_new_format_no_migration(self):\n        \"\"\"New format with providers list is not touched.\"\"\"\n        n = NotificationConfig(\n            enable=True,\n            providers=[NotificationProvider(type=\"telegram\", token=\"tok\")],\n        )\n        assert len(n.providers) == 1\n        assert n.providers[0].type == \"telegram\"\n\n    def test_old_format_migrates_to_provider(self):\n        \"\"\"Old single-provider fields (type, token, chat_id) migrate to providers list.\"\"\"\n        n = NotificationConfig(\n            enable=True,\n            type=\"telegram\",\n            token=\"bot_token\",\n            chat_id=\"-100123\",\n        )\n        assert len(n.providers) == 1\n        provider = n.providers[0]\n        assert provider.type == \"telegram\"\n        assert provider.enabled is True\n\n    def test_old_format_no_migration_when_providers_already_set(self):\n        \"\"\"When providers already exist, legacy fields do not create additional providers.\"\"\"\n        n = NotificationConfig(\n            enable=True,\n            type=\"telegram\",\n            token=\"unused\",\n            providers=[NotificationProvider(type=\"discord\", webhook_url=\"https://d.co\")],\n        )\n        assert len(n.providers) == 1\n        assert n.providers[0].type == \"discord\"\n\n    def test_notification_empty_providers_by_default(self):\n        \"\"\"Default Notification has no providers.\"\"\"\n        n = NotificationConfig()\n        assert n.providers == []\n        assert n.enable is False\n\n\n# ---------------------------------------------------------------------------\n# Downloader env-var expansion\n# ---------------------------------------------------------------------------\n\n\nclass TestDownloaderEnvExpansion:\n    def test_host_expands_env_var(self, monkeypatch):\n        \"\"\"Downloader.host expands $VAR references.\"\"\"\n        monkeypatch.setenv(\"QB_HOST\", \"192.168.5.10:8080\")\n        d = Downloader(host=\"$QB_HOST\")\n        assert d.host == \"192.168.5.10:8080\"\n\n    def test_username_expands_env_var(self, monkeypatch):\n        \"\"\"Downloader.username expands $VAR references.\"\"\"\n        monkeypatch.setenv(\"QB_USER\", \"myuser\")\n        d = Downloader(username=\"$QB_USER\")\n        assert d.username == \"myuser\"\n\n    def test_password_expands_env_var(self, monkeypatch):\n        \"\"\"Downloader.password expands $VAR references.\"\"\"\n        monkeypatch.setenv(\"QB_PASS\", \"s3cret\")\n        d = Downloader(password=\"$QB_PASS\")\n        assert d.password == \"s3cret\"\n\n    def test_literal_host_not_expanded(self):\n        \"\"\"Literal host strings without $ are returned as-is.\"\"\"\n        d = Downloader(host=\"localhost:8080\")\n        assert d.host == \"localhost:8080\"\n\n\n# ---------------------------------------------------------------------------\n# DEFAULT_SETTINGS structure\n# ---------------------------------------------------------------------------\n\n\nclass TestDefaultSettings:\n    def test_security_section_present(self):\n        \"\"\"DEFAULT_SETTINGS contains a security section.\"\"\"\n        assert \"security\" in DEFAULT_SETTINGS\n\n    def test_security_default_mcp_whitelist(self):\n        \"\"\"Default MCP whitelist contains private network ranges.\"\"\"\n        mcp_wl = DEFAULT_SETTINGS[\"security\"][\"mcp_whitelist\"]\n        assert \"127.0.0.0/8\" in mcp_wl\n        assert \"192.168.0.0/16\" in mcp_wl\n        assert \"10.0.0.0/8\" in mcp_wl\n\n    def test_security_default_tokens_empty(self):\n        \"\"\"Default security token lists are empty.\"\"\"\n        assert DEFAULT_SETTINGS[\"security\"][\"login_tokens\"] == []\n        assert DEFAULT_SETTINGS[\"security\"][\"mcp_tokens\"] == []\n\n    def test_notification_uses_providers_format(self):\n        \"\"\"DEFAULT_SETTINGS notification uses new providers format.\"\"\"\n        notif = DEFAULT_SETTINGS[\"notification\"]\n        assert \"providers\" in notif\n        assert notif[\"providers\"] == []\n        assert \"type\" not in notif\n\n\n# ---------------------------------------------------------------------------\n# BCOLORS utility\n# ---------------------------------------------------------------------------\n\n\nclass TestBCOLORS:\n    def test_wrap_single_string(self):\n        \"\"\"BCOLORS._() wraps a string with color codes and reset.\"\"\"\n        result = BCOLORS._(BCOLORS.OKGREEN, \"hello\")\n        assert \"hello\" in result\n        assert BCOLORS.OKGREEN in result\n        assert BCOLORS.ENDC in result\n\n    def test_wrap_multiple_strings(self):\n        \"\"\"BCOLORS._() joins multiple args with commas.\"\"\"\n        result = BCOLORS._(BCOLORS.WARNING, \"foo\", \"bar\")\n        assert \"foo\" in result\n        assert \"bar\" in result\n\n    def test_wrap_non_string_arg(self):\n        \"\"\"BCOLORS._() converts non-string args to str.\"\"\"\n        result = BCOLORS._(BCOLORS.FAIL, 42)\n        assert \"42\" in result\n\n    def test_all_color_constants_are_strings(self):\n        \"\"\"All BCOLORS constants are strings.\"\"\"\n        for attr in [\"HEADER\", \"OKBLUE\", \"OKCYAN\", \"OKGREEN\", \"WARNING\", \"FAIL\", \"ENDC\"]:\n            assert isinstance(getattr(BCOLORS, attr), str)\n\n\n# ---------------------------------------------------------------------------\n# Migration: security section injection\n# ---------------------------------------------------------------------------\n\n\nclass TestMigrateSecuritySection:\n    def test_adds_security_when_missing(self):\n        \"\"\"_migrate_old_config injects a default security section when absent.\"\"\"\n        old_config = {\n            \"program\": {},\n            \"rss_parser\": {},\n        }\n        result = Settings._migrate_old_config(old_config)\n        assert \"security\" in result\n        assert \"mcp_whitelist\" in result[\"security\"]\n\n    def test_preserves_existing_security_section(self):\n        \"\"\"_migrate_old_config does not overwrite an existing security section.\"\"\"\n        existing_config = {\n            \"program\": {},\n            \"rss_parser\": {},\n            \"security\": {\n                \"login_whitelist\": [\"10.0.0.0/8\"],\n                \"login_tokens\": [\"mytoken\"],\n                \"mcp_whitelist\": [],\n                \"mcp_tokens\": [],\n            },\n        }\n        result = Settings._migrate_old_config(existing_config)\n        assert result[\"security\"][\"login_tokens\"] == [\"mytoken\"]\n        assert result[\"security\"][\"login_whitelist\"] == [\"10.0.0.0/8\"]\n"
  },
  {
    "path": "backend/src/test/test_database.py",
    "content": "import json\n\nimport pytest\nfrom sqlmodel import Session, SQLModel, create_engine\n\nfrom module.database.bangumi import BangumiDatabase\nfrom module.database.rss import RSSDatabase\nfrom module.database.torrent import TorrentDatabase\nfrom module.models import Bangumi, RSSItem, Torrent\n\n# sqlite sync engine for testing\nengine = create_engine(\"sqlite://\", echo=False)\n\n\n@pytest.fixture\ndef db_session():\n    SQLModel.metadata.create_all(engine)\n    with Session(engine) as session:\n        yield session\n    SQLModel.metadata.drop_all(engine)\n\n\ndef test_bangumi_database(db_session):\n    test_data = Bangumi(\n        official_title=\"无职转生，到了异世界就拿出真本事\",\n        year=\"2021\",\n        title_raw=\"Mushoku Tensei\",\n        season=1,\n        season_raw=\"\",\n        group_name=\"Lilith-Raws\",\n        dpi=\"1080p\",\n        source=\"Baha\",\n        subtitle=\"CHT\",\n        eps_collect=False,\n        offset=0,\n        filter=\"720p,\\\\d+-\\\\d+\",\n        rss_link=\"test\",\n        poster_link=\"/test/test.jpg\",\n        added=False,\n        rule_name=None,\n        save_path=\"downloads/无职转生，到了异世界就拿出真本事/Season 1\",\n        deleted=False,\n    )\n    db = BangumiDatabase(db_session)\n\n    # insert\n    db.add(test_data)\n    result = db.search_id(1)\n    assert result.official_title == test_data.official_title\n\n    # update\n    test_data.official_title = \"无职转生，到了异世界就拿出真本事II\"\n    db.update(test_data)\n    result = db.search_id(1)\n    assert result.official_title == test_data.official_title\n\n    # search poster\n    poster = db.match_poster(\"无职转生，到了异世界就拿出真本事II (2021)\")\n    assert poster == \"/test/test.jpg\"\n\n    # match torrent\n    result = db.match_torrent(\n        \"[Lilith-Raws] 无职转生，到了异世界就拿出真本事 / Mushoku Tensei - 11 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]\"\n    )\n    assert result.official_title == \"无职转生，到了异世界就拿出真本事II\"\n\n    # delete\n    db.delete_one(1)\n    result = db.search_id(1)\n    assert result is None\n\n\ndef test_torrent_database(db_session):\n    test_data = Torrent(\n        name=\"[Sub Group]test S02 01 [720p].mkv\",\n        url=\"https://test.com/test.mkv\",\n    )\n    db = TorrentDatabase(db_session)\n\n    # insert\n    db.add(test_data)\n    result = db.search(1)\n    assert result.name == test_data.name\n\n    # update\n    test_data.downloaded = True\n    db.update(test_data)\n    result = db.search(1)\n    assert result.downloaded == True\n\n\ndef test_rss_database(db_session):\n    rss_url = \"https://test.com/test.xml\"\n    db = RSSDatabase(db_session)\n\n    db.add(RSSItem(url=rss_url, name=\"Test RSS\"))\n    result = db.search_id(1)\n    assert result.url == rss_url\n\n\n# ---------------------------------------------------------------------------\n# TorrentDatabase qb_hash methods\n# ---------------------------------------------------------------------------\n\n\ndef test_torrent_search_by_qb_hash(db_session):\n    \"\"\"Test searching torrent by qBittorrent hash.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    # Create torrent with qb_hash\n    torrent = Torrent(\n        name=\"[SubGroup] Test Anime - 01 [1080p].mkv\",\n        url=\"https://example.com/torrent1\",\n        qb_hash=\"abc123def456\",\n    )\n    db.add(torrent)\n\n    # Search by qb_hash\n    result = db.search_by_qb_hash(\"abc123def456\")\n    assert result is not None\n    assert result.name == torrent.name\n    assert result.qb_hash == \"abc123def456\"\n\n\ndef test_torrent_search_by_qb_hash_not_found(db_session):\n    \"\"\"Test searching non-existent qb_hash returns None.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    result = db.search_by_qb_hash(\"nonexistent_hash\")\n    assert result is None\n\n\ndef test_torrent_search_by_url(db_session):\n    \"\"\"Test searching torrent by URL.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    url = \"https://mikanani.me/Download/torrent123.torrent\"\n    torrent = Torrent(\n        name=\"[SubGroup] Test Anime - 02 [1080p].mkv\",\n        url=url,\n    )\n    db.add(torrent)\n\n    # Search by URL\n    result = db.search_by_url(url)\n    assert result is not None\n    assert result.url == url\n    assert result.name == torrent.name\n\n\ndef test_torrent_search_by_url_not_found(db_session):\n    \"\"\"Test searching non-existent URL returns None.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    result = db.search_by_url(\"https://nonexistent.com/torrent.torrent\")\n    assert result is None\n\n\ndef test_torrent_update_qb_hash(db_session):\n    \"\"\"Test updating qb_hash for existing torrent.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    # Create torrent without qb_hash\n    torrent = Torrent(\n        name=\"[SubGroup] Test Anime - 03 [1080p].mkv\",\n        url=\"https://example.com/torrent3\",\n    )\n    db.add(torrent)\n    assert torrent.qb_hash is None\n\n    # Update qb_hash\n    success = db.update_qb_hash(torrent.id, \"new_hash_value\")\n    assert success is True\n\n    # Verify update\n    result = db.search(torrent.id)\n    assert result.qb_hash == \"new_hash_value\"\n\n\ndef test_torrent_update_qb_hash_nonexistent(db_session):\n    \"\"\"Test updating qb_hash for non-existent torrent returns False.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    success = db.update_qb_hash(99999, \"some_hash\")\n    assert success is False\n\n\ndef test_torrent_with_bangumi_id(db_session):\n    \"\"\"Test torrent with bangumi_id for offset lookup.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    # Create torrent linked to a bangumi\n    torrent = Torrent(\n        name=\"[SubGroup] Test Anime - 04 [1080p].mkv\",\n        url=\"https://example.com/torrent4\",\n        bangumi_id=42,\n        qb_hash=\"hash_for_bangumi_42\",\n    )\n    db.add(torrent)\n\n    # Search and verify bangumi_id is preserved\n    result = db.search_by_qb_hash(\"hash_for_bangumi_42\")\n    assert result is not None\n    assert result.bangumi_id == 42\n\n\ndef test_torrent_qb_hash_index_efficient(db_session):\n    \"\"\"Test that qb_hash lookups work correctly with multiple torrents.\"\"\"\n    db = TorrentDatabase(db_session)\n\n    # Add multiple torrents\n    torrents = [\n        Torrent(\n            name=f\"Torrent {i}\", url=f\"https://example.com/{i}\", qb_hash=f\"hash_{i}\"\n        )\n        for i in range(10)\n    ]\n    db.add_all(torrents)\n\n    # Verify we can find specific torrents by hash\n    result = db.search_by_qb_hash(\"hash_5\")\n    assert result is not None\n    assert result.name == \"Torrent 5\"\n\n    result = db.search_by_qb_hash(\"hash_9\")\n    assert result is not None\n    assert result.name == \"Torrent 9\"\n\n    # Non-existent hash\n    result = db.search_by_qb_hash(\"hash_100\")\n    assert result is None\n\n\n# ============================================================\n# Title Alias Tests - for mid-season naming change handling\n# ============================================================\n\n\ndef test_add_title_alias(db_session):\n    \"\"\"Test adding a title alias to an existing bangumi.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test\",\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n\n    # Add an alias\n    result = db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n    assert result is True\n\n    # Verify alias was added\n    updated = db.search_id(bangumi_id)\n    assert updated.title_aliases is not None\n    aliases = json.loads(updated.title_aliases)\n    assert \"Test Anime Season 1\" in aliases\n\n\ndef test_add_title_alias_duplicate(db_session):\n    \"\"\"Test that adding the same alias twice is a no-op.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test\",\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n\n    # Add same alias twice\n    db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n    result = db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n    assert result is False  # Second add should be a no-op\n\n\ndef test_add_title_alias_same_as_title_raw(db_session):\n    \"\"\"Test that adding title_raw as alias is a no-op.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test\",\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n\n    result = db.add_title_alias(bangumi_id, \"Test Anime S1\")\n    assert result is False\n\n\ndef test_match_torrent_with_alias(db_session):\n    \"\"\"Test that match_torrent finds bangumi using aliases.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test\",\n        deleted=False,\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n\n    # Add alias\n    db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n\n    # Match using title_raw\n    result = db.match_torrent(\"[TestGroup] Test Anime S1 - 01.mkv\")\n    assert result is not None\n    assert result.official_title == \"Test Anime\"\n\n    # Match using alias\n    result = db.match_torrent(\"[TestGroup] Test Anime Season 1 - 01.mkv\")\n    assert result is not None\n    assert result.official_title == \"Test Anime\"\n\n\ndef test_find_semantic_duplicate_same_official_title(db_session):\n    \"\"\"Test finding semantic duplicates with same official title.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    # Add first bangumi\n    bangumi1 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Sousou no Frieren\",\n        group_name=\"LoliHouse\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test1\",\n    )\n    db.add(bangumi1)\n\n    # Create a semantically similar bangumi (same anime, group changed naming)\n    bangumi2 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Frieren Beyond Journey's End\",  # Different title_raw\n        group_name=\"LoliHouse&动漫国\",  # Group changed mid-season\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test2\",\n    )\n\n    # Should find semantic duplicate\n    result = db.find_semantic_duplicate(bangumi2)\n    assert result is not None\n    assert result.title_raw == \"Sousou no Frieren\"\n\n\ndef test_find_semantic_duplicate_no_match_different_resolution(db_session):\n    \"\"\"Test that different resolution is NOT a semantic match.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi1 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Sousou no Frieren\",\n        group_name=\"LoliHouse\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test1\",\n    )\n    db.add(bangumi1)\n\n    # Same anime but different resolution - should NOT be semantic duplicate\n    bangumi2 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Sousou no Frieren 4K\",\n        group_name=\"LoliHouse\",\n        dpi=\"2160p\",  # Different resolution\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test2\",\n    )\n\n    result = db.find_semantic_duplicate(bangumi2)\n    assert result is None\n\n\ndef test_add_with_semantic_duplicate_creates_alias(db_session):\n    \"\"\"Test that adding a semantic duplicate creates an alias instead.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    # Add first bangumi\n    bangumi1 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Sousou no Frieren\",\n        group_name=\"LoliHouse\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test1\",\n    )\n    db.add(bangumi1)\n    initial_count = len(db.search_all())\n    assert initial_count == 1\n\n    # Try to add semantic duplicate\n    bangumi2 = Bangumi(\n        official_title=\"Frieren\",\n        title_raw=\"Frieren Beyond Journey's End\",\n        group_name=\"LoliHouse&动漫国\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test2\",\n    )\n    result = db.add(bangumi2)\n    assert result is False  # Should not add new entry\n\n    # Count should still be 1\n    final_count = len(db.search_all())\n    assert final_count == 1\n\n    # But the new title_raw should be an alias\n    original = db.search_all()[0]\n    aliases = json.loads(original.title_aliases) if original.title_aliases else []\n    assert \"Frieren Beyond Journey's End\" in aliases\n\n\nclass TestDeleteByBangumiId:\n    \"\"\"Tests for TorrentDatabase.delete_by_bangumi_id.\"\"\"\n\n    def test_deletes_matching_torrents(self, db_session):\n        db = TorrentDatabase(db_session)\n        for i in range(3):\n            db.add(Torrent(name=f\"torrent_{i}\", url=f\"https://example.com/{i}\", bangumi_id=10))\n        assert len(db.search_all()) == 3\n\n        count = db.delete_by_bangumi_id(10)\n        assert count == 3\n        assert len(db.search_all()) == 0\n\n    def test_leaves_other_bangumi_torrents(self, db_session):\n        db = TorrentDatabase(db_session)\n        db.add(Torrent(name=\"keep\", url=\"https://example.com/keep\", bangumi_id=20))\n        db.add(Torrent(name=\"delete\", url=\"https://example.com/delete\", bangumi_id=30))\n\n        count = db.delete_by_bangumi_id(30)\n        assert count == 1\n        remaining = db.search_all()\n        assert len(remaining) == 1\n        assert remaining[0].name == \"keep\"\n\n    def test_no_match_returns_zero(self, db_session):\n        db = TorrentDatabase(db_session)\n        db.add(Torrent(name=\"unrelated\", url=\"https://example.com/1\", bangumi_id=5))\n\n        count = db.delete_by_bangumi_id(999)\n        assert count == 0\n        assert len(db.search_all()) == 1\n\n    def test_skips_null_bangumi_id(self, db_session):\n        db = TorrentDatabase(db_session)\n        db.add(Torrent(name=\"orphan\", url=\"https://example.com/orphan\", bangumi_id=None))\n        db.add(Torrent(name=\"target\", url=\"https://example.com/target\", bangumi_id=7))\n\n        count = db.delete_by_bangumi_id(7)\n        assert count == 1\n        remaining = db.search_all()\n        assert len(remaining) == 1\n        assert remaining[0].bangumi_id is None\n\n    def test_check_new_finds_urls_after_cleanup(self, db_session):\n        \"\"\"Core scenario: after deleting torrent records, check_new should treat those URLs as new.\"\"\"\n        db = TorrentDatabase(db_session)\n        db.add(Torrent(name=\"ep01\", url=\"https://mikan.me/t/001\", bangumi_id=42))\n        db.add(Torrent(name=\"ep02\", url=\"https://mikan.me/t/002\", bangumi_id=42))\n\n        # Before cleanup: check_new filters them out\n        incoming = [Torrent(name=\"ep01\", url=\"https://mikan.me/t/001\")]\n        assert db.check_new(incoming) == []\n\n        # After cleanup: same URLs are now \"new\"\n        db.delete_by_bangumi_id(42)\n        new = db.check_new(incoming)\n        assert len(new) == 1\n        assert new[0].url == \"https://mikan.me/t/001\"\n\n\ndef test_groups_are_similar():\n    \"\"\"Test group name similarity detection.\"\"\"\n    from module.database.bangumi import _groups_are_similar\n\n    # Exact match\n    assert _groups_are_similar(\"LoliHouse\", \"LoliHouse\") is True\n\n    # Substring match (one contains the other)\n    assert _groups_are_similar(\"LoliHouse\", \"LoliHouse&动漫国字幕组\") is True\n    assert _groups_are_similar(\"LoliHouse&动漫国字幕组\", \"LoliHouse\") is True\n\n    # Completely different groups\n    assert _groups_are_similar(\"LoliHouse\", \"Sakurato\") is False\n    assert _groups_are_similar(\"字幕组A\", \"字幕组B\") is False\n\n    # Edge cases\n    assert _groups_are_similar(None, \"LoliHouse\") is False\n    assert _groups_are_similar(\"LoliHouse\", None) is False\n    assert _groups_are_similar(None, None) is False\n\n\ndef test_get_all_title_patterns(db_session):\n    \"\"\"Test getting all title patterns for a bangumi.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"test\",\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n\n    # Add aliases\n    db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n    db.add_title_alias(bangumi_id, \"TA S1\")\n\n    # Get all patterns\n    updated = db.search_id(bangumi_id)\n    patterns = db.get_all_title_patterns(updated)\n\n    assert len(patterns) == 3\n    assert \"Test Anime S1\" in patterns\n    assert \"Test Anime Season 1\" in patterns\n    assert \"TA S1\" in patterns\n\n\ndef test_match_list_with_aliases(db_session):\n    \"\"\"Test match_list works with aliases.\"\"\"\n    db = BangumiDatabase(db_session)\n\n    bangumi = Bangumi(\n        official_title=\"Test Anime\",\n        title_raw=\"Test Anime S1\",\n        group_name=\"TestGroup\",\n        dpi=\"1080p\",\n        source=\"Web\",\n        subtitle=\"CHT\",\n        rss_link=\"rss1\",\n    )\n    db.add(bangumi)\n    bangumi_id = db.search_all()[0].id\n    db.add_title_alias(bangumi_id, \"Test Anime Season 1\")\n\n    # Create torrents with different naming patterns\n    torrents = [\n        Torrent(name=\"[TestGroup] Test Anime S1 - 01.mkv\", url=\"url1\"),\n        Torrent(name=\"[TestGroup] Test Anime Season 1 - 02.mkv\", url=\"url2\"),\n        Torrent(name=\"[OtherGroup] Different Anime - 01.mkv\", url=\"url3\"),\n    ]\n\n    # Only the third torrent should be unmatched\n    unmatched = db.match_list(torrents, \"rss2\")\n    assert len(unmatched) == 1\n    assert unmatched[0].name == \"[OtherGroup] Different Anime - 01.mkv\"\n"
  },
  {
    "path": "backend/src/test/test_download_client.py",
    "content": "\"\"\"Tests for DownloadClient: init, set_rule, add_torrent, rename, etc.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, patch, MagicMock\n\nfrom module.models import Bangumi, Torrent\nfrom module.models.config import Config\nfrom module.downloader.download_client import DownloadClient\n\nfrom test.factories import make_bangumi, make_torrent\n\n\n@pytest.fixture\ndef download_client(mock_qb_client):\n    \"\"\"Create a DownloadClient with mocked internal client.\"\"\"\n    with patch(\"module.downloader.download_client.settings\") as mock_settings:\n        mock_settings.downloader.type = \"qbittorrent\"\n        mock_settings.downloader.host = \"localhost:8080\"\n        mock_settings.downloader.username = \"admin\"\n        mock_settings.downloader.password = \"admin\"\n        mock_settings.downloader.ssl = False\n        mock_settings.downloader.path = \"/downloads/Bangumi\"\n        mock_settings.bangumi_manage.group_tag = False\n        with patch(\n            \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n            return_value=mock_qb_client,\n        ):\n            client = DownloadClient()\n    client.client = mock_qb_client\n    return client\n\n\n# ---------------------------------------------------------------------------\n# auth\n# ---------------------------------------------------------------------------\n\n\nclass TestAuth:\n    async def test_auth_success(self, download_client, mock_qb_client):\n        \"\"\"auth() sets authed=True when client authenticates.\"\"\"\n        mock_qb_client.auth.return_value = True\n        await download_client.auth()\n        assert download_client.authed is True\n\n    async def test_auth_failure(self, download_client, mock_qb_client):\n        \"\"\"auth() keeps authed=False when client fails.\"\"\"\n        mock_qb_client.auth.return_value = False\n        await download_client.auth()\n        assert download_client.authed is False\n\n\n# ---------------------------------------------------------------------------\n# init_downloader\n# ---------------------------------------------------------------------------\n\n\nclass TestInitDownloader:\n    async def test_sets_prefs_and_category(self, download_client, mock_qb_client):\n        \"\"\"init_downloader calls prefs_init with RSS config and adds category.\"\"\"\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            await download_client.init_downloader()\n\n        mock_qb_client.prefs_init.assert_called_once()\n        prefs_arg = mock_qb_client.prefs_init.call_args[1][\"prefs\"]\n        assert prefs_arg[\"rss_auto_downloading_enabled\"] is True\n        assert prefs_arg[\"rss_refresh_interval\"] == 30\n        mock_qb_client.add_category.assert_called_once_with(\"BangumiCollection\")\n\n    async def test_detects_path_when_empty(self, download_client, mock_qb_client):\n        \"\"\"When downloader.path is empty, fetches from app prefs.\"\"\"\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.path = \"\"\n            mock_qb_client.get_app_prefs.return_value = {\"save_path\": \"/data\"}\n            await download_client.init_downloader()\n\n        assert mock_settings.downloader.path != \"\"\n        assert \"Bangumi\" in mock_settings.downloader.path\n\n    async def test_category_already_exists_no_error(self, download_client, mock_qb_client):\n        \"\"\"If category already exists, logs debug but doesn't crash.\"\"\"\n        mock_qb_client.add_category.side_effect = Exception(\"already exists\")\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            # Should not raise\n            await download_client.init_downloader()\n\n\n# ---------------------------------------------------------------------------\n# set_rule\n# ---------------------------------------------------------------------------\n\n\nclass TestSetRule:\n    async def test_generates_correct_rule(self, download_client, mock_qb_client):\n        \"\"\"set_rule creates a rule with correct mustContain and savePath.\"\"\"\n        bangumi = make_bangumi(\n            title_raw=\"Mushoku Tensei\",\n            filter=\"720,480\",\n            official_title=\"Mushoku Tensei\",\n            season=2,\n            year=\"2024\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            await download_client.set_rule(bangumi)\n\n        mock_qb_client.rss_set_rule.assert_called_once()\n        call_kwargs = mock_qb_client.rss_set_rule.call_args[1]\n        rule = call_kwargs[\"rule_def\"]\n        assert rule[\"mustContain\"] == \"Mushoku Tensei\"\n        # filter string is joined char-by-char with \"|\" (this is how the code works)\n        assert rule[\"mustNotContain\"] == \"|\".join(\"720,480\")\n        assert rule[\"enable\"] is True\n        assert \"Season 2\" in rule[\"savePath\"]\n\n    async def test_marks_bangumi_added(self, download_client, mock_qb_client):\n        \"\"\"set_rule sets data.added=True after creating the rule.\"\"\"\n        bangumi = make_bangumi(added=False, filter=\"\")\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            await download_client.set_rule(bangumi)\n\n        assert bangumi.added is True\n\n    async def test_rule_name_set(self, download_client, mock_qb_client):\n        \"\"\"set_rule populates rule_name and save_path on the Bangumi.\"\"\"\n        bangumi = make_bangumi(\n            official_title=\"My Anime\",\n            season=1,\n            filter=\"\",\n            rule_name=None,\n            save_path=None,\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            await download_client.set_rule(bangumi)\n\n        assert bangumi.rule_name is not None\n        assert \"My Anime\" in bangumi.rule_name\n        assert bangumi.save_path is not None\n\n    async def test_rule_name_with_group_tag(self, download_client, mock_qb_client):\n        \"\"\"When group_tag=True, rule_name includes [group].\"\"\"\n        bangumi = make_bangumi(\n            official_title=\"My Anime\",\n            group_name=\"SubGroup\",\n            season=1,\n            filter=\"\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = True\n            await download_client.set_rule(bangumi)\n\n        assert \"[SubGroup]\" in bangumi.rule_name\n\n\n# ---------------------------------------------------------------------------\n# add_torrent\n# ---------------------------------------------------------------------------\n\n\nclass TestAddTorrent:\n    async def test_magnet_url(self, download_client, mock_qb_client):\n        \"\"\"Magnet URLs are passed as torrent_urls, no file download.\"\"\"\n        torrent = make_torrent(url=\"magnet:?xt=urn:btih:abc123\")\n        bangumi = make_bangumi()\n\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await download_client.add_torrent(torrent, bangumi)\n\n        assert result is True\n        call_kwargs = mock_qb_client.add_torrents.call_args[1]\n        assert call_kwargs[\"torrent_urls\"] == \"magnet:?xt=urn:btih:abc123\"\n        assert call_kwargs[\"torrent_files\"] is None\n\n    async def test_file_url_downloads_content(self, download_client, mock_qb_client):\n        \"\"\"Non-magnet URLs trigger file download and pass as torrent_files.\"\"\"\n        torrent = make_torrent(url=\"https://example.com/file.torrent\")\n        bangumi = make_bangumi()\n\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            mock_req.get_content = AsyncMock(return_value=b\"torrent-file-data\")\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await download_client.add_torrent(torrent, bangumi)\n\n        assert result is True\n        call_kwargs = mock_qb_client.add_torrents.call_args[1]\n        assert call_kwargs[\"torrent_files\"] == b\"torrent-file-data\"\n        assert call_kwargs[\"torrent_urls\"] is None\n\n    async def test_list_magnet_urls(self, download_client, mock_qb_client):\n        \"\"\"List of magnet torrents are joined as list of URLs.\"\"\"\n        torrents = [\n            make_torrent(url=\"magnet:?xt=urn:btih:aaa\"),\n            make_torrent(url=\"magnet:?xt=urn:btih:bbb\"),\n            make_torrent(url=\"magnet:?xt=urn:btih:ccc\"),\n        ]\n        bangumi = make_bangumi()\n\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await download_client.add_torrent(torrents, bangumi)\n\n        assert result is True\n        call_kwargs = mock_qb_client.add_torrents.call_args[1]\n        assert len(call_kwargs[\"torrent_urls\"]) == 3\n\n    async def test_empty_list_returns_false(self, download_client, mock_qb_client):\n        \"\"\"Empty torrent list returns False without calling client.\"\"\"\n        bangumi = make_bangumi()\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n            result = await download_client.add_torrent([], bangumi)\n\n        assert result is False\n        mock_qb_client.add_torrents.assert_not_called()\n\n    async def test_client_rejects_returns_false(self, download_client, mock_qb_client):\n        \"\"\"When client.add_torrents returns False, returns False.\"\"\"\n        mock_qb_client.add_torrents.return_value = False\n        torrent = make_torrent(url=\"magnet:?xt=urn:btih:abc\")\n        bangumi = make_bangumi()\n\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await download_client.add_torrent(torrent, bangumi)\n\n        assert result is False\n\n    async def test_generates_save_path_if_missing(self, download_client, mock_qb_client):\n        \"\"\"When bangumi.save_path is empty, generates one.\"\"\"\n        torrent = make_torrent(url=\"magnet:?xt=urn:btih:abc\")\n        bangumi = make_bangumi(save_path=None)\n\n        with patch(\"module.downloader.download_client.RequestContent\") as MockReq:\n            mock_req = AsyncMock()\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_req)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            with patch(\"module.downloader.path.settings\") as mock_settings:\n                mock_settings.downloader.path = \"/downloads/Bangumi\"\n                await download_client.add_torrent(torrent, bangumi)\n\n        assert bangumi.save_path is not None\n\n\n# ---------------------------------------------------------------------------\n# get_torrent_info / rename_torrent_file / delete_torrent\n# ---------------------------------------------------------------------------\n\n\nclass TestClientDelegation:\n    async def test_get_torrent_info(self, download_client, mock_qb_client):\n        \"\"\"get_torrent_info delegates to client.torrents_info.\"\"\"\n        mock_qb_client.torrents_info.return_value = [\n            {\"hash\": \"abc\", \"name\": \"test\", \"save_path\": \"/test\"}\n        ]\n        result = await download_client.get_torrent_info()\n        mock_qb_client.torrents_info.assert_called_once_with(\n            status_filter=\"completed\", category=\"Bangumi\", tag=None\n        )\n        assert len(result) == 1\n\n    async def test_rename_torrent_file_success(self, download_client, mock_qb_client):\n        \"\"\"rename_torrent_file returns True on success.\"\"\"\n        mock_qb_client.torrents_rename_file.return_value = True\n        result = await download_client.rename_torrent_file(\"hash1\", \"old.mkv\", \"new.mkv\")\n        assert result is True\n\n    async def test_rename_torrent_file_failure(self, download_client, mock_qb_client):\n        \"\"\"rename_torrent_file returns False on failure.\"\"\"\n        mock_qb_client.torrents_rename_file.return_value = False\n        result = await download_client.rename_torrent_file(\"hash1\", \"old.mkv\", \"new.mkv\")\n        assert result is False\n\n    async def test_rename_torrent_file_passes_verify_flag(\n        self, download_client, mock_qb_client\n    ):\n        \"\"\"rename_torrent_file forwards the verify kwarg to the underlying client.\"\"\"\n        mock_qb_client.torrents_rename_file.return_value = True\n        await download_client.rename_torrent_file(\n            \"hash1\", \"old.mkv\", \"new.mkv\", verify=False\n        )\n        call_kwargs = mock_qb_client.torrents_rename_file.call_args[1]\n        assert call_kwargs[\"verify\"] is False\n\n    async def test_delete_torrent(self, download_client, mock_qb_client):\n        \"\"\"delete_torrent delegates to client.torrents_delete.\"\"\"\n        await download_client.delete_torrent(\"hash1\", delete_files=True)\n        mock_qb_client.torrents_delete.assert_called_once_with(\"hash1\", delete_files=True)\n\n\n# ---------------------------------------------------------------------------\n# add_tag\n# ---------------------------------------------------------------------------\n\n\nclass TestAddTag:\n    async def test_add_tag_delegates_to_client(self, download_client, mock_qb_client):\n        \"\"\"add_tag delegates to client.add_tag.\"\"\"\n        mock_qb_client.add_tag = AsyncMock(return_value=None)\n        download_client.client = mock_qb_client\n        await download_client.add_tag(\"deadbeef12345678\", \"ab:42\")\n        mock_qb_client.add_tag.assert_called_once_with(\"deadbeef12345678\", \"ab:42\")\n\n    async def test_add_tag_short_hash_no_error(self, download_client, mock_qb_client):\n        \"\"\"add_tag with a hash shorter than 8 chars does not crash the slice.\"\"\"\n        mock_qb_client.add_tag = AsyncMock(return_value=None)\n        download_client.client = mock_qb_client\n        # Should not raise even for short hashes\n        await download_client.add_tag(\"abc\", \"ab:1\")\n\n\n# ---------------------------------------------------------------------------\n# Context manager: ConnectionError on failed auth\n# ---------------------------------------------------------------------------\n\n\nclass TestContextManagerAuth:\n    async def test_aenter_raises_on_auth_failure(self, download_client, mock_qb_client):\n        \"\"\"__aenter__ raises ConnectionError when auth fails.\"\"\"\n        mock_qb_client.auth.return_value = False\n        download_client.authed = False\n        with pytest.raises(ConnectionError, match=\"authentication failed\"):\n            await download_client.__aenter__()\n\n    async def test_aenter_succeeds_when_auth_passes(self, download_client, mock_qb_client):\n        \"\"\"__aenter__ returns self when auth succeeds.\"\"\"\n        mock_qb_client.auth.return_value = True\n        download_client.authed = False\n        result = await download_client.__aenter__()\n        assert result is download_client\n        assert download_client.authed is True\n\n    async def test_aexit_calls_logout_when_authed(self, download_client, mock_qb_client):\n        \"\"\"__aexit__ calls logout and resets authed when session was active.\"\"\"\n        download_client.authed = True\n        await download_client.__aexit__(None, None, None)\n        mock_qb_client.logout.assert_called_once()\n        assert download_client.authed is False\n"
  },
  {
    "path": "backend/src/test/test_integration.py",
    "content": "\"\"\"Integration tests: end-to-end flows with real DB and mocked externals.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, patch, MagicMock\n\nfrom sqlmodel import Session, SQLModel, create_engine\n\nfrom module.database.bangumi import BangumiDatabase, _invalidate_bangumi_cache\nfrom module.database.rss import RSSDatabase\nfrom module.database.torrent import TorrentDatabase\nfrom module.models import Bangumi, EpisodeFile, Notification, RSSItem, Torrent\nfrom module.rss.engine import RSSEngine\n\nfrom test.factories import make_bangumi, make_torrent, make_rss_item\n\n\n@pytest.fixture(autouse=True)\ndef clear_cache():\n    _invalidate_bangumi_cache()\n    yield\n    _invalidate_bangumi_cache()\n\n\n# ---------------------------------------------------------------------------\n# RSS → Download Flow\n# ---------------------------------------------------------------------------\n\n\nclass TestRssToDownloadFlow:\n    \"\"\"End-to-end: RSS feed parsed → matched → downloaded → stored in DB.\"\"\"\n\n    async def test_full_flow(self, db_engine):\n        \"\"\"Complete RSS → match → download pipeline.\"\"\"\n        # 1. Setup: create engine with real in-memory DB\n        engine = RSSEngine(_engine=db_engine)\n\n        # 2. Add RSS feed and Bangumi to DB\n        rss_item = make_rss_item(name=\"My Feed\", url=\"https://mikanani.me/RSS/test\")\n        engine.rss.add(rss_item)\n\n        bangumi = make_bangumi(\n            title_raw=\"Mushoku Tensei\",\n            official_title=\"Mushoku Tensei\",\n            filter=\"\",\n            added=True,\n        )\n        engine.bangumi.add(bangumi)\n\n        # 3. Mock the HTTP layer to return new torrents\n        new_torrents = [\n            Torrent(\n                name=\"[Sub] Mushoku Tensei - 11 [1080p].mkv\",\n                url=\"https://example.com/ep11.torrent\",\n            ),\n            Torrent(\n                name=\"[Sub] Mushoku Tensei - 12 [1080p].mkv\",\n                url=\"https://example.com/ep12.torrent\",\n            ),\n            Torrent(\n                name=\"[Other] Unknown Anime - 01 [720p].mkv\",\n                url=\"https://example.com/unknown.torrent\",\n            ),\n        ]\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = new_torrents\n\n            # 4. Mock download client\n            mock_client = AsyncMock()\n            mock_client.add_torrent = AsyncMock(return_value=True)\n\n            # 5. Execute refresh_rss\n            await engine.refresh_rss(mock_client)\n\n        # 6. Verify: matched torrents were downloaded\n        assert mock_client.add_torrent.call_count == 2\n\n        # 7. Verify: all torrents stored in DB\n        all_torrents = engine.torrent.search_all()\n        assert len(all_torrents) == 3\n\n        # 8. Verify: matched torrents are marked downloaded\n        downloaded = [t for t in all_torrents if t.downloaded]\n        assert len(downloaded) == 2\n        # All downloaded torrents should contain \"Mushoku Tensei\"\n        for t in downloaded:\n            assert \"Mushoku Tensei\" in t.name\n\n        # 9. Verify: unmatched torrent is NOT downloaded\n        not_downloaded = [t for t in all_torrents if not t.downloaded]\n        assert len(not_downloaded) == 1\n        assert \"Unknown Anime\" in not_downloaded[0].name\n\n    async def test_filtered_torrents_not_downloaded(self, db_engine):\n        \"\"\"Torrents matching the filter regex are NOT downloaded.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        rss_item = make_rss_item()\n        engine.rss.add(rss_item)\n\n        # Bangumi has filter=\"720\" to exclude 720p\n        bangumi = make_bangumi(\n            title_raw=\"Mushoku Tensei\",\n            filter=\"720\",\n        )\n        engine.bangumi.add(bangumi)\n\n        torrents = [\n            Torrent(\n                name=\"[Sub] Mushoku Tensei - 01 [720p].mkv\",\n                url=\"https://example.com/720.torrent\",\n            ),\n            Torrent(\n                name=\"[Sub] Mushoku Tensei - 01 [1080p].mkv\",\n                url=\"https://example.com/1080.torrent\",\n            ),\n        ]\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = torrents\n            mock_client = AsyncMock()\n            mock_client.add_torrent = AsyncMock(return_value=True)\n            await engine.refresh_rss(mock_client)\n\n        # Only 1080p should be downloaded (720p is filtered)\n        assert mock_client.add_torrent.call_count == 1\n\n    async def test_duplicate_torrents_not_reprocessed(self, db_engine):\n        \"\"\"Torrents already in the DB are not processed again.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        rss_item = make_rss_item()\n        engine.rss.add(rss_item)\n\n        bangumi = make_bangumi(title_raw=\"Anime\", filter=\"\")\n        engine.bangumi.add(bangumi)\n\n        # Pre-insert a torrent\n        existing = Torrent(\n            name=\"[Sub] Anime - 01 [1080p].mkv\",\n            url=\"https://example.com/ep01.torrent\",\n            downloaded=True,\n        )\n        engine.torrent.add(existing)\n\n        # Mock returns same torrent + a new one\n        torrents = [\n            Torrent(\n                name=\"[Sub] Anime - 01 [1080p].mkv\",\n                url=\"https://example.com/ep01.torrent\",\n            ),\n            Torrent(\n                name=\"[Sub] Anime - 02 [1080p].mkv\",\n                url=\"https://example.com/ep02.torrent\",\n            ),\n        ]\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = torrents\n            mock_client = AsyncMock()\n            mock_client.add_torrent = AsyncMock(return_value=True)\n            await engine.refresh_rss(mock_client)\n\n        # Only ep02 should be downloaded (ep01 already exists)\n        assert mock_client.add_torrent.call_count == 1\n        all_torrents = engine.torrent.search_all()\n        assert len(all_torrents) == 2  # original + new one\n\n\n# ---------------------------------------------------------------------------\n# Rename Flow\n# ---------------------------------------------------------------------------\n\n\nclass TestRenameFlow:\n    \"\"\"End-to-end: completed torrent → parse → rename → notification.\"\"\"\n\n    async def test_single_file_rename(self, mock_qb_client):\n        \"\"\"Single-file torrent is parsed and renamed correctly.\"\"\"\n        from module.manager.renamer import Renamer\n\n        # Setup renamer with mocked client\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                renamer = Renamer()\n        renamer.client = mock_qb_client\n\n        # Mock completed torrent info\n        mock_qb_client.torrents_info.return_value = [\n            {\n                \"hash\": \"abc123\",\n                \"name\": \"[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv\",\n                \"save_path\": \"/downloads/Bangumi/Mushoku Tensei (2024)/Season 1\",\n            }\n        ]\n        mock_qb_client.torrents_files.return_value = [\n            {\"name\": \"[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv\"}\n        ]\n        mock_qb_client.torrents_rename_file.return_value = True\n\n        ep = EpisodeFile(\n            media_path=\"[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv\",\n            title=\"Mushoku Tensei\",\n            season=1,\n            episode=11,\n            suffix=\".mkv\",\n        )\n\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=ep):\n            with patch(\"module.manager.renamer.settings\") as mock_mgr_settings:\n                mock_mgr_settings.bangumi_manage.rename_method = \"pn\"\n                mock_mgr_settings.bangumi_manage.remove_bad_torrent = False\n                with patch(\"module.downloader.path.settings\") as mock_path_settings:\n                    mock_path_settings.downloader.path = \"/downloads/Bangumi\"\n                    result = await renamer.rename()\n\n        # Verify: file was renamed\n        mock_qb_client.torrents_rename_file.assert_called_once()\n        call_args = mock_qb_client.torrents_rename_file.call_args\n        assert \"S01E11\" in str(call_args)\n\n        # Verify: notification returned\n        assert len(result) == 1\n        assert result[0].official_title == \"Mushoku Tensei (2024)\"\n        assert result[0].episode == 11\n\n    async def test_collection_rename(self, mock_qb_client):\n        \"\"\"Multi-file torrent is treated as collection and re-categorized.\"\"\"\n        from module.manager.renamer import Renamer\n\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                renamer = Renamer()\n        renamer.client = mock_qb_client\n\n        mock_qb_client.torrents_info.return_value = [\n            {\n                \"hash\": \"batch_hash\",\n                \"name\": \"Anime Batch\",\n                \"save_path\": \"/downloads/Bangumi/Anime (2024)/Season 1\",\n            }\n        ]\n        mock_qb_client.torrents_files.return_value = [\n            {\"name\": \"ep01.mkv\"},\n            {\"name\": \"ep02.mkv\"},\n            {\"name\": \"ep03.mkv\"},\n        ]\n        mock_qb_client.torrents_rename_file.return_value = True\n\n        def mock_parser(torrent_path, season, **kwargs):\n            ep_num = int(torrent_path.replace(\"ep\", \"\").replace(\".mkv\", \"\"))\n            return EpisodeFile(\n                media_path=torrent_path,\n                title=\"Anime\",\n                season=season,\n                episode=ep_num,\n                suffix=\".mkv\",\n            )\n\n        with patch.object(renamer._parser, \"torrent_parser\", side_effect=mock_parser):\n            with patch(\"module.manager.renamer.settings\") as mock_mgr_settings:\n                mock_mgr_settings.bangumi_manage.rename_method = \"pn\"\n                mock_mgr_settings.bangumi_manage.remove_bad_torrent = False\n                with patch(\"module.downloader.path.settings\") as mock_path_settings:\n                    mock_path_settings.downloader.path = \"/downloads/Bangumi\"\n                    await renamer.rename()\n\n        # Verify: all 3 files renamed\n        assert mock_qb_client.torrents_rename_file.call_count == 3\n        # Verify: category set to BangumiCollection\n        mock_qb_client.set_category.assert_called_once_with(\n            \"batch_hash\", \"BangumiCollection\"\n        )\n\n\n# ---------------------------------------------------------------------------\n# Database Consistency\n# ---------------------------------------------------------------------------\n\n\nclass TestDatabaseConsistency:\n    \"\"\"Verify database operations maintain data integrity across operations.\"\"\"\n\n    def test_bangumi_uniqueness_by_title_raw(self, db_engine):\n        \"\"\"Cannot add two Bangumi with same title_raw.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        b1 = make_bangumi(title_raw=\"Same Title\", official_title=\"First\")\n        b2 = make_bangumi(title_raw=\"Same Title\", official_title=\"Second\")\n\n        assert engine.bangumi.add(b1) is True\n        assert engine.bangumi.add(b2) is False  # Duplicate rejected\n\n        all_bangumi = engine.bangumi.search_all()\n        assert len(all_bangumi) == 1\n        assert all_bangumi[0].official_title == \"First\"\n\n    def test_rss_uniqueness_by_url(self, db_engine):\n        \"\"\"Cannot add two RSSItems with same URL.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        r1 = make_rss_item(url=\"https://same.url/rss\", name=\"First\")\n        r2 = make_rss_item(url=\"https://same.url/rss\", name=\"Second\")\n\n        assert engine.rss.add(r1) is True\n        assert engine.rss.add(r2) is False\n\n    def test_torrent_check_new_filters_duplicates(self, db_engine):\n        \"\"\"check_new only returns torrents not already in the database.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        existing = Torrent(name=\"existing\", url=\"https://existing.com\")\n        engine.torrent.add(existing)\n\n        candidates = [\n            Torrent(name=\"existing\", url=\"https://existing.com\"),\n            Torrent(name=\"new1\", url=\"https://new1.com\"),\n            Torrent(name=\"new2\", url=\"https://new2.com\"),\n        ]\n        new_ones = engine.torrent.check_new(candidates)\n        assert len(new_ones) == 2\n        assert all(t.url != \"https://existing.com\" for t in new_ones)\n\n    def test_match_torrent_respects_deleted_flag(self, db_engine):\n        \"\"\"Deleted bangumi are not matched by match_torrent.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        bangumi = make_bangumi(title_raw=\"Deleted Anime\", filter=\"\", deleted=True)\n        engine.bangumi.add(bangumi)\n\n        torrent = Torrent(\n            name=\"[Sub] Deleted Anime - 01 [1080p].mkv\",\n            url=\"https://test.com\",\n        )\n        result = engine.match_torrent(torrent)\n        assert result is None\n\n    def test_bangumi_disable_and_enable(self, db_engine):\n        \"\"\"disable_rule and re-enabling preserves data.\"\"\"\n        engine = RSSEngine(_engine=db_engine)\n\n        bangumi = make_bangumi(title_raw=\"My Anime\", deleted=False)\n        engine.bangumi.add(bangumi)\n        bangumi_id = engine.bangumi.search_all()[0].id\n\n        # Disable\n        engine.bangumi.disable_rule(bangumi_id)\n        disabled = engine.bangumi.search_id(bangumi_id)\n        assert disabled.deleted is True\n\n        # Torrent matching should now fail\n        torrent = Torrent(name=\"[Sub] My Anime - 01.mkv\", url=\"https://test.com\")\n        assert engine.match_torrent(torrent) is None\n"
  },
  {
    "path": "backend/src/test/test_issue_bugs.py",
    "content": "\"\"\"Tests reproducing bugs from GitHub issues #974, #976, #977, #986.\n\nEach test class targets a specific issue with tests that demonstrate\nthe current (buggy) behavior and the expected (fixed) behavior.\n\"\"\"\n\nimport re\n\nimport pytest\n\nfrom module.models import EpisodeFile\nfrom module.manager.renamer import Renamer\nfrom module.parser.analyser.raw_parser import (\n    get_group,\n    process,\n    raw_parser,\n)\n\n\n# ---------------------------------------------------------------------------\n# Issue #986: Parser fails on [group][title][episode_text] format\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/986\n#\n# Torrent names from Atlas subtitle group use a [group][title][ep_text]\n# format instead of the typical [group] title - ep [tags] format.\n# The raw_parser's TITLE_RE regex doesn't match, returning None.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue986AtlasSubGroupFormat:\n    \"\"\"Issue #986: Parser crashes on Atlas subtitle group naming convention.\"\"\"\n\n    ATLAS_TITLES = [\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv\",\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv\",\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][03_无英灵的战斗][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC].mkv\",\n    ]\n\n    def test_get_group_extracts_atlas_group(self):\n        \"\"\"get_group should extract the group name from [group][title][ep] format.\"\"\"\n        name = \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][04_半神们的卡农曲]\"\n        group = get_group(name)\n        assert group == \"阿特拉斯字幕组·雪原市出差所\"\n\n    def test_process_returns_none_for_atlas_format(self):\n        \"\"\"process() currently returns None for Atlas format (bug demonstration).\"\"\"\n        title = self.ATLAS_TITLES[0]\n        result = process(title)\n        # BUG: process returns None because TITLE_RE doesn't match this format\n        assert result is None, (\n            \"If this passes, the parser still can't handle Atlas format. \"\n            \"If it fails (result is not None), the bug may have been fixed!\"\n        )\n\n    def test_raw_parser_returns_none_for_atlas_format(self):\n        \"\"\"raw_parser returns None for Atlas format, causing AttributeError downstream.\"\"\"\n        title = self.ATLAS_TITLES[0]\n        result = raw_parser(title)\n        # BUG: returns None → downstream code does .groups() on None → AttributeError\n        assert result is None\n\n    @pytest.mark.parametrize(\"title\", ATLAS_TITLES)\n    def test_atlas_titles_all_fail_to_parse(self, title):\n        \"\"\"All Atlas format titles fail to parse.\"\"\"\n        result = raw_parser(title)\n        assert result is None\n\n    def test_get_group_returns_empty_for_no_brackets(self):\n        \"\"\"get_group returns empty string for title without brackets (regression guard).\"\"\"\n        result = get_group(\"No Brackets Title\")\n        assert result == \"\"\n\n    def test_get_group_does_not_crash_on_empty_string(self):\n        \"\"\"get_group handles empty string without crashing.\"\"\"\n        result = get_group(\"\")\n        assert result == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Issue #977: Episode 0 (specials/OVAs) incorrectly renamed to E01\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/977\n#\n# When a file is S01E00.mkv (episode 0 special), and there's a positive\n# episode_offset (e.g. from offset scanner), the renamer changes it to\n# S01E01.mkv which overwrites the real episode 1.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue977EpisodeZeroOffset:\n    \"\"\"Issue #977: Episode 0 should not be shifted by positive offset.\"\"\"\n\n    def test_episode_zero_preserved_with_no_offset(self):\n        \"\"\"Episode 0 with offset=0 stays as E00.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\",\n            title=\"Fate strange Fake\",\n            season=1,\n            episode=0,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(\n            ep, \"Fate strange Fake\", method=\"pn\", episode_offset=0\n        )\n        assert \"E00\" in result\n\n    def test_episode_zero_immune_to_positive_offset(self):\n        \"\"\"Episode 0 (special/OVA) should not be shifted by positive offset.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\",\n            title=\"Fate strange Fake\",\n            season=1,\n            episode=0,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(\n            ep, \"Fate strange Fake\", method=\"pn\", episode_offset=1\n        )\n        assert \"E00\" in result\n\n    def test_episode_zero_immune_to_negative_offset(self):\n        \"\"\"Episode 0 (special/OVA) should not be shifted by negative offset.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\",\n            title=\"Fate strange Fake\",\n            season=1,\n            episode=0,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(\n            ep, \"Fate strange Fake\", method=\"pn\", episode_offset=-12\n        )\n        assert \"E00\" in result\n\n    def test_regular_episode_offset_still_works(self):\n        \"\"\"Regular episodes should still be affected by offset normally.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\",\n            title=\"Test\",\n            season=1,\n            episode=13,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(ep, \"Test\", method=\"pn\", episode_offset=-12)\n        assert \"E01\" in result  # 13 - 12 = 1\n\n    def test_episode_zero_advance_method(self):\n        \"\"\"Episode 0 with advance method and no offset stays E00.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\",\n            title=\"Test\",\n            season=1,\n            episode=0,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(\n            ep, \"Bangumi Name\", method=\"advance\", episode_offset=0\n        )\n        assert result == \"Bangumi Name S01E00.mkv\"\n\n\n# ---------------------------------------------------------------------------\n# Issue #976: NoneType in match_list causes TypeError\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/976\n#\n# When bangumi records have None as title_raw or aliases contain None,\n# sorted(title_index.keys(), key=len) crashes because len(None) fails.\n# Also, get_group crashes with IndexError on names without brackets.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue976NoneInMatchList:\n    \"\"\"Issue #976: match_list should handle None titles gracefully.\"\"\"\n\n    def test_match_list_filters_none_title_raw(self, db_session):\n        \"\"\"match_list should skip bangumi with title_raw=None.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n\n        # Create bangumi with None-ish title_raw\n        b1 = Bangumi(\n            official_title=\"Normal Anime\",\n            year=\"2024\",\n            title_raw=\"[Group] Normal Anime\",\n            season=1,\n        )\n        db.add(b1)\n\n        # The match_list code now checks `if m.title_raw:` before adding to index\n        # This test verifies that path works when all entries are valid\n        match_datas = db.search_all()\n        title_index = {}\n        for m in match_datas:\n            if m.title_raw:\n                title_index[m.title_raw] = m\n\n        # Should not raise TypeError\n        sorted_titles = sorted(title_index.keys(), key=len, reverse=True)\n        assert len(sorted_titles) == 1\n\n    def test_sorted_with_none_key_raises_typeerror(self):\n        \"\"\"Demonstrate that sorted() with None keys crashes (the original bug).\"\"\"\n        title_index = {\"valid_title\": \"data\", None: \"bad_data\"}\n        with pytest.raises(TypeError, match=\"'NoneType'\"):\n            sorted(title_index.keys(), key=len, reverse=True)\n\n    def test_empty_title_index_produces_empty_pattern(self):\n        \"\"\"When all titles are None/empty, the regex pattern should be empty.\"\"\"\n        title_index = {}\n        sorted_titles = sorted(title_index.keys(), key=len, reverse=True)\n        pattern = \"|\".join(re.escape(t) for t in sorted_titles)\n        assert pattern == \"\"\n\n    def test_get_group_no_brackets_returns_empty(self):\n        \"\"\"get_group handles names without brackets (regression for IndexError).\"\"\"\n        # The original code did: re.split(r\"[\\[\\]]\", name)[1]\n        # which crashes with IndexError when there are no brackets\n        result = get_group(\"No Brackets At All\")\n        assert result == \"\"\n\n    def test_get_group_single_bracket_pair(self):\n        \"\"\"get_group extracts group from single bracket pair.\"\"\"\n        result = get_group(\"[GroupName] Some Title\")\n        assert result == \"GroupName\"\n\n    def test_get_group_empty_brackets(self):\n        \"\"\"get_group handles empty brackets.\"\"\"\n        result = get_group(\"[] empty\")\n        assert result == \"\"\n\n\n# ---------------------------------------------------------------------------\n# Issue #974: PatternError when filter string contains regex special chars\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/974\n#\n# The _get_filter_pattern method does filter_str.replace(\",\", \"|\") and\n# then re.compile(). If the filter contains regex special characters\n# like [ ] ( ) etc., it causes PatternError.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue974FilterPatternError:\n    \"\"\"Issue #974: Filter strings with regex special chars crash re.compile.\"\"\"\n\n    def test_normal_filter_compiles(self):\n        \"\"\"Normal filter string like '720,繁体' works fine.\"\"\"\n        filter_str = \"720,繁体\"\n        pattern_str = filter_str.replace(\",\", \"|\")\n        pattern = re.compile(pattern_str, re.IGNORECASE)\n        assert pattern.search(\"720p test\")\n        assert pattern.search(\"繁体字幕\")\n        assert not pattern.search(\"1080p 简体\")\n\n    def test_raw_unterminated_bracket_is_invalid_regex(self):\n        \"\"\"Demonstrate that unterminated '[' is invalid regex.\"\"\"\n        filter_str = \"720,[字幕组\"\n        pattern_str = filter_str.replace(\",\", \"|\")\n        with pytest.raises(re.error):\n            re.compile(pattern_str, re.IGNORECASE)\n\n    def test_engine_handles_unterminated_bracket(self):\n        \"\"\"_get_filter_pattern falls back to literal matching for invalid regex.\"\"\"\n        from module.rss.engine import RSSEngine\n        from unittest.mock import MagicMock\n\n        engine = RSSEngine.__new__(RSSEngine)\n        engine._filter_cache = {}\n        pattern = engine._get_filter_pattern(\"720,[字幕组\")\n        # Should not raise — falls back to escaped literal matching\n        assert pattern.search(\"720p video\")\n        assert pattern.search(\"[字幕组 release\")\n        assert not pattern.search(\"1080p no match\")\n\n    def test_engine_handles_unmatched_parenthesis(self):\n        \"\"\"_get_filter_pattern falls back for unmatched '('.\"\"\"\n        from module.rss.engine import RSSEngine\n\n        engine = RSSEngine.__new__(RSSEngine)\n        engine._filter_cache = {}\n        pattern = engine._get_filter_pattern(\"720,test(v2\")\n        assert pattern.search(\"720p\")\n        assert pattern.search(\"test(v2 stuff\")\n\n    def test_engine_handles_trailing_backslash(self):\n        \"\"\"_get_filter_pattern falls back for trailing backslash.\"\"\"\n        from module.rss.engine import RSSEngine\n\n        engine = RSSEngine.__new__(RSSEngine)\n        engine._filter_cache = {}\n        pattern = engine._get_filter_pattern(\"720,path\\\\\")\n        assert pattern.search(\"720p\")\n\n    def test_engine_default_filter_still_uses_regex(self):\n        r\"\"\"Default filter '720,\\d+-\\d+' is valid regex and used as-is.\"\"\"\n        from module.rss.engine import RSSEngine\n\n        engine = RSSEngine.__new__(RSSEngine)\n        engine._filter_cache = {}\n        pattern = engine._get_filter_pattern(r\"720,\\d+-\\d+\")\n        assert pattern.search(\"720p video\")\n        assert pattern.search(\"01-12 batch\")\n        assert not pattern.search(\"1080p single episode\")\n\n    def test_engine_caches_filter_pattern(self):\n        \"\"\"Filter patterns are cached to avoid recompilation.\"\"\"\n        from module.rss.engine import RSSEngine\n\n        engine = RSSEngine.__new__(RSSEngine)\n        engine._filter_cache = {}\n        p1 = engine._get_filter_pattern(\"720,1080\")\n        p2 = engine._get_filter_pattern(\"720,1080\")\n        assert p1 is p2\n\n\n# ---------------------------------------------------------------------------\n# Issue #990: Titles starting with numbers cause title_raw=None, crashing\n#             the RSS loop with TypeError in match_torrent\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/990\n#\n# \"[ANi] 29 岁单身中坚冒险家的日常 - 07\" → regex matches \"29 \" as episode,\n# title becomes empty → title_raw=None → None stored as alias → crash on\n# `None in torrent_name` in match_torrent.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue990NumberPrefixTitle:\n    \"\"\"Issue #990: Titles starting with numbers crash RSS loop.\"\"\"\n\n    PROBLEM_TITLE = (\n        \"[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]\"\n    )\n\n    def test_raw_parser_correctly_parses_leading_number_title(self):\n        \"\"\"raw_parser correctly parses title starting with number and extracts episode.\"\"\"\n        result = raw_parser(self.PROBLEM_TITLE)\n        assert result is not None\n        assert result.episode == 7\n        assert result.title_zh == \"29 岁单身中坚冒险家的日常\"\n        assert result.resolution == \"1080P\"\n        assert result.group == \"ANi\"\n\n    def test_title_parser_returns_bangumi_for_number_prefix_title(self):\n        \"\"\"TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles.\"\"\"\n        from module.parser.title_parser import TitleParser\n\n        result = TitleParser.raw_parser(self.PROBLEM_TITLE)\n        assert result is not None\n        assert result.official_title == \"29 岁单身中坚冒险家的日常\"\n        assert result.title_raw == \"29 岁单身中坚冒险家的日常\"\n\n    def test_add_title_alias_rejects_none(self, db_session):\n        \"\"\"add_title_alias should reject None as alias.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"29岁单身冒险家的日常\",\n            title_raw=\"[ANi] 29岁单身冒险家的日常\",\n            season=1,\n        )\n        db_session.add(bangumi)\n        db_session.commit()\n\n        result = db.add_title_alias(bangumi.id, None)\n        assert result is False\n        # Verify no alias was stored\n        assert bangumi.title_aliases is None\n\n    def test_add_title_alias_rejects_empty_string(self, db_session):\n        \"\"\"add_title_alias should reject empty string as alias.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            title_raw=\"[Group] Test Anime\",\n            season=1,\n        )\n        db_session.add(bangumi)\n        db_session.commit()\n\n        result = db.add_title_alias(bangumi.id, \"\")\n        assert result is False\n\n    def test_get_aliases_list_filters_null_values(self):\n        \"\"\"_get_aliases_list should filter out null values from JSON.\"\"\"\n        from module.database.bangumi import _get_aliases_list\n        from module.models import Bangumi\n\n        bangumi = Bangumi(title_raw=\"test\", official_title=\"Test\")\n        # Simulates the corrupted state: [null] stored in DB\n        bangumi.title_aliases = \"[null]\"\n        assert _get_aliases_list(bangumi) == []\n\n        # Mixed valid and null values\n        bangumi.title_aliases = '[null, \"valid_alias\", null, \"another\"]'\n        assert _get_aliases_list(bangumi) == [\"valid_alias\", \"another\"]\n\n    def test_get_all_title_patterns_skips_none_title_raw(self, db_session):\n        \"\"\"get_all_title_patterns should return empty list when title_raw is None.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        bangumi = Bangumi(official_title=\"Test Anime\")\n        bangumi.title_raw = None\n        bangumi.title_aliases = None\n\n        patterns = db.get_all_title_patterns(bangumi)\n        assert patterns == []\n\n    def test_match_torrent_no_crash_on_none_title_raw(self, db_session):\n        \"\"\"match_torrent should not crash when a bangumi has None title_raw.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        # Insert a bangumi with corrupted title_raw (simulating the bug state)\n        bangumi = Bangumi(\n            official_title=\"29岁单身冒险家的日常\",\n            season=1,\n        )\n        bangumi.title_raw = None\n        db_session.add(bangumi)\n        db_session.commit()\n\n        # Should not raise TypeError: 'in <string>' requires string\n        result = db.match_torrent(\n            \"[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL]\"\n        )\n        assert result is None\n\n    def test_match_torrent_no_crash_on_null_aliases(self, db_session):\n        \"\"\"match_torrent should not crash when title_aliases contains null.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"29岁单身冒险家的日常\",\n            title_raw=\"[ANi] 29岁单身冒险家的日常\",\n            season=1,\n        )\n        bangumi.title_aliases = \"[null]\"\n        db_session.add(bangumi)\n        db_session.commit()\n\n        # Should not crash — null aliases are filtered out\n        result = db.match_torrent(\n            \"[ANi] 29岁单身冒险家的日常 - 07 [1080P][Baha][WEB-DL]\"\n        )\n        assert result is not None\n        assert result.official_title == \"29岁单身冒险家的日常\"\n\n    def test_match_list_no_crash_on_corrupted_data(self, db_session):\n        \"\"\"match_list should handle bangumi with None title_raw and null aliases.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n        from unittest.mock import MagicMock\n\n        db = BangumiDatabase(db_session)\n\n        # Insert corrupted bangumi (title_raw=None, aliases=[null])\n        bangumi = Bangumi(official_title=\"29岁单身冒险家的日常\", season=1)\n        bangumi.title_raw = None\n        bangumi.title_aliases = \"[null]\"\n        db_session.add(bangumi)\n\n        # Insert a valid bangumi\n        valid = Bangumi(\n            official_title=\"葬送的芙莉莲\",\n            title_raw=\"葬送的芙莉莲 / Sousou no Frieren\",\n            season=1,\n        )\n        db_session.add(valid)\n        db_session.commit()\n\n        torrent = MagicMock()\n        torrent.name = \"[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P]\"\n\n        # Should not crash even with corrupted data in the DB\n        unmatched = db.match_list([torrent], \"https://mikanani.me/RSS/test\")\n\n\n# ---------------------------------------------------------------------------\n# Issue #992: Non-episodic resource causes AttributeError in title_parser\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/992\n#\n# When raw_parser returns None (movie/collection resources), title_parser\n# accesses episode.title_zh on None, causing AttributeError.\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue992NonEpisodicAttributeError:\n    \"\"\"Issue #992: title_parser crashes on non-episodic resources.\"\"\"\n\n    # Titles that raw_parser cannot parse (returns None)\n    NON_EPISODIC_TITLES = [\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC]\",\n        \"[KitaujiSub] Shikanoko Nokonoko Koshitantan [01Pre][WebRip][HEVC_AAC][CHS_JP].mp4\",\n    ]\n\n    @pytest.mark.parametrize(\"title\", NON_EPISODIC_TITLES)\n    def test_title_parser_returns_none_for_non_episodic(self, title):\n        \"\"\"TitleParser.raw_parser should return None instead of crashing.\"\"\"\n        from module.parser.title_parser import TitleParser\n\n        result = TitleParser.raw_parser(title)\n        assert result is None\n\n    def test_raw_parser_returns_none_for_unparseable(self):\n        \"\"\"raw_parser returns None for resources it cannot parse.\"\"\"\n        result = raw_parser(self.NON_EPISODIC_TITLES[0])\n        assert result is None\n\n\n# ---------------------------------------------------------------------------\n# Issue #1005: BangumiDatabase missing search_official_title method\n# https://github.com/EstrellaXD/Auto_Bangumi/issues/1005\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue1005SearchOfficialTitle:\n    \"\"\"Issue #1005: search_official_title method must exist on BangumiDatabase.\"\"\"\n\n    def test_method_exists(self):\n        \"\"\"BangumiDatabase should have search_official_title method.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n\n        assert hasattr(BangumiDatabase, \"search_official_title\")\n\n    def test_search_official_title_finds_match(self, db_session):\n        \"\"\"search_official_title returns the matching bangumi.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.models import Bangumi\n\n        db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"路人女主的养成方法\",\n            title_raw=\"Saenai Heroine no Sodatekata\",\n            season=1,\n            rss_link=\"test\",\n        )\n        db.add(bangumi)\n\n        result = db.search_official_title(\"路人女主的养成方法\")\n        assert result is not None\n        assert result.official_title == \"路人女主的养成方法\"\n\n    def test_search_official_title_returns_none_when_not_found(self, db_session):\n        \"\"\"search_official_title returns None for non-existent title.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n\n        db = BangumiDatabase(db_session)\n        result = db.search_official_title(\"不存在的番剧\")\n        assert result is None\n"
  },
  {
    "path": "backend/src/test/test_mcp_resources.py",
    "content": "\"\"\"Tests for module.mcp.resources - handle_resource() and _bangumi_to_dict().\"\"\"\n\nimport json\nfrom unittest.mock import MagicMock, patch\n\nimport pytest\n\nfrom module.mcp.resources import (\n    RESOURCE_TEMPLATES,\n    RESOURCES,\n    _bangumi_to_dict,\n    handle_resource,\n)\nfrom module.models import Bangumi, ResponseModel\nfrom test.factories import make_bangumi\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _mock_sync_manager(bangumi_list=None, single=None):\n    \"\"\"Build a MagicMock that acts as a sync context-manager TorrentManager.\"\"\"\n    mock_mgr = MagicMock()\n    if bangumi_list is not None:\n        mock_mgr.bangumi.search_all.return_value = bangumi_list\n    if single is not None:\n        mock_mgr.search_one.return_value = single\n\n    ctx = MagicMock()\n    ctx.__enter__ = MagicMock(return_value=mock_mgr)\n    ctx.__exit__ = MagicMock(return_value=False)\n    return ctx, mock_mgr\n\n\ndef _mock_rss_engine(feeds):\n    \"\"\"Build a MagicMock that acts as a sync context-manager RSSEngine.\"\"\"\n    mock_eng = MagicMock()\n    mock_eng.rss.search_all.return_value = feeds\n\n    ctx = MagicMock()\n    ctx.__enter__ = MagicMock(return_value=mock_eng)\n    ctx.__exit__ = MagicMock(return_value=False)\n    return ctx\n\n\ndef _parse(raw: str) -> dict | list:\n    return json.loads(raw)\n\n\n# ---------------------------------------------------------------------------\n# Static metadata (RESOURCES / RESOURCE_TEMPLATES)\n# ---------------------------------------------------------------------------\n\n\nclass TestResourceMetadata:\n    \"\"\"Verify the static resource and template lists.\"\"\"\n\n    def test_resources_is_list(self):\n        assert isinstance(RESOURCES, list)\n\n    def test_resources_not_empty(self):\n        assert len(RESOURCES) > 0\n\n    def test_resource_uris_present(self):\n        uris = {str(r.uri) for r in RESOURCES}\n        assert \"autobangumi://anime/list\" in uris\n        assert \"autobangumi://status\" in uris\n        assert \"autobangumi://rss/feeds\" in uris\n\n    def test_resource_templates_is_list(self):\n        assert isinstance(RESOURCE_TEMPLATES, list)\n\n    def test_anime_id_template_present(self):\n        templates = [str(t.uriTemplate) for t in RESOURCE_TEMPLATES]\n        assert \"autobangumi://anime/{id}\" in templates\n\n\n# ---------------------------------------------------------------------------\n# _bangumi_to_dict (resources module version)\n# ---------------------------------------------------------------------------\n\n\nclass TestBangumiToDictResources:\n    \"\"\"\n    resources._bangumi_to_dict is a leaner version of the tools one\n    (no dpi/source/subtitle/group_name fields).\n    \"\"\"\n\n    @pytest.fixture\n    def sample(self) -> Bangumi:\n        return make_bangumi(\n            id=10,\n            official_title=\"Demon Slayer\",\n            title_raw=\"Kimetsu no Yaiba\",\n            season=3,\n            episode_offset=2,\n            season_offset=1,\n            filter=\"720\",\n            rss_link=\"https://mikanani.me/RSS/ds\",\n            poster_link=\"/poster/ds.jpg\",\n            added=True,\n            save_path=\"/downloads/Demon Slayer\",\n            deleted=False,\n            archived=False,\n            eps_collect=True,\n        )\n\n    def test_returns_dict(self, sample):\n        assert isinstance(_bangumi_to_dict(sample), dict)\n\n    def test_required_keys_present(self, sample):\n        result = _bangumi_to_dict(sample)\n        required = {\n            \"id\",\n            \"official_title\",\n            \"title_raw\",\n            \"season\",\n            \"episode_offset\",\n            \"season_offset\",\n            \"filter\",\n            \"rss_link\",\n            \"poster_link\",\n            \"added\",\n            \"save_path\",\n            \"deleted\",\n            \"archived\",\n            \"eps_collect\",\n        }\n        assert required.issubset(result.keys())\n\n    def test_id_value(self, sample):\n        assert _bangumi_to_dict(sample)[\"id\"] == 10\n\n    def test_official_title_value(self, sample):\n        assert _bangumi_to_dict(sample)[\"official_title\"] == \"Demon Slayer\"\n\n    def test_eps_collect_true(self, sample):\n        assert _bangumi_to_dict(sample)[\"eps_collect\"] is True\n\n    def test_none_optional_poster(self):\n        b = make_bangumi(id=1, poster_link=None)\n        assert _bangumi_to_dict(b)[\"poster_link\"] is None\n\n\n# ---------------------------------------------------------------------------\n# handle_resource - known static URIs\n# ---------------------------------------------------------------------------\n\n\nclass TestHandleResourceAnimeList:\n    \"\"\"Tests for autobangumi://anime/list.\"\"\"\n\n    def test_returns_json_string(self):\n        \"\"\"Result is a valid JSON string.\"\"\"\n        ctx, _ = _mock_sync_manager(bangumi_list=[])\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            raw = handle_resource(\"autobangumi://anime/list\")\n        assert isinstance(raw, str)\n        _parse(raw)  # must not raise\n\n    def test_empty_database_returns_empty_list(self):\n        \"\"\"Empty DB produces an empty JSON array.\"\"\"\n        ctx, _ = _mock_sync_manager(bangumi_list=[])\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/list\"))\n        assert result == []\n\n    def test_multiple_bangumi_serialised(self):\n        \"\"\"Multiple Bangumi entries all appear in the output list.\"\"\"\n        bangumi = [make_bangumi(id=1), make_bangumi(id=2, title_raw=\"Other\")]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/list\"))\n        assert len(result) == 2\n\n    def test_ids_are_in_output(self):\n        \"\"\"Each serialised entry contains its correct id.\"\"\"\n        bangumi = [make_bangumi(id=7), make_bangumi(id=8, title_raw=\"B\")]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/list\"))\n        ids = {item[\"id\"] for item in result}\n        assert {7, 8}.issubset(ids)\n\n    def test_non_ascii_titles_preserved(self):\n        \"\"\"Japanese/Chinese titles survive JSON serialisation.\"\"\"\n        bangumi = [make_bangumi(id=1, official_title=\"進撃の巨人\")]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            raw = handle_resource(\"autobangumi://anime/list\")\n        # ensure_ascii=False means the characters appear verbatim\n        assert \"進撃の巨人\" in raw\n\n\nclass TestHandleResourceStatus:\n    \"\"\"Tests for autobangumi://status.\"\"\"\n\n    @pytest.fixture\n    def mock_program(self):\n        prog = MagicMock()\n        prog.is_running = True\n        prog.first_run = False\n        return prog\n\n    def test_returns_json_string(self, mock_program):\n        with (\n            patch(\"module.mcp.resources.VERSION\", \"3.2.0-test\"),\n            patch(\"module.api.program.program\", mock_program),\n        ):\n            raw = handle_resource(\"autobangumi://status\")\n        assert isinstance(raw, str)\n        _parse(raw)\n\n    def test_version_in_output(self, mock_program):\n        with (\n            patch(\"module.mcp.resources.VERSION\", \"3.2.0-test\"),\n            patch(\"module.api.program.program\", mock_program),\n        ):\n            result = _parse(handle_resource(\"autobangumi://status\"))\n        assert result[\"version\"] == \"3.2.0-test\"\n\n    def test_running_true(self, mock_program):\n        mock_program.is_running = True\n        with (\n            patch(\"module.mcp.resources.VERSION\", \"3.2.0-test\"),\n            patch(\"module.api.program.program\", mock_program),\n        ):\n            result = _parse(handle_resource(\"autobangumi://status\"))\n        assert result[\"running\"] is True\n\n    def test_first_run_false(self, mock_program):\n        mock_program.first_run = False\n        with (\n            patch(\"module.mcp.resources.VERSION\", \"3.2.0-test\"),\n            patch(\"module.api.program.program\", mock_program),\n        ):\n            result = _parse(handle_resource(\"autobangumi://status\"))\n        assert result[\"first_run\"] is False\n\n    def test_all_keys_present(self, mock_program):\n        with (\n            patch(\"module.mcp.resources.VERSION\", \"3.2.0-test\"),\n            patch(\"module.api.program.program\", mock_program),\n        ):\n            result = _parse(handle_resource(\"autobangumi://status\"))\n        assert {\"version\", \"running\", \"first_run\"}.issubset(result.keys())\n\n\nclass TestHandleResourceRssFeeds:\n    \"\"\"Tests for autobangumi://rss/feeds.\"\"\"\n\n    def _make_feed(self, feed_id=1, name=\"TestFeed\", url=\"https://example.com/rss\"):\n        f = MagicMock()\n        f.id = feed_id\n        f.name = name\n        f.url = url\n        f.enabled = True\n        f.connection_status = \"ok\"\n        f.last_checked_at = \"2024-01-01T00:00:00\"\n        return f\n\n    def test_returns_json_string(self):\n        ctx = _mock_rss_engine([])\n        with patch(\"module.mcp.resources.RSSEngine\", return_value=ctx):\n            raw = handle_resource(\"autobangumi://rss/feeds\")\n        assert isinstance(raw, str)\n        _parse(raw)\n\n    def test_empty_feeds_returns_empty_list(self):\n        ctx = _mock_rss_engine([])\n        with patch(\"module.mcp.resources.RSSEngine\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://rss/feeds\"))\n        assert result == []\n\n    def test_feed_fields_present(self):\n        feed = self._make_feed(feed_id=2, name=\"Mikan\", url=\"https://mikanani.me/rss\")\n        ctx = _mock_rss_engine([feed])\n        with patch(\"module.mcp.resources.RSSEngine\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://rss/feeds\"))\n        entry = result[0]\n        assert entry[\"id\"] == 2\n        assert entry[\"name\"] == \"Mikan\"\n        assert entry[\"url\"] == \"https://mikanani.me/rss\"\n        assert \"enabled\" in entry\n        assert \"connection_status\" in entry\n        assert \"last_checked_at\" in entry\n\n    def test_multiple_feeds(self):\n        feeds = [\n            self._make_feed(1, \"Feed A\", \"https://a.example.com/rss\"),\n            self._make_feed(2, \"Feed B\", \"https://b.example.com/rss\"),\n        ]\n        ctx = _mock_rss_engine(feeds)\n        with patch(\"module.mcp.resources.RSSEngine\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://rss/feeds\"))\n        assert len(result) == 2\n\n\n# ---------------------------------------------------------------------------\n# handle_resource - anime/{id} template\n# ---------------------------------------------------------------------------\n\n\nclass TestHandleResourceAnimeById:\n    \"\"\"Tests for the autobangumi://anime/{id} template.\"\"\"\n\n    def test_valid_id_returns_bangumi_dict(self):\n        \"\"\"A numeric ID resolves to the bangumi's serialised dict.\"\"\"\n        bangumi = make_bangumi(id=3, official_title=\"Fullmetal Alchemist\")\n        ctx, _ = _mock_sync_manager(single=bangumi)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/3\"))\n        assert result[\"id\"] == 3\n        assert result[\"official_title\"] == \"Fullmetal Alchemist\"\n\n    def test_not_found_id_returns_error(self):\n        \"\"\"When search_one returns a ResponseModel, result contains 'error'.\"\"\"\n        not_found = ResponseModel(\n            status=False, status_code=404, msg_en=\"Anime not found\", msg_zh=\"未找到\"\n        )\n        ctx, _ = _mock_sync_manager(single=not_found)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/9999\"))\n        assert \"error\" in result\n\n    def test_non_numeric_id_returns_error(self):\n        \"\"\"A non-integer ID segment produces a JSON error without crashing.\"\"\"\n        result = _parse(handle_resource(\"autobangumi://anime/abc\"))\n        assert \"error\" in result\n        assert \"abc\" in result[\"error\"]\n\n    def test_negative_id_is_passed_to_manager(self):\n        \"\"\"Negative integers are valid integers and passed through.\"\"\"\n        not_found = ResponseModel(\n            status=False, status_code=404, msg_en=\"Anime not found\", msg_zh=\"未找到\"\n        )\n        ctx, mock_mgr = _mock_sync_manager(single=not_found)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            handle_resource(\"autobangumi://anime/-1\")\n        mock_mgr.search_one.assert_called_once_with(-1)\n\n    def test_result_has_required_fields(self):\n        \"\"\"Returned dict contains all expected bangumi fields.\"\"\"\n        bangumi = make_bangumi(id=5)\n        ctx, _ = _mock_sync_manager(single=bangumi)\n        with patch(\"module.mcp.resources.TorrentManager\", return_value=ctx):\n            result = _parse(handle_resource(\"autobangumi://anime/5\"))\n        required = {\"id\", \"official_title\", \"title_raw\", \"season\", \"rss_link\"}\n        assert required.issubset(result.keys())\n\n\n# ---------------------------------------------------------------------------\n# handle_resource - unknown URI\n# ---------------------------------------------------------------------------\n\n\nclass TestHandleResourceUnknown:\n    \"\"\"Tests for unrecognised resource URIs.\"\"\"\n\n    def test_unknown_uri_returns_json_error(self):\n        \"\"\"An unrecognised URI produces a JSON object with 'error'.\"\"\"\n        result = _parse(handle_resource(\"autobangumi://does/not/exist\"))\n        assert \"error\" in result\n\n    def test_unknown_uri_mentions_uri_in_error(self):\n        \"\"\"The error message includes the unrecognised URI.\"\"\"\n        uri = \"autobangumi://does/not/exist\"\n        result = _parse(handle_resource(uri))\n        assert uri in result[\"error\"]\n\n    def test_empty_uri_returns_error(self):\n        \"\"\"An empty string URI returns a JSON error.\"\"\"\n        result = _parse(handle_resource(\"\"))\n        assert \"error\" in result\n\n    def test_completely_different_scheme_returns_error(self):\n        \"\"\"A URI with a wrong scheme returns a JSON error.\"\"\"\n        result = _parse(handle_resource(\"https://autobangumi.example.com/anime/list\"))\n        assert \"error\" in result\n"
  },
  {
    "path": "backend/src/test/test_mcp_security.py",
    "content": "\"\"\"Tests for module.mcp.security - McpAccessMiddleware, _is_allowed(), and clear_network_cache().\"\"\"\n\nfrom unittest.mock import patch\n\nimport pytest\nfrom starlette.applications import Starlette\nfrom starlette.responses import PlainTextResponse\nfrom starlette.routing import Route\nfrom starlette.testclient import TestClient\n\nfrom module.mcp.security import McpAccessMiddleware, _is_allowed, clear_network_cache\n\n\n# ---------------------------------------------------------------------------\n# _is_allowed() unit tests\n# ---------------------------------------------------------------------------\n\n\nclass TestIsAllowed:\n    \"\"\"Verify _is_allowed() checks IPs against a given whitelist.\"\"\"\n\n    def setup_method(self):\n        clear_network_cache()\n\n    LOCAL_WHITELIST = [\n        \"127.0.0.0/8\",\n        \"10.0.0.0/8\",\n        \"172.16.0.0/12\",\n        \"192.168.0.0/16\",\n        \"::1/128\",\n        \"fe80::/10\",\n        \"fc00::/7\",\n    ]\n\n    # --- allowed IPs ---\n\n    def test_ipv4_loopback_allowed(self):\n        assert _is_allowed(\"127.0.0.1\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv4_loopback_range(self):\n        assert _is_allowed(\"127.255.255.255\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv4_10_network(self):\n        assert _is_allowed(\"10.0.0.1\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv4_172_16_network(self):\n        assert _is_allowed(\"172.16.0.1\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv4_192_168_network(self):\n        assert _is_allowed(\"192.168.1.100\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv6_loopback(self):\n        assert _is_allowed(\"::1\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv6_link_local(self):\n        assert _is_allowed(\"fe80::1\", self.LOCAL_WHITELIST) is True\n\n    def test_ipv6_ula(self):\n        assert _is_allowed(\"fd00::1\", self.LOCAL_WHITELIST) is True\n\n    # --- denied IPs ---\n\n    def test_public_ipv4_denied(self):\n        assert _is_allowed(\"8.8.8.8\", self.LOCAL_WHITELIST) is False\n\n    def test_public_ipv6_denied(self):\n        assert _is_allowed(\"2001:4860:4860::8888\", self.LOCAL_WHITELIST) is False\n\n    def test_172_outside_range(self):\n        assert _is_allowed(\"172.32.0.0\", self.LOCAL_WHITELIST) is False\n\n    # --- empty whitelist ---\n\n    def test_empty_whitelist_denies_all(self):\n        assert _is_allowed(\"127.0.0.1\", []) is False\n\n    # --- invalid inputs ---\n\n    def test_invalid_hostname(self):\n        assert _is_allowed(\"localhost\", self.LOCAL_WHITELIST) is False\n\n    def test_empty_string(self):\n        assert _is_allowed(\"\", self.LOCAL_WHITELIST) is False\n\n    def test_malformed_ipv4(self):\n        assert _is_allowed(\"256.0.0.1\", self.LOCAL_WHITELIST) is False\n\n    # --- single IP whitelist ---\n\n    def test_single_ip_whitelist(self):\n        assert _is_allowed(\"203.0.113.5\", [\"203.0.113.5/32\"]) is True\n        assert _is_allowed(\"203.0.113.6\", [\"203.0.113.5/32\"]) is False\n\n\n# ---------------------------------------------------------------------------\n# McpAccessMiddleware integration tests\n# ---------------------------------------------------------------------------\n\n\ndef _make_mcp_settings(mcp_whitelist=None, mcp_tokens=None):\n    \"\"\"Create a mock settings.security object.\"\"\"\n\n    class MockSecurity:\n        def __init__(self):\n            self.mcp_whitelist = mcp_whitelist if mcp_whitelist is not None else []\n            self.mcp_tokens = mcp_tokens if mcp_tokens is not None else []\n\n    class MockSettings:\n        def __init__(self):\n            self.security = MockSecurity()\n\n    return MockSettings()\n\n\ndef _make_app() -> Starlette:\n    \"\"\"Build a minimal Starlette app with McpAccessMiddleware applied.\"\"\"\n\n    async def homepage(request):\n        return PlainTextResponse(\"ok\")\n\n    app = Starlette(routes=[Route(\"/\", homepage)])\n    app.add_middleware(McpAccessMiddleware)\n    return app\n\n\ndef _patch_client_ip(app, ip):\n    \"\"\"Return a modified app that overrides the client IP in ASGI scope.\"\"\"\n    original_build = app.build_middleware_stack\n\n    async def patched_app(scope, receive, send):\n        if scope[\"type\"] == \"http\":\n            scope[\"client\"] = (ip, 12345) if ip is not None else None\n        await original_build()(scope, receive, send)\n\n    app.build_middleware_stack = lambda: patched_app\n    return app\n\n\nclass TestMcpAccessMiddleware:\n    \"\"\"Verify McpAccessMiddleware allows/denies requests by IP and token.\"\"\"\n\n    def setup_method(self):\n        clear_network_cache()\n\n    def test_allowed_ip_passes(self):\n        mock_settings = _make_mcp_settings(mcp_whitelist=[\"127.0.0.0/8\"])\n        app = _patch_client_ip(_make_app(), \"127.0.0.1\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 200\n        assert response.text == \"ok\"\n\n    def test_denied_ip_blocked(self):\n        mock_settings = _make_mcp_settings(mcp_whitelist=[\"127.0.0.0/8\"])\n        app = _patch_client_ip(_make_app(), \"8.8.8.8\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 403\n        assert \"MCP access denied\" in response.text\n\n    def test_empty_whitelist_denies_all(self):\n        mock_settings = _make_mcp_settings(mcp_whitelist=[], mcp_tokens=[])\n        app = _patch_client_ip(_make_app(), \"127.0.0.1\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 403\n\n    def test_missing_client_blocked(self):\n        mock_settings = _make_mcp_settings(mcp_whitelist=[\"127.0.0.0/8\"])\n        app = _patch_client_ip(_make_app(), None)\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 403\n\n    def test_bearer_token_bypasses_ip(self):\n        mock_settings = _make_mcp_settings(\n            mcp_whitelist=[], mcp_tokens=[\"secret-token-123\"]\n        )\n        app = _patch_client_ip(_make_app(), \"8.8.8.8\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\n                \"/\", headers={\"Authorization\": \"Bearer secret-token-123\"}\n            )\n        assert response.status_code == 200\n\n    def test_invalid_bearer_token_denied(self):\n        mock_settings = _make_mcp_settings(\n            mcp_whitelist=[], mcp_tokens=[\"secret-token-123\"]\n        )\n        app = _patch_client_ip(_make_app(), \"8.8.8.8\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\n                \"/\", headers={\"Authorization\": \"Bearer wrong-token\"}\n            )\n        assert response.status_code == 403\n\n    def test_private_network_with_default_whitelist(self):\n        default_whitelist = [\n            \"127.0.0.0/8\",\n            \"10.0.0.0/8\",\n            \"172.16.0.0/12\",\n            \"192.168.0.0/16\",\n            \"::1/128\",\n            \"fe80::/10\",\n            \"fc00::/7\",\n        ]\n        mock_settings = _make_mcp_settings(mcp_whitelist=default_whitelist)\n        app = _patch_client_ip(_make_app(), \"192.168.1.100\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 200\n\n    def test_blocked_response_is_json(self):\n        import json\n\n        mock_settings = _make_mcp_settings(mcp_whitelist=[\"127.0.0.0/8\"])\n        app = _patch_client_ip(_make_app(), \"1.2.3.4\")\n        with patch(\"module.mcp.security.settings\", mock_settings):\n            client = TestClient(app, raise_server_exceptions=False)\n            response = client.get(\"/\")\n        assert response.status_code == 403\n        body = json.loads(response.text)\n        assert \"error\" in body\n"
  },
  {
    "path": "backend/src/test/test_mcp_tools.py",
    "content": "\"\"\"Tests for module.mcp.tools - _bangumi_to_dict helper and _dispatch routing.\"\"\"\n\nimport json\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom module.mcp.tools import (\n    TOOLS,\n    _bangumi_to_dict,\n    _dispatch,\n    handle_tool,\n)\nfrom module.models import Bangumi, ResponseModel\nfrom test.factories import make_bangumi\n\n# ---------------------------------------------------------------------------\n# Helpers\n# ---------------------------------------------------------------------------\n\n\ndef _make_response(status: bool = True, msg: str = \"OK\") -> ResponseModel:\n    return ResponseModel(status=status, status_code=200, msg_en=msg, msg_zh=msg)\n\n\ndef _mock_sync_manager(bangumi_list=None, single=None):\n    \"\"\"Return a MagicMock that acts as a sync context-manager TorrentManager.\"\"\"\n    mock_mgr = MagicMock()\n    if bangumi_list is not None:\n        mock_mgr.bangumi.search_all.return_value = bangumi_list\n        mock_mgr.search_all_bangumi.return_value = bangumi_list\n    if single is not None:\n        mock_mgr.search_one.return_value = single\n        mock_mgr.bangumi.search_id.return_value = single\n\n    ctx = MagicMock()\n    ctx.__enter__ = MagicMock(return_value=mock_mgr)\n    ctx.__exit__ = MagicMock(return_value=False)\n    return ctx, mock_mgr\n\n\n# ---------------------------------------------------------------------------\n# _bangumi_to_dict\n# ---------------------------------------------------------------------------\n\n\nclass TestBangumiToDict:\n    \"\"\"Verify _bangumi_to_dict converts a Bangumi model to the expected dict shape.\"\"\"\n\n    @pytest.fixture\n    def sample_bangumi(self) -> Bangumi:\n        return make_bangumi(\n            id=42,\n            official_title=\"Attack on Titan\",\n            title_raw=\"Shingeki no Kyojin\",\n            season=4,\n            group_name=\"SubsPlease\",\n            dpi=\"1080p\",\n            source=\"Web\",\n            subtitle=\"ENG\",\n            episode_offset=0,\n            season_offset=0,\n            filter=\"720\",\n            rss_link=\"https://mikanani.me/RSS/Bangumi/1\",\n            poster_link=\"/poster/aot.jpg\",\n            added=True,\n            save_path=\"/downloads/Attack on Titan\",\n            deleted=False,\n            archived=False,\n            eps_collect=False,\n        )\n\n    def test_returns_dict(self, sample_bangumi):\n        \"\"\"Result must be a plain dict.\"\"\"\n        result = _bangumi_to_dict(sample_bangumi)\n        assert isinstance(result, dict)\n\n    def test_id_field(self, sample_bangumi):\n        \"\"\"id is mapped correctly.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"id\"] == 42\n\n    def test_official_title_field(self, sample_bangumi):\n        \"\"\"official_title is mapped correctly.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"official_title\"] == \"Attack on Titan\"\n\n    def test_title_raw_field(self, sample_bangumi):\n        \"\"\"title_raw is mapped correctly.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"title_raw\"] == \"Shingeki no Kyojin\"\n\n    def test_season_field(self, sample_bangumi):\n        \"\"\"season is mapped correctly.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"season\"] == 4\n\n    def test_episode_offset_field(self, sample_bangumi):\n        \"\"\"episode_offset is present.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"episode_offset\"] == 0\n\n    def test_season_offset_field(self, sample_bangumi):\n        \"\"\"season_offset is present.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"season_offset\"] == 0\n\n    def test_rss_link_field(self, sample_bangumi):\n        \"\"\"rss_link is mapped correctly.\"\"\"\n        assert (\n            _bangumi_to_dict(sample_bangumi)[\"rss_link\"]\n            == \"https://mikanani.me/RSS/Bangumi/1\"\n        )\n\n    def test_deleted_field(self, sample_bangumi):\n        \"\"\"deleted flag is mapped.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"deleted\"] is False\n\n    def test_archived_field(self, sample_bangumi):\n        \"\"\"archived flag is mapped.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"archived\"] is False\n\n    def test_eps_collect_field(self, sample_bangumi):\n        \"\"\"eps_collect flag is mapped.\"\"\"\n        assert _bangumi_to_dict(sample_bangumi)[\"eps_collect\"] is False\n\n    def test_all_expected_keys_present(self, sample_bangumi):\n        \"\"\"Every expected key is present in the returned dict.\"\"\"\n        expected_keys = {\n            \"id\",\n            \"official_title\",\n            \"title_raw\",\n            \"season\",\n            \"group_name\",\n            \"dpi\",\n            \"source\",\n            \"subtitle\",\n            \"episode_offset\",\n            \"season_offset\",\n            \"filter\",\n            \"rss_link\",\n            \"poster_link\",\n            \"added\",\n            \"save_path\",\n            \"deleted\",\n            \"archived\",\n            \"eps_collect\",\n        }\n        result = _bangumi_to_dict(sample_bangumi)\n        assert expected_keys.issubset(result.keys())\n\n    def test_none_optional_fields(self):\n        \"\"\"Optional fields that are None are preserved as None.\"\"\"\n        b = make_bangumi(id=1, poster_link=None, save_path=None, group_name=None)\n        result = _bangumi_to_dict(b)\n        assert result[\"poster_link\"] is None\n        assert result[\"save_path\"] is None\n        assert result[\"group_name\"] is None\n\n\n# ---------------------------------------------------------------------------\n# TOOLS list\n# ---------------------------------------------------------------------------\n\n\nclass TestToolsDefinitions:\n    \"\"\"Sanity-check the static TOOLS list.\"\"\"\n\n    def test_tools_is_list(self):\n        assert isinstance(TOOLS, list)\n\n    def test_tools_not_empty(self):\n        assert len(TOOLS) > 0\n\n    def test_all_tools_have_names(self):\n        for tool in TOOLS:\n            assert tool.name, f\"Tool missing name: {tool}\"\n\n    def test_expected_tool_names_present(self):\n        names = {t.name for t in TOOLS}\n        required = {\n            \"list_anime\",\n            \"get_anime\",\n            \"search_anime\",\n            \"subscribe_anime\",\n            \"unsubscribe_anime\",\n            \"list_downloads\",\n            \"list_rss_feeds\",\n            \"get_program_status\",\n            \"refresh_feeds\",\n            \"update_anime\",\n        }\n        assert required.issubset(names)\n\n\n# ---------------------------------------------------------------------------\n# _dispatch routing\n# ---------------------------------------------------------------------------\n\n\nclass TestDispatch:\n    \"\"\"Verify _dispatch delegates to the correct handler for each tool name.\"\"\"\n\n    # --- list_anime ---\n\n    async def test_dispatch_list_anime_all(self):\n        \"\"\"list_anime without active_only returns all bangumi.\"\"\"\n        bangumi = [make_bangumi(id=1), make_bangumi(id=2, title_raw=\"Another\")]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"list_anime\", {})\n\n        assert isinstance(result, list)\n        assert len(result) == 2\n\n    async def test_dispatch_list_anime_active_only(self):\n        \"\"\"list_anime with active_only=True calls search_all_bangumi.\"\"\"\n        bangumi = [make_bangumi(id=1)]\n        ctx, mock_mgr = _mock_sync_manager(bangumi_list=bangumi)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"list_anime\", {\"active_only\": True})\n\n        mock_mgr.search_all_bangumi.assert_called_once()\n        assert len(result) == 1\n\n    # --- get_anime ---\n\n    async def test_dispatch_get_anime_found(self):\n        \"\"\"get_anime returns dict when bangumi exists.\"\"\"\n        bangumi = make_bangumi(id=5, official_title=\"Naruto\")\n        ctx, _ = _mock_sync_manager(single=bangumi)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"get_anime\", {\"id\": 5})\n\n        assert result[\"id\"] == 5\n        assert result[\"official_title\"] == \"Naruto\"\n\n    async def test_dispatch_get_anime_not_found(self):\n        \"\"\"get_anime returns error dict when lookup fails.\"\"\"\n        not_found = ResponseModel(\n            status=False,\n            status_code=404,\n            msg_en=\"Not found\",\n            msg_zh=\"未找到\",\n        )\n        ctx, _ = _mock_sync_manager(single=not_found)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"get_anime\", {\"id\": 999})\n\n        assert \"error\" in result\n        assert result[\"error\"] == \"Not found\"\n\n    # --- search_anime ---\n\n    async def test_dispatch_search_anime(self):\n        \"\"\"search_anime calls SearchTorrent.analyse_keyword and returns list.\"\"\"\n        fake_item = json.dumps(\n            {\"official_title\": \"One Piece\", \"rss_link\": \"https://mikan/rss/1\"}\n        )\n\n        async def fake_analyse_keyword(keywords, site):\n            yield fake_item\n\n        mock_st = AsyncMock()\n        mock_st.analyse_keyword = fake_analyse_keyword\n        mock_st.__aenter__ = AsyncMock(return_value=mock_st)\n        mock_st.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"module.mcp.tools.SearchTorrent\", return_value=mock_st):\n            result = await _dispatch(\n                \"search_anime\", {\"keywords\": \"One Piece\", \"site\": \"mikan\"}\n            )\n\n        assert isinstance(result, list)\n        assert len(result) == 1\n        assert result[0][\"official_title\"] == \"One Piece\"\n\n    async def test_dispatch_search_anime_default_site(self):\n        \"\"\"search_anime defaults to site='mikan' when site is omitted.\"\"\"\n        captured_site = []\n\n        async def fake_analyse_keyword(keywords, site):\n            captured_site.append(site)\n            return\n            yield  # make it an async generator\n\n        mock_st = AsyncMock()\n        mock_st.analyse_keyword = fake_analyse_keyword\n        mock_st.__aenter__ = AsyncMock(return_value=mock_st)\n        mock_st.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"module.mcp.tools.SearchTorrent\", return_value=mock_st):\n            await _dispatch(\"search_anime\", {\"keywords\": \"Bleach\"})\n\n        assert captured_site == [\"mikan\"]\n\n    # --- subscribe_anime ---\n\n    async def test_dispatch_subscribe_anime_success(self):\n        \"\"\"subscribe_anime returns status dict on success.\"\"\"\n        fake_bangumi = make_bangumi(id=10)\n        fake_resp = _make_response(True, \"Subscribed successfully\")\n\n        mock_analyser = AsyncMock()\n        mock_analyser.link_to_data = AsyncMock(return_value=fake_bangumi)\n\n        with (\n            patch(\"module.mcp.tools.RSSAnalyser\", return_value=mock_analyser),\n            patch(\n                \"module.mcp.tools.SeasonCollector.subscribe_season\",\n                new=AsyncMock(return_value=fake_resp),\n            ),\n        ):\n            result = await _dispatch(\n                \"subscribe_anime\",\n                {\"rss_link\": \"https://mikanani.me/RSS/test\", \"parser\": \"mikan\"},\n            )\n\n        assert result[\"status\"] is True\n        assert \"Subscribed\" in result[\"message\"]\n\n    async def test_dispatch_subscribe_anime_failure(self):\n        \"\"\"subscribe_anime returns error when analyser does not return Bangumi.\"\"\"\n        fake_error = ResponseModel(\n            status=False, status_code=500, msg_en=\"Parse failed\", msg_zh=\"解析失败\"\n        )\n\n        mock_analyser = AsyncMock()\n        mock_analyser.link_to_data = AsyncMock(return_value=fake_error)\n\n        with patch(\"module.mcp.tools.RSSAnalyser\", return_value=mock_analyser):\n            result = await _dispatch(\n                \"subscribe_anime\",\n                {\"rss_link\": \"https://bad-rss.example.com\", \"parser\": \"mikan\"},\n            )\n\n        assert \"error\" in result\n\n    # --- unsubscribe_anime ---\n\n    async def test_dispatch_unsubscribe_disable(self):\n        \"\"\"unsubscribe_anime with delete=False calls disable_rule.\"\"\"\n        ctx, mock_mgr = _mock_sync_manager()\n        mock_mgr.disable_rule = AsyncMock(return_value=_make_response(True, \"Disabled\"))\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"unsubscribe_anime\", {\"id\": 3, \"delete\": False})\n\n        mock_mgr.disable_rule.assert_called_once_with(3)\n        assert result[\"status\"] is True\n\n    async def test_dispatch_unsubscribe_delete(self):\n        \"\"\"unsubscribe_anime with delete=True calls delete_rule.\"\"\"\n        ctx, mock_mgr = _mock_sync_manager()\n        mock_mgr.delete_rule = AsyncMock(return_value=_make_response(True, \"Deleted\"))\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"unsubscribe_anime\", {\"id\": 3, \"delete\": True})\n\n        mock_mgr.delete_rule.assert_called_once_with(3)\n        assert result[\"status\"] is True\n\n    # --- list_downloads ---\n\n    async def test_dispatch_list_downloads_all(self):\n        \"\"\"list_downloads with status='all' returns full torrent list.\"\"\"\n        torrent_data = [\n            {\n                \"name\": \"Ep01.mkv\",\n                \"size\": 500,\n                \"progress\": 1.0,\n                \"state\": \"completed\",\n                \"dlspeed\": 0,\n                \"upspeed\": 0,\n                \"eta\": 0,\n            }\n        ]\n        mock_client = AsyncMock()\n        mock_client.get_torrent_info = AsyncMock(return_value=torrent_data)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"module.mcp.tools.DownloadClient\", return_value=mock_client):\n            result = await _dispatch(\"list_downloads\", {\"status\": \"all\"})\n\n        mock_client.get_torrent_info.assert_called_once_with(\n            status_filter=None, category=\"Bangumi\"\n        )\n        assert len(result) == 1\n        assert result[0][\"name\"] == \"Ep01.mkv\"\n\n    async def test_dispatch_list_downloads_filter_downloading(self):\n        \"\"\"list_downloads with status='downloading' passes filter to client.\"\"\"\n        mock_client = AsyncMock()\n        mock_client.get_torrent_info = AsyncMock(return_value=[])\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"module.mcp.tools.DownloadClient\", return_value=mock_client):\n            await _dispatch(\"list_downloads\", {\"status\": \"downloading\"})\n\n        mock_client.get_torrent_info.assert_called_once_with(\n            status_filter=\"downloading\", category=\"Bangumi\"\n        )\n\n    async def test_dispatch_list_downloads_keys(self):\n        \"\"\"Each torrent entry contains expected keys only.\"\"\"\n        torrent_data = [\n            {\n                \"name\": \"Ep02.mkv\",\n                \"size\": 800,\n                \"progress\": 0.5,\n                \"state\": \"downloading\",\n                \"dlspeed\": 1024,\n                \"upspeed\": 512,\n                \"eta\": 3600,\n                \"extra_key\": \"should_not_appear\",\n            }\n        ]\n        mock_client = AsyncMock()\n        mock_client.get_torrent_info = AsyncMock(return_value=torrent_data)\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        with patch(\"module.mcp.tools.DownloadClient\", return_value=mock_client):\n            result = await _dispatch(\"list_downloads\", {})\n\n        expected_keys = {\n            \"name\",\n            \"size\",\n            \"progress\",\n            \"state\",\n            \"dlspeed\",\n            \"upspeed\",\n            \"eta\",\n        }\n        assert set(result[0].keys()) == expected_keys\n\n    # --- list_rss_feeds ---\n\n    async def test_dispatch_list_rss_feeds(self):\n        \"\"\"list_rss_feeds returns serialised RSS feed list.\"\"\"\n        fake_feed = MagicMock()\n        fake_feed.id = 1\n        fake_feed.name = \"Mikan\"\n        fake_feed.url = \"https://mikanani.me/RSS/test\"\n        fake_feed.aggregate = True\n        fake_feed.parser = \"mikan\"\n        fake_feed.enabled = True\n        fake_feed.connection_status = \"ok\"\n        fake_feed.last_checked_at = \"2024-01-01T00:00:00\"\n        fake_feed.last_error = None\n\n        mock_engine = MagicMock()\n        mock_engine.rss.search_all.return_value = [fake_feed]\n        ctx = MagicMock()\n        ctx.__enter__ = MagicMock(return_value=mock_engine)\n        ctx.__exit__ = MagicMock(return_value=False)\n\n        with patch(\"module.mcp.tools.RSSEngine\", return_value=ctx):\n            result = await _dispatch(\"list_rss_feeds\", {})\n\n        assert isinstance(result, list)\n        assert result[0][\"name\"] == \"Mikan\"\n        assert result[0][\"url\"] == \"https://mikanani.me/RSS/test\"\n\n    # --- get_program_status ---\n\n    async def test_dispatch_get_program_status(self):\n        \"\"\"get_program_status returns version, running, and first_run.\"\"\"\n        mock_program = MagicMock()\n        mock_program.is_running = True\n        mock_program.first_run = False\n\n        with (\n            patch(\"module.mcp.tools.VERSION\", \"3.2.0-beta\"),\n            patch(\"module.mcp.tools._get_program_status\") as mock_fn,\n        ):\n            mock_fn.return_value = {\n                \"version\": \"3.2.0-beta\",\n                \"running\": True,\n                \"first_run\": False,\n            }\n            result = await _dispatch(\"get_program_status\", {})\n\n        assert \"version\" in result\n        assert \"running\" in result\n        assert \"first_run\" in result\n\n    # --- refresh_feeds ---\n\n    async def test_dispatch_refresh_feeds(self):\n        \"\"\"refresh_feeds triggers engine.refresh_rss and returns success dict.\"\"\"\n        mock_client = AsyncMock()\n        mock_client.__aenter__ = AsyncMock(return_value=mock_client)\n        mock_client.__aexit__ = AsyncMock(return_value=False)\n\n        mock_engine = MagicMock()\n        mock_engine.refresh_rss = AsyncMock(return_value=None)\n        engine_ctx = MagicMock()\n        engine_ctx.__enter__ = MagicMock(return_value=mock_engine)\n        engine_ctx.__exit__ = MagicMock(return_value=False)\n\n        with (\n            patch(\"module.mcp.tools.DownloadClient\", return_value=mock_client),\n            patch(\"module.mcp.tools.RSSEngine\", return_value=engine_ctx),\n        ):\n            result = await _dispatch(\"refresh_feeds\", {})\n\n        assert result[\"status\"] is True\n        mock_engine.refresh_rss.assert_called_once_with(mock_client)\n\n    # --- update_anime ---\n\n    async def test_dispatch_update_anime_success(self):\n        \"\"\"update_anime applies field overrides and calls update_rule.\"\"\"\n        existing = make_bangumi(id=7, episode_offset=0, season_offset=0, season=1)\n        resp = _make_response(True, \"Updated\")\n\n        ctx, mock_mgr = _mock_sync_manager(single=existing)\n        mock_mgr.bangumi.search_id.return_value = existing\n        mock_mgr.update_rule = AsyncMock(return_value=resp)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\n                \"update_anime\",\n                {\"id\": 7, \"episode_offset\": 12, \"season\": 2},\n            )\n\n        mock_mgr.update_rule.assert_called_once()\n        assert result[\"status\"] is True\n\n    async def test_dispatch_update_anime_not_found(self):\n        \"\"\"update_anime returns error dict when bangumi does not exist.\"\"\"\n        ctx, mock_mgr = _mock_sync_manager()\n        mock_mgr.bangumi.search_id.return_value = None\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await _dispatch(\"update_anime\", {\"id\": 9999})\n\n        assert \"error\" in result\n        assert \"9999\" in result[\"error\"]\n\n    # --- unknown tool ---\n\n    async def test_dispatch_unknown_tool(self):\n        \"\"\"An unrecognised tool name returns an error dict.\"\"\"\n        result = await _dispatch(\"does_not_exist\", {})\n        assert \"error\" in result\n        assert \"does_not_exist\" in result[\"error\"]\n\n\n# ---------------------------------------------------------------------------\n# handle_tool wrapper\n# ---------------------------------------------------------------------------\n\n\nclass TestHandleTool:\n    \"\"\"Verify handle_tool wraps results correctly and handles exceptions.\"\"\"\n\n    async def test_handle_tool_returns_text_content_list(self):\n        \"\"\"handle_tool always returns a list of TextContent objects.\"\"\"\n        from mcp import types\n\n        bangumi = [make_bangumi(id=1)]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await handle_tool(\"list_anime\", {})\n\n        assert isinstance(result, list)\n        assert all(isinstance(item, types.TextContent) for item in result)\n\n    async def test_handle_tool_result_is_valid_json(self):\n        \"\"\"The text in TextContent is valid JSON.\"\"\"\n        bangumi = [make_bangumi(id=1)]\n        ctx, _ = _mock_sync_manager(bangumi_list=bangumi)\n\n        with patch(\"module.mcp.tools.TorrentManager\", return_value=ctx):\n            result = await handle_tool(\"list_anime\", {})\n\n        parsed = json.loads(result[0].text)\n        assert isinstance(parsed, list)\n\n    async def test_handle_tool_exception_returns_error_json(self):\n        \"\"\"If the underlying handler raises, handle_tool returns a JSON error.\"\"\"\n        with patch(\n            \"module.mcp.tools._dispatch\",\n            new=AsyncMock(side_effect=RuntimeError(\"something broke\")),\n        ):\n            result = await handle_tool(\"list_anime\", {})\n\n        assert len(result) == 1\n        body = json.loads(result[0].text)\n        assert \"error\" in body\n        assert \"something broke\" in body[\"error\"]\n\n    async def test_handle_tool_unknown_name_returns_error_json(self):\n        \"\"\"An unknown tool name bubbles up as a JSON error via handle_tool.\"\"\"\n        result = await handle_tool(\"totally_unknown_tool\", {})\n        body = json.loads(result[0].text)\n        assert \"error\" in body\n"
  },
  {
    "path": "backend/src/test/test_migration.py",
    "content": "\"\"\"Tests for config and database migration from 3.1.x to 3.2.x.\"\"\"\nimport json\nimport tempfile\nfrom pathlib import Path\nfrom unittest.mock import patch\n\nimport pytest\nfrom sqlalchemy import inspect, text\nfrom sqlmodel import Session, SQLModel, create_engine\n\nfrom module.conf.config import Settings\nfrom module.database.combine import CURRENT_SCHEMA_VERSION, Database\nfrom module.models import Bangumi, RSSItem, Torrent, User\n\n# --- Mock old 3.1.x config (as stored in config.json) ---\nOLD_31X_CONFIG = {\n    \"program\": {\n        \"sleep_time\": 7200,\n        \"times\": 20,\n        \"webui_port\": 7892,\n        \"data_version\": 4.0,\n    },\n    \"downloader\": {\n        \"type\": \"qbittorrent\",\n        \"host\": \"192.168.1.100:8080\",\n        \"username\": \"admin\",\n        \"password\": \"mypassword\",\n        \"path\": \"/downloads/Bangumi\",\n        \"ssl\": False,\n    },\n    \"rss_parser\": {\n        \"enable\": True,\n        \"type\": \"mikan\",\n        \"custom_url\": \"mikanani.me\",\n        \"token\": \"abc123token\",\n        \"enable_tmdb\": True,\n        \"filter\": [\"720\", \"\\\\d+-\\\\d+\"],\n        \"language\": \"zh\",\n    },\n    \"bangumi_manage\": {\n        \"enable\": True,\n        \"eps_complete\": False,\n        \"rename_method\": \"pn\",\n        \"group_tag\": True,\n        \"remove_bad_torrent\": False,\n    },\n    \"log\": {\n        \"debug_enable\": True,\n    },\n    \"proxy\": {\n        \"enable\": True,\n        \"type\": \"http\",\n        \"host\": \"127.0.0.1\",\n        \"port\": 7890,\n        \"username\": \"\",\n        \"password\": \"\",\n    },\n    \"notification\": {\n        \"enable\": True,\n        \"type\": \"telegram\",\n        \"token\": \"bot123456:ABC-DEF\",\n        \"chat_id\": \"123456789\",\n    },\n}\n\n\nclass TestConfigMigration:\n    \"\"\"Test that old 3.1.x config files are properly migrated.\"\"\"\n\n    def test_migrate_old_config_renames_program_fields(self):\n        \"\"\"sleep_time -> rss_time, times -> rename_time.\"\"\"\n        result = Settings._migrate_old_config(json.loads(json.dumps(OLD_31X_CONFIG)))\n        assert \"rss_time\" in result[\"program\"]\n        assert result[\"program\"][\"rss_time\"] == 7200\n        assert \"rename_time\" in result[\"program\"]\n        assert result[\"program\"][\"rename_time\"] == 20\n        assert \"sleep_time\" not in result[\"program\"]\n        assert \"times\" not in result[\"program\"]\n\n    def test_migrate_old_config_removes_data_version(self):\n        \"\"\"data_version field should be removed.\"\"\"\n        result = Settings._migrate_old_config(json.loads(json.dumps(OLD_31X_CONFIG)))\n        assert \"data_version\" not in result[\"program\"]\n\n    def test_migrate_old_config_removes_deprecated_rss_fields(self):\n        \"\"\"type, custom_url, token, enable_tmdb should be removed from rss_parser.\"\"\"\n        result = Settings._migrate_old_config(json.loads(json.dumps(OLD_31X_CONFIG)))\n        assert \"type\" not in result[\"rss_parser\"]\n        assert \"custom_url\" not in result[\"rss_parser\"]\n        assert \"token\" not in result[\"rss_parser\"]\n        assert \"enable_tmdb\" not in result[\"rss_parser\"]\n\n    def test_migrate_old_config_preserves_valid_fields(self):\n        \"\"\"Valid fields like rss_parser.filter, downloader.host should be preserved.\"\"\"\n        result = Settings._migrate_old_config(json.loads(json.dumps(OLD_31X_CONFIG)))\n        assert result[\"rss_parser\"][\"enable\"] is True\n        assert result[\"rss_parser\"][\"filter\"] == [\"720\", \"\\\\d+-\\\\d+\"]\n        assert result[\"rss_parser\"][\"language\"] == \"zh\"\n        assert result[\"downloader\"][\"host\"] == \"192.168.1.100:8080\"\n        assert result[\"downloader\"][\"password\"] == \"mypassword\"\n        assert result[\"notification\"][\"token\"] == \"bot123456:ABC-DEF\"\n        assert result[\"bangumi_manage\"][\"group_tag\"] is True\n        assert result[\"log\"][\"debug_enable\"] is True\n        assert result[\"proxy\"][\"port\"] == 7890\n\n    def test_migrate_new_config_no_change(self):\n        \"\"\"A config already in 3.2 format should not be altered.\"\"\"\n        new_config = {\n            \"program\": {\n                \"rss_time\": 900,\n                \"rename_time\": 60,\n                \"webui_port\": 7892,\n            },\n            \"rss_parser\": {\n                \"enable\": True,\n                \"filter\": [\"720\"],\n                \"language\": \"zh\",\n            },\n        }\n        result = Settings._migrate_old_config(json.loads(json.dumps(new_config)))\n        assert result[\"program\"][\"rss_time\"] == 900\n        assert result[\"program\"][\"rename_time\"] == 60\n\n    def test_migrate_does_not_overwrite_new_fields_with_old(self):\n        \"\"\"If both old and new field names exist, keep the new one.\"\"\"\n        config = {\n            \"program\": {\n                \"sleep_time\": 7200,\n                \"rss_time\": 900,\n                \"times\": 20,\n                \"rename_time\": 60,\n                \"webui_port\": 7892,\n            },\n            \"rss_parser\": {\"enable\": True, \"filter\": [], \"language\": \"zh\"},\n        }\n        result = Settings._migrate_old_config(json.loads(json.dumps(config)))\n        assert result[\"program\"][\"rss_time\"] == 900\n        assert result[\"program\"][\"rename_time\"] == 60\n        assert \"sleep_time\" not in result[\"program\"]\n        assert \"times\" not in result[\"program\"]\n\n    def test_load_old_config_file(self):\n        \"\"\"Full integration: loading a 3.1.x config.json produces correct Settings.\"\"\"\n        with tempfile.NamedTemporaryFile(\n            mode=\"w\", suffix=\".json\", delete=False\n        ) as f:\n            json.dump(OLD_31X_CONFIG, f)\n            config_path = Path(f.name)\n\n        try:\n            with patch(\"module.conf.config.CONFIG_PATH\", config_path):\n                settings = Settings()\n                # Verify migrated fields\n                assert settings.program.rss_time == 7200\n                assert settings.program.rename_time == 20\n                assert settings.program.webui_port == 7892\n                # Verify preserved fields\n                assert settings.downloader.host_ == \"192.168.1.100:8080\"\n                assert settings.downloader.password_ == \"mypassword\"\n                assert settings.rss_parser.enable is True\n                assert settings.rss_parser.filter == [\"720\", \"\\\\d+-\\\\d+\"]\n                assert settings.notification.enable is True\n                assert settings.notification.token_ == \"bot123456:ABC-DEF\"\n                assert settings.bangumi_manage.group_tag is True\n                assert settings.log.debug_enable is True\n                assert settings.proxy.port == 7890\n                # Verify experimental_openai gets defaults\n                assert settings.experimental_openai.enable is False\n        finally:\n            config_path.unlink()\n\n    def test_load_old_config_saves_migrated_format(self):\n        \"\"\"After loading old config, the saved file should use new field names.\"\"\"\n        with tempfile.NamedTemporaryFile(\n            mode=\"w\", suffix=\".json\", delete=False\n        ) as f:\n            json.dump(OLD_31X_CONFIG, f)\n            config_path = Path(f.name)\n\n        try:\n            with patch(\"module.conf.config.CONFIG_PATH\", config_path):\n                Settings()\n                # Re-read saved config\n                with open(config_path) as f:\n                    saved = json.load(f)\n                assert \"rss_time\" in saved[\"program\"]\n                assert \"rename_time\" in saved[\"program\"]\n                assert \"sleep_time\" not in saved[\"program\"]\n                assert \"times\" not in saved[\"program\"]\n                assert \"data_version\" not in saved[\"program\"]\n                assert \"type\" not in saved[\"rss_parser\"]\n                assert \"custom_url\" not in saved[\"rss_parser\"]\n                assert \"token\" not in saved[\"rss_parser\"]\n                assert \"enable_tmdb\" not in saved[\"rss_parser\"]\n        finally:\n            config_path.unlink()\n\n\nclass TestDatabaseMigration:\n    \"\"\"Test that old 3.1.x databases are properly migrated to 3.2.x schema.\"\"\"\n\n    def _create_old_31x_database(self, engine):\n        \"\"\"Create a database matching the 3.1.x schema (no air_weekday column).\"\"\"\n        with engine.connect() as conn:\n            # Create bangumi table WITHOUT air_weekday (3.1.x schema)\n            conn.execute(text(\"\"\"\n                CREATE TABLE bangumi (\n                    id INTEGER PRIMARY KEY,\n                    official_title TEXT NOT NULL DEFAULT 'official_title',\n                    year TEXT,\n                    title_raw TEXT NOT NULL DEFAULT 'title_raw',\n                    season INTEGER NOT NULL DEFAULT 1,\n                    season_raw TEXT,\n                    group_name TEXT,\n                    dpi TEXT,\n                    source TEXT,\n                    subtitle TEXT,\n                    eps_collect BOOLEAN NOT NULL DEFAULT 0,\n                    \"offset\" INTEGER NOT NULL DEFAULT 0,\n                    filter TEXT NOT NULL DEFAULT '720,\\\\d+-\\\\d+',\n                    rss_link TEXT NOT NULL DEFAULT '',\n                    poster_link TEXT,\n                    added BOOLEAN NOT NULL DEFAULT 0,\n                    rule_name TEXT,\n                    save_path TEXT,\n                    deleted BOOLEAN NOT NULL DEFAULT 0\n                )\n            \"\"\"))\n            # Create user table\n            conn.execute(text(\"\"\"\n                CREATE TABLE user (\n                    id INTEGER PRIMARY KEY,\n                    username TEXT NOT NULL DEFAULT 'admin',\n                    password TEXT NOT NULL DEFAULT 'adminadmin'\n                )\n            \"\"\"))\n            # Create torrent table\n            conn.execute(text(\"\"\"\n                CREATE TABLE torrent (\n                    id INTEGER PRIMARY KEY,\n                    bangumi_id INTEGER REFERENCES bangumi(id),\n                    rss_id INTEGER REFERENCES rssitem(id),\n                    name TEXT NOT NULL DEFAULT '',\n                    url TEXT NOT NULL DEFAULT 'https://example.com/torrent',\n                    homepage TEXT,\n                    downloaded BOOLEAN NOT NULL DEFAULT 0\n                )\n            \"\"\"))\n            # Create rssitem table\n            conn.execute(text(\"\"\"\n                CREATE TABLE rssitem (\n                    id INTEGER PRIMARY KEY,\n                    name TEXT,\n                    url TEXT NOT NULL DEFAULT 'https://mikanani.me',\n                    aggregate BOOLEAN NOT NULL DEFAULT 0,\n                    parser TEXT NOT NULL DEFAULT 'mikan',\n                    enabled BOOLEAN NOT NULL DEFAULT 1\n                )\n            \"\"\"))\n            conn.commit()\n\n    def _insert_old_data(self, engine):\n        \"\"\"Insert sample 3.1.x data.\"\"\"\n        with engine.connect() as conn:\n            conn.execute(text(\"\"\"\n                INSERT INTO user (username, password) VALUES ('admin', 'adminadmin')\n            \"\"\"))\n            conn.execute(text(\"\"\"\n                INSERT INTO bangumi (\n                    official_title, year, title_raw, season, group_name,\n                    dpi, source, subtitle, eps_collect, \"offset\",\n                    filter, rss_link, poster_link, added, deleted\n                ) VALUES (\n                    '无职转生', '2021', 'Mushoku Tensei', 1, 'Lilith-Raws',\n                    '1080p', 'Baha', 'CHT', 0, 0,\n                    '720,\\\\d+-\\\\d+', 'https://mikanani.me/RSS/Bangumi?bangumiId=2353',\n                    'https://mikanani.me/images/Bangumi/202101/test.jpg', 1, 0\n                )\n            \"\"\"))\n            conn.execute(text(\"\"\"\n                INSERT INTO bangumi (\n                    official_title, year, title_raw, season, group_name,\n                    dpi, eps_collect, \"offset\", filter, rss_link, added, deleted\n                ) VALUES (\n                    '咒术回战', '2023', 'Jujutsu Kaisen', 2, 'ANi',\n                    '1080p', 0, 0, '720', 'https://mikanani.me/RSS/Bangumi?bangumiId=2888',\n                    1, 0\n                )\n            \"\"\"))\n            conn.execute(text(\"\"\"\n                INSERT INTO rssitem (name, url, aggregate, parser, enabled)\n                VALUES ('Mikan', 'https://mikanani.me/RSS/MyBangumi?token=abc', 1, 'mikan', 1)\n            \"\"\"))\n            conn.execute(text(\"\"\"\n                INSERT INTO torrent (bangumi_id, rss_id, name, url, downloaded)\n                VALUES (1, 1, '[Lilith-Raws] Mushoku Tensei - 01.mkv',\n                        'https://example.com/torrent1', 1)\n            \"\"\"))\n            conn.commit()\n\n    def test_migrate_adds_air_weekday_column(self):\n        \"\"\"Migration should add air_weekday column to bangumi table.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        # Verify air_weekday does NOT exist before migration\n        inspector = inspect(engine)\n        columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n        assert \"air_weekday\" not in columns\n\n        # Run migration\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        # Verify air_weekday now exists\n        inspector = inspect(engine)\n        columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n        assert \"air_weekday\" in columns\n\n        db.close()\n\n    def test_migrate_preserves_existing_data(self):\n        \"\"\"Migration should not lose existing bangumi data.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        # Run migration\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        # Check data is preserved\n        bangumis = db.bangumi.search_all()\n        assert len(bangumis) == 2\n        assert bangumis[0].official_title == \"无职转生\"\n        assert bangumis[0].year == \"2021\"\n        assert bangumis[0].season == 1\n        assert bangumis[0].group_name == \"Lilith-Raws\"\n        assert bangumis[0].added is True\n        assert bangumis[0].air_weekday is None  # New column, should be NULL\n\n        assert bangumis[1].official_title == \"咒术回战\"\n        assert bangumis[1].season == 2\n\n        db.close()\n\n    def test_migrate_preserves_user_data(self):\n        \"\"\"User table should be intact after migration.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        users = db.user.get_user(\"admin\")\n        assert users is not None\n        assert users.username == \"admin\"\n\n        db.close()\n\n    def test_migrate_preserves_rss_data(self):\n        \"\"\"RSS items should be preserved after migration.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        rss = db.rss.search_id(1)\n        assert rss is not None\n        assert rss.url == \"https://mikanani.me/RSS/MyBangumi?token=abc\"\n        assert rss.aggregate is True\n\n        db.close()\n\n    def test_migrate_preserves_torrent_data(self):\n        \"\"\"Torrent data should be preserved after migration.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        torrent = db.torrent.search(1)\n        assert torrent is not None\n        assert \"[Lilith-Raws]\" in torrent.name\n        assert torrent.downloaded is True\n\n        db.close()\n\n    def test_migrate_idempotent(self):\n        \"\"\"Running migration multiple times should not cause errors.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        # Run migration twice\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n        db.run_migrations()  # Should not fail\n\n        bangumis = db.bangumi.search_all()\n        assert len(bangumis) == 2\n\n        db.close()\n\n    def test_new_bangumi_with_air_weekday(self):\n        \"\"\"After migration, new bangumi can be added with air_weekday.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        new_bangumi = Bangumi(\n            official_title=\"葬送的芙莉莲\",\n            year=\"2023\",\n            title_raw=\"Sousou no Frieren\",\n            season=1,\n            group_name=\"SubsPlease\",\n            dpi=\"1080p\",\n            rss_link=\"https://mikanani.me/RSS/test\",\n            added=True,\n            air_weekday=5,  # Friday\n        )\n        db.bangumi.add(new_bangumi)\n        db.commit()\n\n        result = db.bangumi.search_id(3)\n        assert result is not None\n        assert result.official_title == \"葬送的芙莉莲\"\n        assert result.air_weekday == 5\n\n        db.close()\n\n    def test_passkey_table_created(self):\n        \"\"\"Migration should create the new passkey table.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        inspector = inspect(engine)\n        tables = inspector.get_table_names()\n        assert \"passkey\" in tables\n\n        db.close()\n\n    def test_schema_version_tracked(self):\n        \"\"\"After migration, schema_version table should store current version.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        # Verify schema_version table exists and has correct version\n        inspector = inspect(engine)\n        assert \"schema_version\" in inspector.get_table_names()\n        assert db._get_schema_version() == CURRENT_SCHEMA_VERSION\n\n        db.close()\n\n    def test_schema_version_skips_applied_migrations(self):\n        \"\"\"If schema version is current, run_migrations should be a no-op.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        db.create_table()\n        db.run_migrations()\n\n        # Set version to current - second run should skip\n        version_before = db._get_schema_version()\n        db.run_migrations()\n        version_after = db._get_schema_version()\n        assert version_before == version_after == CURRENT_SCHEMA_VERSION\n\n        db.close()\n\n    def test_schema_version_zero_for_old_db(self):\n        \"\"\"Old database without schema_version table should report version 0.\"\"\"\n        engine = create_engine(\"sqlite://\", echo=False)\n        self._create_old_31x_database(engine)\n        self._insert_old_data(engine)\n\n        db = Database(engine)\n        assert db._get_schema_version() == 0\n\n        db.close()\n"
  },
  {
    "path": "backend/src/test/test_mock_downloader.py",
    "content": "\"\"\"Tests for MockDownloader - state management and API contract.\"\"\"\n\nimport pytest\n\nfrom module.downloader.client.mock_downloader import MockDownloader\n\n\n@pytest.fixture\ndef mock_dl() -> MockDownloader:\n    \"\"\"Fresh MockDownloader for each test.\"\"\"\n    return MockDownloader()\n\n\n# ---------------------------------------------------------------------------\n# Initialization\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderInit:\n    def test_initial_state_is_empty(self, mock_dl):\n        \"\"\"MockDownloader starts with no torrents, rules, or feeds.\"\"\"\n        state = mock_dl.get_state()\n        assert state[\"torrents\"] == {}\n        assert state[\"rules\"] == {}\n        assert state[\"feeds\"] == {}\n\n    def test_initial_categories(self, mock_dl):\n        \"\"\"Default categories include Bangumi and BangumiCollection.\"\"\"\n        state = mock_dl.get_state()\n        assert \"Bangumi\" in state[\"categories\"]\n        assert \"BangumiCollection\" in state[\"categories\"]\n\n    def test_initial_prefs(self, mock_dl):\n        \"\"\"Default prefs are populated.\"\"\"\n        # Access private attribute directly to confirm defaults\n        assert mock_dl._prefs[\"rss_auto_downloading_enabled\"] is True\n        assert mock_dl._prefs[\"rss_max_articles_per_feed\"] == 500\n\n\n# ---------------------------------------------------------------------------\n# Auth / connection\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderAuth:\n    async def test_auth_returns_true(self, mock_dl):\n        result = await mock_dl.auth()\n        assert result is True\n\n    async def test_auth_retry_param_accepted(self, mock_dl):\n        result = await mock_dl.auth(retry=5)\n        assert result is True\n\n    async def test_logout_does_not_raise(self, mock_dl):\n        await mock_dl.logout()\n\n    async def test_check_host_returns_true(self, mock_dl):\n        result = await mock_dl.check_host()\n        assert result is True\n\n    async def test_check_connection_returns_version_string(self, mock_dl):\n        result = await mock_dl.check_connection()\n        assert \"mock\" in result.lower()\n\n\n# ---------------------------------------------------------------------------\n# Prefs\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderPrefs:\n    async def test_prefs_init_updates_prefs(self, mock_dl):\n        \"\"\"prefs_init merges given prefs into the internal store.\"\"\"\n        await mock_dl.prefs_init({\"rss_refresh_interval\": 60, \"custom_key\": \"val\"})\n        assert mock_dl._prefs[\"rss_refresh_interval\"] == 60\n        assert mock_dl._prefs[\"custom_key\"] == \"val\"\n\n    async def test_get_app_prefs_returns_dict(self, mock_dl):\n        result = await mock_dl.get_app_prefs()\n        assert isinstance(result, dict)\n        assert \"save_path\" in result\n\n\n# ---------------------------------------------------------------------------\n# Categories\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderCategories:\n    async def test_add_category_persists(self, mock_dl):\n        await mock_dl.add_category(\"NewCategory\")\n        state = mock_dl.get_state()\n        assert \"NewCategory\" in state[\"categories\"]\n\n    async def test_add_duplicate_category_no_error(self, mock_dl):\n        await mock_dl.add_category(\"Bangumi\")\n        state = mock_dl.get_state()\n        # Still only one entry for Bangumi (set semantics)\n        assert state[\"categories\"].count(\"Bangumi\") == 1\n\n\n# ---------------------------------------------------------------------------\n# Torrent management\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderAddTorrents:\n    async def test_add_torrent_url_returns_true(self, mock_dl):\n        result = await mock_dl.add_torrents(\n            torrent_urls=\"magnet:?xt=urn:btih:abc\",\n            torrent_files=None,\n            save_path=\"/downloads/Bangumi\",\n            category=\"Bangumi\",\n        )\n        assert result is True\n\n    async def test_add_torrent_stores_in_state(self, mock_dl):\n        await mock_dl.add_torrents(\n            torrent_urls=\"magnet:?xt=urn:btih:abc\",\n            torrent_files=None,\n            save_path=\"/downloads/Bangumi\",\n            category=\"Bangumi\",\n        )\n        state = mock_dl.get_state()\n        assert len(state[\"torrents\"]) == 1\n\n    async def test_add_torrent_with_tag_stored(self, mock_dl):\n        await mock_dl.add_torrents(\n            torrent_urls=\"magnet:?xt=urn:btih:abc\",\n            torrent_files=None,\n            save_path=\"/downloads/Bangumi\",\n            category=\"Bangumi\",\n            tags=\"ab:42\",\n        )\n        state = mock_dl.get_state()\n        torrent = list(state[\"torrents\"].values())[0]\n        assert torrent[\"tags\"] == \"ab:42\"\n\n    async def test_add_torrent_with_file_bytes(self, mock_dl):\n        result = await mock_dl.add_torrents(\n            torrent_urls=None,\n            torrent_files=b\"\\x00\\x01\\x02\",\n            save_path=\"/downloads\",\n            category=\"Bangumi\",\n        )\n        assert result is True\n\n    async def test_two_different_torrents_stored_separately(self, mock_dl):\n        await mock_dl.add_torrents(\n            torrent_urls=\"magnet:?xt=urn:btih:aaa\",\n            torrent_files=None,\n            save_path=\"/dl\",\n            category=\"Bangumi\",\n        )\n        await mock_dl.add_torrents(\n            torrent_urls=\"magnet:?xt=urn:btih:bbb\",\n            torrent_files=None,\n            save_path=\"/dl\",\n            category=\"Bangumi\",\n        )\n        state = mock_dl.get_state()\n        assert len(state[\"torrents\"]) == 2\n\n\nclass TestMockDownloaderTorrentsInfo:\n    async def test_returns_all_when_no_filter(self, mock_dl):\n        mock_dl.add_mock_torrent(\"Anime A\", category=\"Bangumi\")\n        mock_dl.add_mock_torrent(\"Anime B\", category=\"Bangumi\")\n        result = await mock_dl.torrents_info(status_filter=None, category=None)\n        assert len(result) == 2\n\n    async def test_filters_by_category(self, mock_dl):\n        mock_dl.add_mock_torrent(\"Anime A\", category=\"Bangumi\")\n        mock_dl.add_mock_torrent(\"Movie\", category=\"BangumiCollection\")\n        result = await mock_dl.torrents_info(status_filter=None, category=\"Bangumi\")\n        assert len(result) == 1\n        assert result[0][\"name\"] == \"Anime A\"\n\n    async def test_filters_by_tag(self, mock_dl):\n        h1 = mock_dl.add_mock_torrent(\"Anime A\", category=\"Bangumi\")\n        mock_dl.add_mock_torrent(\"Anime B\", category=\"Bangumi\")\n        # Manually set the tag on first torrent\n        mock_dl._torrents[h1][\"tags\"] = [\"ab:1\"]\n        result = await mock_dl.torrents_info(\n            status_filter=None, category=None, tag=\"ab:1\"\n        )\n        assert len(result) == 1\n        assert result[0][\"name\"] == \"Anime A\"\n\n    async def test_empty_store_returns_empty_list(self, mock_dl):\n        result = await mock_dl.torrents_info(status_filter=None, category=\"Bangumi\")\n        assert result == []\n\n\nclass TestMockDownloaderTorrentsFiles:\n    async def test_returns_files_for_known_hash(self, mock_dl):\n        files = [{\"name\": \"ep01.mkv\", \"size\": 500_000_000}]\n        h = mock_dl.add_mock_torrent(\"Anime\", files=files)\n        result = await mock_dl.torrents_files(torrent_hash=h)\n        assert len(result) == 1\n        assert result[0][\"name\"] == \"ep01.mkv\"\n\n    async def test_returns_empty_list_for_unknown_hash(self, mock_dl):\n        result = await mock_dl.torrents_files(torrent_hash=\"nonexistent\")\n        assert result == []\n\n\nclass TestMockDownloaderDelete:\n    async def test_delete_single_torrent(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        await mock_dl.torrents_delete(hash=h)\n        assert h not in mock_dl._torrents\n\n    async def test_delete_multiple_torrents_pipe_separated(self, mock_dl):\n        h1 = mock_dl.add_mock_torrent(\"Anime A\")\n        h2 = mock_dl.add_mock_torrent(\"Anime B\")\n        await mock_dl.torrents_delete(hash=f\"{h1}|{h2}\")\n        assert h1 not in mock_dl._torrents\n        assert h2 not in mock_dl._torrents\n\n    async def test_delete_nonexistent_hash_no_error(self, mock_dl):\n        await mock_dl.torrents_delete(hash=\"deadbeef\")\n\n\nclass TestMockDownloaderPauseResume:\n    async def test_pause_sets_state(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", state=\"downloading\")\n        await mock_dl.torrents_pause(hashes=h)\n        assert mock_dl._torrents[h][\"state\"] == \"paused\"\n\n    async def test_resume_sets_state(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", state=\"paused\")\n        await mock_dl.torrents_resume(hashes=h)\n        assert mock_dl._torrents[h][\"state\"] == \"downloading\"\n\n    async def test_pause_multiple_pipe_separated(self, mock_dl):\n        h1 = mock_dl.add_mock_torrent(\"Anime A\", state=\"downloading\")\n        h2 = mock_dl.add_mock_torrent(\"Anime B\", state=\"downloading\")\n        await mock_dl.torrents_pause(hashes=f\"{h1}|{h2}\")\n        assert mock_dl._torrents[h1][\"state\"] == \"paused\"\n        assert mock_dl._torrents[h2][\"state\"] == \"paused\"\n\n    async def test_pause_unknown_hash_no_error(self, mock_dl):\n        await mock_dl.torrents_pause(hashes=\"deadbeef\")\n\n\n# ---------------------------------------------------------------------------\n# Rename\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderRename:\n    async def test_rename_returns_true(self, mock_dl):\n        result = await mock_dl.torrents_rename_file(\n            torrent_hash=\"hash1\",\n            old_path=\"old.mkv\",\n            new_path=\"new.mkv\",\n        )\n        assert result is True\n\n    async def test_rename_with_verify_flag(self, mock_dl):\n        result = await mock_dl.torrents_rename_file(\n            torrent_hash=\"hash1\",\n            old_path=\"old.mkv\",\n            new_path=\"new.mkv\",\n            verify=False,\n        )\n        assert result is True\n\n\n# ---------------------------------------------------------------------------\n# RSS feed management\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderRssFeeds:\n    async def test_add_feed_stored(self, mock_dl):\n        await mock_dl.rss_add_feed(url=\"https://mikan.me/RSS/test\", item_path=\"Mikan\")\n        feeds = await mock_dl.rss_get_feeds()\n        assert \"Mikan\" in feeds\n        assert feeds[\"Mikan\"][\"url\"] == \"https://mikan.me/RSS/test\"\n\n    async def test_remove_feed(self, mock_dl):\n        await mock_dl.rss_add_feed(url=\"https://example.com\", item_path=\"Feed1\")\n        await mock_dl.rss_remove_item(item_path=\"Feed1\")\n        feeds = await mock_dl.rss_get_feeds()\n        assert \"Feed1\" not in feeds\n\n    async def test_remove_nonexistent_feed_no_error(self, mock_dl):\n        await mock_dl.rss_remove_item(item_path=\"nonexistent\")\n\n    async def test_get_feeds_initially_empty(self, mock_dl):\n        feeds = await mock_dl.rss_get_feeds()\n        assert feeds == {}\n\n\n# ---------------------------------------------------------------------------\n# Rules\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderRules:\n    async def test_set_rule_stored(self, mock_dl):\n        rule_def = {\"enable\": True, \"mustContain\": \"Anime\"}\n        await mock_dl.rss_set_rule(\"rule1\", rule_def)\n        rules = await mock_dl.get_download_rule()\n        assert \"rule1\" in rules\n        assert rules[\"rule1\"][\"mustContain\"] == \"Anime\"\n\n    async def test_remove_rule(self, mock_dl):\n        await mock_dl.rss_set_rule(\"rule1\", {\"enable\": True})\n        await mock_dl.remove_rule(\"rule1\")\n        rules = await mock_dl.get_download_rule()\n        assert \"rule1\" not in rules\n\n    async def test_remove_nonexistent_rule_no_error(self, mock_dl):\n        await mock_dl.remove_rule(\"nonexistent\")\n\n    async def test_get_download_rule_initially_empty(self, mock_dl):\n        rules = await mock_dl.get_download_rule()\n        assert rules == {}\n\n\n# ---------------------------------------------------------------------------\n# Move / path\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderMovePath:\n    async def test_move_torrent_updates_save_path(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", save_path=\"/old/path\")\n        await mock_dl.move_torrent(hashes=h, new_location=\"/new/path\")\n        assert mock_dl._torrents[h][\"save_path\"] == \"/new/path\"\n\n    async def test_move_multiple_pipe_separated(self, mock_dl):\n        h1 = mock_dl.add_mock_torrent(\"Anime A\", save_path=\"/old\")\n        h2 = mock_dl.add_mock_torrent(\"Anime B\", save_path=\"/old\")\n        await mock_dl.move_torrent(hashes=f\"{h1}|{h2}\", new_location=\"/new\")\n        assert mock_dl._torrents[h1][\"save_path\"] == \"/new\"\n        assert mock_dl._torrents[h2][\"save_path\"] == \"/new\"\n\n    async def test_get_torrent_path_known_hash(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", save_path=\"/downloads/Bangumi\")\n        path = await mock_dl.get_torrent_path(h)\n        assert path == \"/downloads/Bangumi\"\n\n    async def test_get_torrent_path_unknown_hash_returns_default(self, mock_dl):\n        path = await mock_dl.get_torrent_path(\"nonexistent\")\n        assert path == \"/tmp/mock-downloads\"\n\n\n# ---------------------------------------------------------------------------\n# Category assignment\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderSetCategory:\n    async def test_set_category_updates_torrent(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", category=\"Bangumi\")\n        await mock_dl.set_category(h, \"BangumiCollection\")\n        assert mock_dl._torrents[h][\"category\"] == \"BangumiCollection\"\n\n    async def test_set_category_unknown_hash_no_error(self, mock_dl):\n        await mock_dl.set_category(\"deadbeef\", \"Bangumi\")\n\n\n# ---------------------------------------------------------------------------\n# Tags\n# ---------------------------------------------------------------------------\n\n\nclass TestMockDownloaderTags:\n    async def test_add_tag_appends(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        await mock_dl.add_tag(h, \"ab:1\")\n        assert \"ab:1\" in mock_dl._torrents[h][\"tags\"]\n\n    async def test_add_tag_no_duplicates(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        await mock_dl.add_tag(h, \"ab:1\")\n        await mock_dl.add_tag(h, \"ab:1\")\n        assert mock_dl._torrents[h][\"tags\"].count(\"ab:1\") == 1\n\n    async def test_add_tag_unknown_hash_no_error(self, mock_dl):\n        await mock_dl.add_tag(\"deadbeef\", \"ab:1\")\n\n    async def test_multiple_tags_on_same_torrent(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        await mock_dl.add_tag(h, \"ab:1\")\n        await mock_dl.add_tag(h, \"group:sub\")\n        assert \"ab:1\" in mock_dl._torrents[h][\"tags\"]\n        assert \"group:sub\" in mock_dl._torrents[h][\"tags\"]\n\n\n# ---------------------------------------------------------------------------\n# add_mock_torrent helper\n# ---------------------------------------------------------------------------\n\n\nclass TestAddMockTorrentHelper:\n    def test_generates_hash_from_name(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        assert h is not None\n        assert len(h) == 40  # SHA1 hex digest\n\n    def test_explicit_hash_used(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", hash=\"cafebabe\" + \"0\" * 32)\n        assert h == \"cafebabe\" + \"0\" * 32\n\n    def test_torrent_state_is_completed_by_default(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\")\n        assert mock_dl._torrents[h][\"state\"] == \"completed\"\n        assert mock_dl._torrents[h][\"progress\"] == 1.0\n\n    def test_torrent_state_custom(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"Anime\", state=\"downloading\")\n        assert mock_dl._torrents[h][\"state\"] == \"downloading\"\n        assert mock_dl._torrents[h][\"progress\"] == 0.5\n\n    def test_default_file_is_mkv(self, mock_dl):\n        h = mock_dl.add_mock_torrent(\"My Anime\")\n        files = mock_dl._torrents[h][\"files\"]\n        assert len(files) == 1\n        assert files[0][\"name\"].endswith(\".mkv\")\n\n    def test_custom_files_stored(self, mock_dl):\n        custom_files = [{\"name\": \"ep01.mkv\"}, {\"name\": \"ep02.mkv\"}]\n        h = mock_dl.add_mock_torrent(\"Anime\", files=custom_files)\n        assert len(mock_dl._torrents[h][\"files\"]) == 2\n"
  },
  {
    "path": "backend/src/test/test_notification.py",
    "content": "\"\"\"Tests for notification: provider registry, manager, and provider implementations.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, patch, MagicMock\n\nfrom module.models import Notification\nfrom module.models.config import NotificationProvider as ProviderConfig\nfrom module.notification import PROVIDER_REGISTRY, NotificationManager\nfrom module.notification.providers import (\n    TelegramProvider,\n    DiscordProvider,\n    BarkProvider,\n    ServerChanProvider,\n    WecomProvider,\n    GotifyProvider,\n    PushoverProvider,\n    WebhookProvider,\n)\n\n\n# ---------------------------------------------------------------------------\n# Provider Registry\n# ---------------------------------------------------------------------------\n\n\nclass TestProviderRegistry:\n    def test_telegram(self):\n        \"\"\"Registry contains TelegramProvider for 'telegram' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"telegram\"] is TelegramProvider\n\n    def test_discord(self):\n        \"\"\"Registry contains DiscordProvider for 'discord' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"discord\"] is DiscordProvider\n\n    def test_bark(self):\n        \"\"\"Registry contains BarkProvider for 'bark' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"bark\"] is BarkProvider\n\n    def test_server_chan(self):\n        \"\"\"Registry contains ServerChanProvider for 'server-chan' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"server-chan\"] is ServerChanProvider\n        assert PROVIDER_REGISTRY[\"serverchan\"] is ServerChanProvider\n\n    def test_wecom(self):\n        \"\"\"Registry contains WecomProvider for 'wecom' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"wecom\"] is WecomProvider\n\n    def test_gotify(self):\n        \"\"\"Registry contains GotifyProvider for 'gotify' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"gotify\"] is GotifyProvider\n\n    def test_pushover(self):\n        \"\"\"Registry contains PushoverProvider for 'pushover' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"pushover\"] is PushoverProvider\n\n    def test_webhook(self):\n        \"\"\"Registry contains WebhookProvider for 'webhook' type.\"\"\"\n        assert PROVIDER_REGISTRY[\"webhook\"] is WebhookProvider\n\n    def test_unknown_type(self):\n        \"\"\"Returns None for unknown notification type.\"\"\"\n        result = PROVIDER_REGISTRY.get(\"unknown_service\")\n        assert result is None\n\n\n# ---------------------------------------------------------------------------\n# NotificationManager\n# ---------------------------------------------------------------------------\n\n\nclass TestNotificationManager:\n    @pytest.fixture\n    def mock_settings(self):\n        \"\"\"Mock settings with notification providers.\"\"\"\n        with patch(\"module.notification.manager.settings\") as mock:\n            mock.notification.providers = []\n            yield mock\n\n    def test_empty_providers(self, mock_settings):\n        \"\"\"Manager handles empty provider list.\"\"\"\n        manager = NotificationManager()\n        assert len(manager) == 0\n\n    def test_load_single_provider(self, mock_settings):\n        \"\"\"Manager loads a single enabled provider.\"\"\"\n        config = ProviderConfig(type=\"telegram\", enabled=True, token=\"test\", chat_id=\"123\")\n        mock_settings.notification.providers = [config]\n\n        manager = NotificationManager()\n        assert len(manager) == 1\n        assert isinstance(manager.providers[0], TelegramProvider)\n\n    def test_skip_disabled_provider(self, mock_settings):\n        \"\"\"Manager skips disabled providers.\"\"\"\n        config = ProviderConfig(type=\"telegram\", enabled=False, token=\"test\", chat_id=\"123\")\n        mock_settings.notification.providers = [config]\n\n        manager = NotificationManager()\n        assert len(manager) == 0\n\n    def test_load_multiple_providers(self, mock_settings):\n        \"\"\"Manager loads multiple enabled providers.\"\"\"\n        configs = [\n            ProviderConfig(type=\"telegram\", enabled=True, token=\"test\", chat_id=\"123\"),\n            ProviderConfig(type=\"discord\", enabled=True, webhook_url=\"https://discord.com/webhook\"),\n            ProviderConfig(type=\"bark\", enabled=True, device_key=\"device123\"),\n        ]\n        mock_settings.notification.providers = configs\n\n        manager = NotificationManager()\n        assert len(manager) == 3\n        assert isinstance(manager.providers[0], TelegramProvider)\n        assert isinstance(manager.providers[1], DiscordProvider)\n        assert isinstance(manager.providers[2], BarkProvider)\n\n    def test_skip_unknown_provider(self, mock_settings):\n        \"\"\"Manager skips unknown provider types.\"\"\"\n        configs = [\n            ProviderConfig(type=\"telegram\", enabled=True, token=\"test\", chat_id=\"123\"),\n            ProviderConfig(type=\"unknown_service\", enabled=True),\n        ]\n        mock_settings.notification.providers = configs\n\n        manager = NotificationManager()\n        assert len(manager) == 1\n\n    async def test_send_all(self, mock_settings):\n        \"\"\"Manager sends to all providers.\"\"\"\n        configs = [\n            ProviderConfig(type=\"telegram\", enabled=True, token=\"test\", chat_id=\"123\"),\n            ProviderConfig(type=\"discord\", enabled=True, webhook_url=\"https://discord.com/webhook\"),\n        ]\n        mock_settings.notification.providers = configs\n\n        manager = NotificationManager()\n\n        # Mock the providers\n        for provider in manager.providers:\n            provider.send = AsyncMock(return_value=True)\n            provider.__aenter__ = AsyncMock(return_value=provider)\n            provider.__aexit__ = AsyncMock(return_value=None)\n\n        notify = Notification(official_title=\"Test Anime\", season=1, episode=5)\n\n        with patch.object(manager, \"_get_poster\", new_callable=AsyncMock):\n            await manager.send_all(notify)\n\n        for provider in manager.providers:\n            provider.send.assert_called_once_with(notify)\n\n    async def test_test_provider(self, mock_settings):\n        \"\"\"Manager can test a specific provider.\"\"\"\n        config = ProviderConfig(type=\"telegram\", enabled=True, token=\"test\", chat_id=\"123\")\n        mock_settings.notification.providers = [config]\n\n        manager = NotificationManager()\n\n        # Mock the provider's test method\n        manager.providers[0].test = AsyncMock(return_value=(True, \"Test successful\"))\n        manager.providers[0].__aenter__ = AsyncMock(return_value=manager.providers[0])\n        manager.providers[0].__aexit__ = AsyncMock(return_value=None)\n\n        success, message = await manager.test_provider(0)\n        assert success is True\n        assert message == \"Test successful\"\n\n    async def test_test_provider_invalid_index(self, mock_settings):\n        \"\"\"Manager handles invalid provider index.\"\"\"\n        mock_settings.notification.providers = []\n        manager = NotificationManager()\n\n        success, message = await manager.test_provider(5)\n        assert success is False\n        assert \"Invalid provider index\" in message\n\n\n# ---------------------------------------------------------------------------\n# Provider Implementations\n# ---------------------------------------------------------------------------\n\n\nclass TestTelegramProvider:\n    @pytest.fixture\n    def provider(self):\n        config = ProviderConfig(type=\"telegram\", enabled=True, token=\"test_token\", chat_id=\"12345\")\n        return TelegramProvider(config)\n\n    async def test_send_with_photo(self, provider):\n        \"\"\"Sends photo when poster available.\"\"\"\n        notify = Notification(\n            official_title=\"Test Anime\", season=1, episode=5, poster_path=\"/path/to/poster.jpg\"\n        )\n\n        with patch.object(provider, \"post_files\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=200)\n            with patch(\"module.notification.providers.telegram.load_image\") as mock_load:\n                mock_load.return_value = b\"image_data\"\n                result = await provider.send(notify)\n\n        assert result is True\n        mock_post.assert_called_once()\n\n    async def test_send_without_photo(self, provider):\n        \"\"\"Sends text when no poster available.\"\"\"\n        notify = Notification(official_title=\"Test Anime\", season=1, episode=5)\n\n        with patch.object(provider, \"post_data\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=200)\n            with patch(\"module.notification.providers.telegram.load_image\") as mock_load:\n                mock_load.return_value = None\n                result = await provider.send(notify)\n\n        assert result is True\n        mock_post.assert_called_once()\n\n    async def test_test_success(self, provider):\n        \"\"\"Test method sends test message.\"\"\"\n        with patch.object(provider, \"post_data\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=200)\n            success, message = await provider.test()\n\n        assert success is True\n        assert \"successfully\" in message.lower()\n\n\nclass TestDiscordProvider:\n    @pytest.fixture\n    def provider(self):\n        config = ProviderConfig(\n            type=\"discord\", enabled=True, webhook_url=\"https://discord.com/api/webhooks/123\"\n        )\n        return DiscordProvider(config)\n\n    async def test_send(self, provider):\n        \"\"\"Sends embed message.\"\"\"\n        notify = Notification(\n            official_title=\"Test Anime\", season=1, episode=5, poster_path=\"https://example.com/poster.jpg\"\n        )\n\n        with patch.object(provider, \"post_data\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=204)\n            result = await provider.send(notify)\n\n        assert result is True\n        call_args = mock_post.call_args[0]\n        assert \"embeds\" in call_args[1]\n\n\nclass TestBarkProvider:\n    @pytest.fixture\n    def provider(self):\n        config = ProviderConfig(type=\"bark\", enabled=True, device_key=\"device123\")\n        return BarkProvider(config)\n\n    async def test_send(self, provider):\n        \"\"\"Sends push notification.\"\"\"\n        notify = Notification(official_title=\"Test Anime\", season=1, episode=5)\n\n        with patch.object(provider, \"post_data\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=200)\n            result = await provider.send(notify)\n\n        assert result is True\n        call_args = mock_post.call_args[0]\n        assert \"device_key\" in call_args[1]\n\n\nclass TestWebhookProvider:\n    @pytest.fixture\n    def provider(self):\n        config = ProviderConfig(\n            type=\"webhook\",\n            enabled=True,\n            url=\"https://example.com/webhook\",\n            template='{\"anime\": \"{{title}}\", \"ep\": {{episode}}}',\n        )\n        return WebhookProvider(config)\n\n    def test_render_template(self, provider):\n        \"\"\"Template rendering replaces variables.\"\"\"\n        notify = Notification(official_title=\"Test Anime\", season=1, episode=5)\n        result = provider._render_template(notify)\n\n        assert result[\"anime\"] == \"Test Anime\"\n        assert result[\"ep\"] == 5\n\n    async def test_send(self, provider):\n        \"\"\"Sends custom payload.\"\"\"\n        notify = Notification(official_title=\"Test Anime\", season=1, episode=5)\n\n        with patch.object(provider, \"post_data\", new_callable=AsyncMock) as mock_post:\n            mock_post.return_value = MagicMock(status_code=200)\n            result = await provider.send(notify)\n\n        assert result is True\n\n\n# ---------------------------------------------------------------------------\n# Config Migration\n# ---------------------------------------------------------------------------\n\n\nclass TestConfigMigration:\n    def test_legacy_config_migration(self):\n        \"\"\"Old single-provider config migrates to new format.\"\"\"\n        from module.models.config import Notification as NotificationConfig\n\n        # Old format\n        old_config = NotificationConfig(\n            enable=True,\n            type=\"telegram\",\n            token=\"old_token\",\n            chat_id=\"old_chat_id\",\n        )\n\n        # Should have migrated to new format\n        assert len(old_config.providers) == 1\n        assert old_config.providers[0].type == \"telegram\"\n        assert old_config.providers[0].enabled is True\n\n    def test_new_config_no_migration(self):\n        \"\"\"New format with providers doesn't trigger migration.\"\"\"\n        from module.models.config import Notification as NotificationConfig\n\n        provider = ProviderConfig(type=\"discord\", enabled=True, webhook_url=\"https://discord.com/webhook\")\n        new_config = NotificationConfig(\n            enable=True,\n            providers=[provider],\n        )\n\n        assert len(new_config.providers) == 1\n        assert new_config.providers[0].type == \"discord\"\n"
  },
  {
    "path": "backend/src/test/test_openai.py",
    "content": "import json\nimport pytest\nfrom unittest import mock\n\nfrom module.parser.analyser.openai import DEFAULT_PROMPT, OpenAIParser\n\n\nclass TestOpenAIParser:\n    @classmethod\n    def setup_class(cls):\n        api_key = \"testing!\"\n        cls.parser = OpenAIParser(api_key=api_key)\n\n    @pytest.mark.skip(reason=\"This test is not implemented yet.\")\n    def test__prepare_params_with_openai(self):\n        text = \"hello world\"\n        expected = dict(\n            messages=[\n                dict(role=\"system\", content=DEFAULT_PROMPT),\n                dict(role=\"user\", content=text),\n            ],\n            temperature=0,\n            model=self.parser.model,\n        )\n\n        params = self.parser._prepare_params(text, DEFAULT_PROMPT)\n        assert expected == params\n\n    @pytest.mark.skip(reason=\"This test is not implemented yet.\")\n    def test__prepare_params_with_azure(self):\n        azure_parser = OpenAIParser(\n            api_key=\"aaabbbcc\",\n            api_base=\"https://test.openai.azure.com/\",\n            api_type=\"azure\",\n            api_version=\"2023-05-15\",\n            deployment_id=\"gpt-35-turbo\",\n        )\n\n        text = \"hello world\"\n        expected = dict(\n            messages=[\n                dict(role=\"system\", content=DEFAULT_PROMPT),\n                dict(role=\"user\", content=text),\n            ],\n            temperature=0,\n            deployment_id=\"gpt-35-turbo\",\n            api_version=\"2023-05-15\",\n            api_type=\"azure\",\n        )\n\n        params = azure_parser._prepare_params(text, DEFAULT_PROMPT)\n        assert expected == params\n\n    def test_parse(self):\n        text = \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]\"\n        expected = {\n            \"group\": \"梦蓝字幕组\",\n            \"title_en\": \"New Doraemon\",\n            \"resolution\": \"1080P\",\n            \"episode\": 747,\n            \"season\": 1,\n            \"title_zh\": \"哆啦A梦新番\",\n            \"sub\": \"GB_JP\",\n            \"title_jp\": \"\",\n            \"season_raw\": \"2023.02.25\",\n            \"source\": \"AVC\",\n        }\n\n        with mock.patch(\"module.parser.analyser.OpenAIParser.parse\") as mocker:\n            mocker.return_value = json.dumps(expected)\n\n            result = self.parser.parse(text=text, asdict=False)\n            assert json.loads(result) == expected\n"
  },
  {
    "path": "backend/src/test/test_path.py",
    "content": "\"\"\"Tests for TorrentPath: save path generation, file classification, parsing.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch\n\nfrom module.downloader.path import TorrentPath\nfrom module.models import Bangumi, BangumiUpdate\n\nfrom test.factories import make_bangumi\n\n\n@pytest.fixture\ndef torrent_path():\n    return TorrentPath()\n\n\n# ---------------------------------------------------------------------------\n# _gen_save_path\n# ---------------------------------------------------------------------------\n\n\nclass TestGenSavePath:\n    def test_with_year(self):\n        \"\"\"Save path includes (year) when year is set.\"\"\"\n        bangumi = make_bangumi(official_title=\"My Anime\", year=\"2024\", season=2)\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"My Anime (2024)\" in result\n        assert \"Season 2\" in result\n\n    def test_without_year(self):\n        \"\"\"Save path omits year parentheses when year is None.\"\"\"\n        bangumi = make_bangumi(official_title=\"My Anime\", year=None, season=1)\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"My Anime\" in result\n        assert \"()\" not in result\n        assert \"Season 1\" in result\n\n    def test_season_formatting(self):\n        \"\"\"Season is a plain integer, not zero-padded in path.\"\"\"\n        bangumi = make_bangumi(season=10)\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 10\" in result\n\n    def test_with_different_base_path(self):\n        \"\"\"Works with different base download path.\"\"\"\n        bangumi = make_bangumi(official_title=\"Test\", year=\"2025\", season=3)\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/mnt/media/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert result.startswith(\"/mnt/media/Bangumi\")\n        assert \"Test (2025)\" in result\n        assert \"Season 3\" in result\n\n\n# ---------------------------------------------------------------------------\n# _rule_name\n# ---------------------------------------------------------------------------\n\n\nclass TestRuleName:\n    def test_without_group_tag(self):\n        \"\"\"Rule name without group tag is just title and season.\"\"\"\n        bangumi = make_bangumi(official_title=\"My Anime\", season=1, group_name=\"Sub\")\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.bangumi_manage.group_tag = False\n            result = TorrentPath._rule_name(bangumi)\n\n        assert result == \"My Anime S1\"\n\n    def test_with_group_tag(self):\n        \"\"\"Rule name with group tag includes [group] prefix.\"\"\"\n        bangumi = make_bangumi(official_title=\"My Anime\", season=2, group_name=\"SubGroup\")\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.bangumi_manage.group_tag = True\n            result = TorrentPath._rule_name(bangumi)\n\n        assert result == \"[SubGroup] My Anime S2\"\n\n\n# ---------------------------------------------------------------------------\n# check_files\n# ---------------------------------------------------------------------------\n\n\nclass TestCheckFiles:\n    def test_separates_media_and_subtitles(self):\n        \"\"\"Media files (.mp4/.mkv) and subtitle files (.ass/.srt) are separated.\"\"\"\n        files = [\n            {\"name\": \"episode01.mkv\"},\n            {\"name\": \"episode01.ass\"},\n            {\"name\": \"episode02.mp4\"},\n            {\"name\": \"episode02.srt\"},\n        ]\n        media, subs = TorrentPath.check_files(files)\n\n        assert len(media) == 2\n        assert \"episode01.mkv\" in media\n        assert \"episode02.mp4\" in media\n        assert len(subs) == 2\n        assert \"episode01.ass\" in subs\n        assert \"episode02.srt\" in subs\n\n    def test_ignores_other_extensions(self):\n        \"\"\"Files with non-media, non-subtitle extensions are ignored.\"\"\"\n        files = [\n            {\"name\": \"episode.mkv\"},\n            {\"name\": \"readme.txt\"},\n            {\"name\": \"info.nfo\"},\n            {\"name\": \"cover.jpg\"},\n        ]\n        media, subs = TorrentPath.check_files(files)\n\n        assert len(media) == 1\n        assert len(subs) == 0\n\n    def test_case_insensitive_extensions(self):\n        \"\"\"Extension matching is case-insensitive.\"\"\"\n        files = [\n            {\"name\": \"episode.MKV\"},\n            {\"name\": \"episode.MP4\"},\n            {\"name\": \"sub.ASS\"},\n            {\"name\": \"sub.SRT\"},\n        ]\n        media, subs = TorrentPath.check_files(files)\n\n        assert len(media) == 2\n        assert len(subs) == 2\n\n    def test_empty_file_list(self):\n        \"\"\"Empty file list returns empty lists.\"\"\"\n        media, subs = TorrentPath.check_files([])\n        assert media == []\n        assert subs == []\n\n    def test_nested_paths(self):\n        \"\"\"Files in subdirectories are handled correctly.\"\"\"\n        files = [\n            {\"name\": \"Season 1/episode01.mkv\"},\n            {\"name\": \"Subs/episode01.ass\"},\n        ]\n        media, subs = TorrentPath.check_files(files)\n\n        assert len(media) == 1\n        assert len(subs) == 1\n\n\n# ---------------------------------------------------------------------------\n# _path_to_bangumi\n# ---------------------------------------------------------------------------\n\n\nclass TestPathToBangumi:\n    def test_extracts_name_and_season(self):\n        \"\"\"Parses save_path to extract bangumi name and season number.\"\"\"\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            tp = TorrentPath()\n            name, season = tp._path_to_bangumi(\n                \"/downloads/Bangumi/My Anime (2024)/Season 2\"\n            )\n\n        assert name == \"My Anime (2024)\"\n        assert season == 2\n\n    def test_season_1_default(self):\n        \"\"\"When no Season pattern found, defaults to season 1.\"\"\"\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            tp = TorrentPath()\n            name, season = tp._path_to_bangumi(\"/downloads/Bangumi/My Anime (2024)\")\n\n        assert name == \"My Anime (2024)\"\n        assert season == 1\n\n    def test_s_prefix_pattern(self):\n        \"\"\"Recognizes S01 style season naming.\"\"\"\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            tp = TorrentPath()\n            name, season = tp._path_to_bangumi(\"/downloads/Bangumi/Anime/S03\")\n\n        assert season == 3\n\n\n# ---------------------------------------------------------------------------\n# is_ep / _file_depth\n# ---------------------------------------------------------------------------\n\n\nclass TestIsEp:\n    def test_shallow_file(self):\n        \"\"\"File at depth 1 (just filename) is considered an episode.\"\"\"\n        tp = TorrentPath()\n        assert tp.is_ep(\"episode.mkv\") is True\n\n    def test_one_folder_deep(self):\n        \"\"\"File at depth 2 (one folder) is still an episode.\"\"\"\n        tp = TorrentPath()\n        assert tp.is_ep(\"Season 1/episode.mkv\") is True\n\n    def test_too_deep(self):\n        \"\"\"File at depth 3+ is NOT considered an episode.\"\"\"\n        tp = TorrentPath()\n        assert tp.is_ep(\"a/b/episode.mkv\") is False\n\n    def test_file_depth(self):\n        \"\"\"_file_depth returns correct part count.\"\"\"\n        tp = TorrentPath()\n        assert tp._file_depth(\"file.mkv\") == 1\n        assert tp._file_depth(\"a/file.mkv\") == 2\n        assert tp._file_depth(\"a/b/c/file.mkv\") == 4\n"
  },
  {
    "path": "backend/src/test/test_path_parser.py",
    "content": "from unittest.mock import patch\n\nfrom module.conf import PLATFORM\n\n\ndef test_path_to_bangumi():\n    # Test for unix-like path\n    from module.downloader.path import TorrentPath\n\n    path = \"Downloads/Bangumi/Kono Subarashii Sekai ni Shukufuku wo!/Season 2/\"\n    bangumi_name, season = TorrentPath()._path_to_bangumi(path)\n    assert bangumi_name == \"Kono Subarashii Sekai ni Shukufuku wo!\"\n    assert season == 2\n\n\nclass TestGenSavePath:\n    \"\"\"Tests for TorrentPath._gen_save_path with season_offset.\"\"\"\n\n    def test_gen_save_path_no_offset(self):\n        \"\"\"Save path uses season directly when no offset.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=1,\n            season_offset=0,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 1\" in result\n        assert \"Test Anime (2024)\" in result\n\n    def test_gen_save_path_with_positive_offset(self):\n        \"\"\"Save path uses adjusted season when offset is positive.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=1,\n            season_offset=1,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 2\" in result  # 1 + 1 = 2\n        assert \"Test Anime (2024)\" in result\n\n    def test_gen_save_path_with_negative_offset(self):\n        \"\"\"Save path uses adjusted season when offset is negative.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=3,\n            season_offset=-1,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 2\" in result  # 3 - 1 = 2\n\n    def test_gen_save_path_offset_below_one_ignored(self):\n        \"\"\"Save path doesn't go below Season 1.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=1,\n            season_offset=-5,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 1\" in result  # Would be -4, so uses original season\n\n    def test_gen_save_path_season_two_no_offset(self):\n        \"\"\"Non-S1 base season with no offset resolves directly.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=2,\n            season_offset=0,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 2\" in result\n\n    def test_gen_save_path_large_positive_offset(self):\n        \"\"\"Large positive offset adds correctly.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=1,\n            season_offset=5,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 6\" in result  # 1 + 5\n\n    def test_gen_save_path_offset_yields_exactly_season_one(self):\n        \"\"\"Offset that resolves to exactly Season 1 is kept.\"\"\"\n        from module.downloader.path import TorrentPath\n        from module.models import Bangumi\n\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            season=2,\n            season_offset=-1,\n            title_raw=\"test\",\n        )\n        with patch(\"module.downloader.path.settings\") as mock_settings:\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            result = TorrentPath._gen_save_path(bangumi)\n\n        assert \"Season 1\" in result  # 2 - 1 = 1\n"
  },
  {
    "path": "backend/src/test/test_qb_downloader.py",
    "content": "\"\"\"Tests for QbDownloader: constructor, SSL/scheme logic, auth, and error handling.\n\nThe implementation keeps _use_https as a local variable computed inside auth()\nfrom self.host, so SSL/HTTPS behaviour is validated by observing auth() side-effects\n(log messages) rather than reading an instance attribute directly.\n\"\"\"\n\nimport logging\nimport pytest\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport httpx\n\nfrom module.downloader.client.qb_downloader import QbDownloader\n\n\n# ---------------------------------------------------------------------------\n# Constructor / URL building\n# ---------------------------------------------------------------------------\n\n\nclass TestQbDownloaderConstructor:\n    \"\"\"Verify host URL normalisation at construction time.\"\"\"\n\n    def test_ssl_true_no_scheme_uses_https(self):\n        \"\"\"ssl=True with bare host prepends https://.\"\"\"\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=True)\n        assert qb.host == \"https://192.168.1.10:8080\"\n\n    def test_ssl_false_no_scheme_uses_http(self):\n        \"\"\"ssl=False with bare host prepends http://.\"\"\"\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=False)\n        assert qb.host == \"http://192.168.1.10:8080\"\n\n    def test_explicit_http_scheme_preserved_when_ssl_true(self):\n        \"\"\"Explicit http:// scheme is kept even if ssl=True.\"\"\"\n        qb = QbDownloader(\n            host=\"http://192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=True\n        )\n        assert qb.host == \"http://192.168.1.10:8080\"\n\n    def test_explicit_https_scheme_preserved_when_ssl_false(self):\n        \"\"\"Explicit https:// scheme is kept even if ssl=False.\"\"\"\n        qb = QbDownloader(\n            host=\"https://192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=False\n        )\n        assert qb.host == \"https://192.168.1.10:8080\"\n\n    def test_explicit_http_scheme_preserved_ssl_false(self):\n        \"\"\"Explicit http:// URL with ssl=False stays as http://.\"\"\"\n        qb = QbDownloader(host=\"http://nas.local:8080\", username=\"u\", password=\"p\", ssl=False)\n        assert qb.host == \"http://nas.local:8080\"\n\n    def test_explicit_https_scheme_preserved_ssl_true(self):\n        \"\"\"Explicit https:// URL with ssl=True stays as https://.\"\"\"\n        qb = QbDownloader(host=\"https://nas.local:8080\", username=\"u\", password=\"p\", ssl=True)\n        assert qb.host == \"https://nas.local:8080\"\n\n    def test_credentials_stored(self):\n        \"\"\"Constructor stores username, password, and ssl flag as-is.\"\"\"\n        qb = QbDownloader(\n            host=\"localhost:8080\", username=\"admin\", password=\"secret\", ssl=False\n        )\n        assert qb.username == \"admin\"\n        assert qb.password == \"secret\"\n        assert qb.ssl is False\n\n    def test_client_initially_none(self):\n        \"\"\"_client starts as None before any auth call.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"admin\", password=\"pass\", ssl=False)\n        assert qb._client is None\n\n\n# ---------------------------------------------------------------------------\n# Scheme selection: parametrised matrix\n# ---------------------------------------------------------------------------\n\n\n@pytest.mark.parametrize(\n    \"host,ssl,expected_prefix\",\n    [\n        # Bare host - scheme derived from ssl flag\n        (\"192.168.1.1:8080\", True, \"https://\"),\n        (\"192.168.1.1:8080\", False, \"http://\"),\n        (\"qb.home\", True, \"https://\"),\n        (\"qb.home\", False, \"http://\"),\n        # Explicit http:// always wins regardless of ssl\n        (\"http://192.168.1.1:8080\", True, \"http://\"),\n        (\"http://192.168.1.1:8080\", False, \"http://\"),\n        # Explicit https:// always wins regardless of ssl\n        (\"https://192.168.1.1:8080\", True, \"https://\"),\n        (\"https://192.168.1.1:8080\", False, \"https://\"),\n    ],\n)\ndef test_scheme_selection_matrix(host: str, ssl: bool, expected_prefix: str):\n    \"\"\"Constructor resolves host scheme correctly for all input combinations.\"\"\"\n    qb = QbDownloader(host=host, username=\"u\", password=\"p\", ssl=ssl)\n    assert qb.host.startswith(expected_prefix), (\n        f\"host={host!r} ssl={ssl} -> expected prefix {expected_prefix!r}, got {qb.host!r}\"\n    )\n\n\n# ---------------------------------------------------------------------------\n# auth: AsyncClient is created with verify=False\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthClientCreation:\n    \"\"\"auth() must create the httpx.AsyncClient with verify=False unconditionally.\"\"\"\n\n    async def test_auth_creates_client_with_verify_false_when_ssl_true(self):\n        \"\"\"verify=False is used even when ssl=True (self-signed certs are common).\"\"\"\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=True)\n\n        captured: list[dict] = []\n\n        class _FakeClient:\n            def __init__(self, **kwargs):\n                captured.append(kwargs)\n\n            async def post(self, url, data=None):\n                resp = MagicMock()\n                resp.status_code = 200\n                resp.text = \"Ok.\"\n                return resp\n\n            async def aclose(self):\n                pass\n\n        with patch(\"module.downloader.client.qb_downloader.httpx.AsyncClient\", _FakeClient):\n            result = await qb.auth()\n\n        assert result is True\n        assert len(captured) == 1\n        assert captured[0].get(\"verify\") is False\n\n    async def test_auth_creates_client_with_verify_false_when_ssl_false(self):\n        \"\"\"verify=False is used even when ssl=False.\"\"\"\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"admin\", password=\"pass\", ssl=False)\n\n        captured: list[dict] = []\n\n        class _FakeClient:\n            def __init__(self, **kwargs):\n                captured.append(kwargs)\n\n            async def post(self, url, data=None):\n                resp = MagicMock()\n                resp.status_code = 200\n                resp.text = \"Ok.\"\n                return resp\n\n            async def aclose(self):\n                pass\n\n        with patch(\"module.downloader.client.qb_downloader.httpx.AsyncClient\", _FakeClient):\n            result = await qb.auth()\n\n        assert result is True\n        assert captured[0].get(\"verify\") is False\n\n    async def test_auth_uses_5_second_connect_timeout(self):\n        \"\"\"auth() sets connect timeout to 5.0 seconds.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"u\", password=\"p\", ssl=False)\n\n        captured_timeouts: list[httpx.Timeout] = []\n\n        class _FakeClient:\n            def __init__(self, **kwargs):\n                captured_timeouts.append(kwargs.get(\"timeout\"))\n\n            async def post(self, url, data=None):\n                resp = MagicMock()\n                resp.status_code = 200\n                resp.text = \"Ok.\"\n                return resp\n\n            async def aclose(self):\n                pass\n\n        with patch(\"module.downloader.client.qb_downloader.httpx.AsyncClient\", _FakeClient):\n            await qb.auth()\n\n        assert len(captured_timeouts) == 1\n        assert captured_timeouts[0].connect == pytest.approx(5.0)\n\n\n# ---------------------------------------------------------------------------\n# auth: success / failure paths\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthSuccessFailure:\n    \"\"\"auth() return value reflects qBittorrent server responses.\"\"\"\n\n    async def test_auth_returns_true_on_ok_response(self):\n        \"\"\"Returns True when server responds 200 + 'Ok.'.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"admin\", password=\"pass\", ssl=False)\n\n        mock_client = AsyncMock()\n        mock_resp = MagicMock()\n        mock_resp.status_code = 200\n        mock_resp.text = \"Ok.\"\n        mock_client.post = AsyncMock(return_value=mock_resp)\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            result = await qb.auth()\n\n        assert result is True\n\n    async def test_auth_returns_false_on_403(self):\n        \"\"\"Returns False and stops retrying immediately on 403 Forbidden.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"admin\", password=\"pass\", ssl=False)\n\n        mock_client = AsyncMock()\n        mock_resp = MagicMock()\n        mock_resp.status_code = 403\n        mock_resp.text = \"Forbidden\"\n        mock_client.post = AsyncMock(return_value=mock_resp)\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            result = await qb.auth(retry=3)\n\n        assert result is False\n        # Should break immediately on 403, not exhaust all retries\n        assert mock_client.post.call_count == 1\n\n    async def test_auth_retries_up_to_limit_on_server_error(self):\n        \"\"\"Retries up to the retry limit on non-200/non-403 responses.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"admin\", password=\"pass\", ssl=False)\n\n        mock_client = AsyncMock()\n        mock_resp = MagicMock()\n        mock_resp.status_code = 500\n        mock_resp.text = \"Internal Server Error\"\n        mock_client.post = AsyncMock(return_value=mock_resp)\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                result = await qb.auth(retry=2)\n\n        assert result is False\n        assert mock_client.post.call_count == 2\n\n\n# ---------------------------------------------------------------------------\n# auth: ConnectError logging - HTTPS vs HTTP message branching\n# ---------------------------------------------------------------------------\n\n\nclass TestAuthConnectErrorLogging:\n    \"\"\"On ConnectError, the log message depends on whether the URL uses https://.\"\"\"\n\n    async def test_https_url_logs_https_specific_guidance(self, caplog):\n        \"\"\"HTTPS-specific guidance is logged when host uses https:// and ConnectError occurs.\"\"\"\n        # Use explicit https:// URL so the local use_https flag is True\n        qb = QbDownloader(\n            host=\"https://192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=True\n        )\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"Connection refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.ERROR, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    result = await qb.auth(retry=1)\n\n        assert result is False\n        error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]\n        assert any(\"HTTPS\" in msg for msg in error_messages)\n        assert any(\n            \"disable SSL\" in msg or \"plain HTTP\" in msg for msg in error_messages\n        )\n\n    async def test_https_url_derived_from_ssl_flag_logs_https_guidance(self, caplog):\n        \"\"\"HTTPS guidance also fires when scheme comes from ssl=True (bare host).\"\"\"\n        # Bare host + ssl=True -> self.host becomes https://... -> use_https=True in auth()\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=True)\n        assert qb.host.startswith(\"https://\")\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.ERROR, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    await qb.auth(retry=1)\n\n        error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]\n        assert any(\"HTTPS\" in msg for msg in error_messages)\n\n    async def test_http_url_logs_generic_message_without_ssl_hint(self, caplog):\n        \"\"\"Generic connection error is logged when host uses http:// and ConnectError occurs.\"\"\"\n        qb = QbDownloader(\n            host=\"http://192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=False\n        )\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"Connection refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.ERROR, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    result = await qb.auth(retry=1)\n\n        assert result is False\n        error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]\n        assert any(\"Cannot connect to qBittorrent Server\" in msg for msg in error_messages)\n        # SSL-disable hint must NOT appear for plain HTTP connections\n        assert not any(\"disable SSL\" in msg for msg in error_messages)\n\n    async def test_http_url_derived_from_ssl_flag_false_no_ssl_hint(self, caplog):\n        \"\"\"SSL-disable hint is absent when scheme comes from ssl=False (bare host).\"\"\"\n        qb = QbDownloader(host=\"192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=False)\n        assert qb.host.startswith(\"http://\")\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.ERROR, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    await qb.auth(retry=1)\n\n        all_messages = \" \".join(r.message for r in caplog.records)\n        assert \"disable SSL\" not in all_messages\n        assert \"plain HTTP\" not in all_messages\n\n    async def test_connect_error_logs_check_ip_port_info(self, caplog):\n        \"\"\"Both HTTPS and HTTP paths log an info message about checking IP/port.\"\"\"\n        qb = QbDownloader(\n            host=\"https://192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=True\n        )\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.INFO, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    await qb.auth(retry=1)\n\n        info_messages = [r.message for r in caplog.records if r.levelno == logging.INFO]\n        assert any(\"IP\" in msg or \"port\" in msg for msg in info_messages)\n\n    async def test_explicit_http_with_ssl_true_still_uses_generic_message(self, caplog):\n        \"\"\"Explicit http:// URL overrides ssl=True: generic error message, no HTTPS hint.\"\"\"\n        # ssl=True but explicit http:// -> use_https=False inside auth()\n        qb = QbDownloader(\n            host=\"http://192.168.1.10:8080\", username=\"u\", password=\"p\", ssl=True\n        )\n        assert qb.host.startswith(\"http://\")\n\n        mock_client = AsyncMock()\n        mock_client.post = AsyncMock(side_effect=httpx.ConnectError(\"refused\"))\n\n        with patch(\n            \"module.downloader.client.qb_downloader.httpx.AsyncClient\",\n            return_value=mock_client,\n        ):\n            with patch(\n                \"module.downloader.client.qb_downloader.asyncio.sleep\",\n                new_callable=AsyncMock,\n            ):\n                with caplog.at_level(\n                    logging.ERROR, logger=\"module.downloader.client.qb_downloader\"\n                ):\n                    await qb.auth(retry=1)\n\n        error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR]\n        assert not any(\"disable SSL\" in msg for msg in error_messages)\n        assert not any(\"HTTPS\" in msg for msg in error_messages)\n\n\n# ---------------------------------------------------------------------------\n# _url helper\n# ---------------------------------------------------------------------------\n\n\nclass TestUrlHelper:\n    \"\"\"_url() builds the correct API endpoint path.\"\"\"\n\n    def test_url_format_with_http(self):\n        \"\"\"_url returns host + /api/v2/ + endpoint for http hosts.\"\"\"\n        qb = QbDownloader(host=\"localhost:8080\", username=\"u\", password=\"p\", ssl=False)\n        assert qb._url(\"auth/login\") == \"http://localhost:8080/api/v2/auth/login\"\n\n    def test_url_format_with_https(self):\n        \"\"\"_url includes https:// when SSL is used.\"\"\"\n        qb = QbDownloader(host=\"nas.local:8080\", username=\"u\", password=\"p\", ssl=True)\n        assert qb._url(\"app/version\") == \"https://nas.local:8080/api/v2/app/version\"\n\n    def test_url_with_explicit_http_scheme_overriding_ssl_true(self):\n        \"\"\"_url works correctly when explicit http:// scheme overrides ssl=True.\"\"\"\n        qb = QbDownloader(host=\"http://nas.local:8080\", username=\"u\", password=\"p\", ssl=True)\n        assert qb._url(\"torrents/info\") == \"http://nas.local:8080/api/v2/torrents/info\"\n"
  },
  {
    "path": "backend/src/test/test_raw_parser.py",
    "content": "import pytest\n\nfrom module.parser.analyser import raw_parser\n\n\ndef test_raw_parser():\n    # Issue #794, RSS link: https://mikanani.me/RSS/Bangumi?bangumiId=3367&subgroupid=370\n    content = \"[喵萌奶茶屋&LoliHouse] 鹿乃子乃子乃子虎视眈眈 / Shikanoko Nokonoko Koshitantan\\n- 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\"\n    info = raw_parser(content)\n    assert info.group == \"喵萌奶茶屋&LoliHouse\"\n    assert info.title_zh == \"鹿乃子乃子乃子虎视眈眈\"\n    assert info.title_en == \"Shikanoko Nokonoko Koshitantan\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 1\n    assert info.season == 1\n\n    # Issue #679, RSS link: https://mikanani.me/RSS/Bangumi?bangumiId=3225&subgroupid=370\n    content = \"[LoliHouse] 轮回七次的反派大小姐，在前敌国享受随心所欲的新婚生活\\n / 7th Time Loop - 12 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕][END]\"\n    info = raw_parser(content)\n    assert info.group == \"LoliHouse\"\n    assert info.title_zh == \"轮回七次的反派大小姐，在前敌国享受随心所欲的新婚生活\"\n    assert info.title_en == \"7th Time Loop\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 12\n    assert info.season == 1\n\n    content = \"【幻樱字幕组】【4月新番】【古见同学有交流障碍症 第二季 Komi-san wa, Komyushou Desu. S02】【22】【GB_MP4】【1920X1080】\"\n    info = raw_parser(content)\n    assert info.title_en == \"Komi-san wa, Komyushou Desu.\"\n    assert info.resolution == \"1920X1080\"\n    assert info.episode == 22\n    assert info.season == 2\n\n    content = \"[百冬练习组&LoliHouse] BanG Dream! 少女乐团派对！☆PICO FEVER！ / Garupa Pico: Fever! - 26 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕][END] [101.69 MB]\"\n    info = raw_parser(content)\n    assert info.group == \"百冬练习组&LoliHouse\"\n    assert info.title_zh == \"BanG Dream! 少女乐团派对！☆PICO FEVER！\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 26\n    assert info.season == 1\n\n    content = \"【喵萌奶茶屋】★04月新番★[夏日重现/Summer Time Rendering][11][1080p][繁日双语][招募翻译]\"\n    info = raw_parser(content)\n    assert info.group == \"喵萌奶茶屋\"\n    assert info.title_en == \"Summer Time Rendering\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 11\n    assert info.season == 1\n\n    content = \"[Lilith-Raws] 关于我在无意间被隔壁的天使变成废柴这件事 / Otonari no Tenshi-sama - 09 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4]\"\n    info = raw_parser(content)\n    assert info.group == \"Lilith-Raws\"\n    assert info.title_zh == \"关于我在无意间被隔壁的天使变成废柴这件事\"\n    assert info.title_en == \"Otonari no Tenshi-sama\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 9\n    assert info.season == 1\n\n    content = \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]\"\n    info = raw_parser(content)\n    assert info.group == \"梦蓝字幕组\"\n    assert info.title_zh == \"哆啦A梦新番\"\n    assert info.title_en == \"New Doraemon\"\n    assert info.resolution == \"1080P\"\n    assert info.episode == 747\n    assert info.season == 1\n\n    content = \"[织梦字幕组][尼尔：机械纪元 NieR Automata Ver1.1a][02集][1080P][AVC][简日双语]\"\n    info = raw_parser(content)\n    assert info.group == \"织梦字幕组\"\n    assert info.title_zh == \"尼尔：机械纪元\"\n    assert info.title_en == \"NieR Automata Ver1.1a\"\n    assert info.resolution == \"1080P\"\n    assert info.episode == 2\n    assert info.season == 1\n\n    content = \"[MagicStar] 假面骑士Geats / 仮面ライダーギーツ EP33 [WEBDL] [1080p] [TTFC]【生】\"\n    info = raw_parser(content)\n    assert info.group == \"MagicStar\"\n    assert info.title_zh == \"假面骑士Geats\"\n    assert info.title_jp == \"仮面ライダーギーツ\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 33\n    assert info.season == 1\n\n    content = \"【极影字幕社】★4月新番 天国大魔境 Tengoku Daimakyou 第05话 GB 720P MP4（字幕社招人内详）\"\n    info = raw_parser(content)\n    assert info.group == \"极影字幕社\"\n    assert info.title_zh == \"天国大魔境\"\n    assert info.title_en == \"Tengoku Daimakyou\"\n    assert info.resolution == \"720P\"\n    assert info.episode == 5\n    assert info.season == 1\n\n    content = \"【喵萌奶茶屋】★07月新番★[银砂糖师与黑妖精 ~ Sugar Apple Fairy Tale ~][13][1080p][简日双语][招募翻译]\"\n    info = raw_parser(content)\n    assert info.group == \"喵萌奶茶屋\"\n    assert info.title_zh == \"银砂糖师与黑妖精\"\n    assert info.title_en == \"~ Sugar Apple Fairy Tale ~\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 13\n    assert info.season == 1\n\n    content = \"[ANi]  16bit 的感动 ANOTHER LAYER - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]\"\n    info = raw_parser(content)\n    assert info.group == \"ANi\"\n    assert info.title_zh == \"16bit 的感动 ANOTHER LAYER\"\n    assert info.resolution == \"1080P\"\n    assert info.episode == 1\n    assert info.season == 1\n\n    # Chinese season number via CHINESE_NUMBER_MAP (\"二\" → 2)\n    content = \"[LoliHouse] 关于我转生变成史莱姆这档事 第二季 / Tensei shitara Slime Datta Ken 2nd Season - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\"\n    info = raw_parser(content)\n    assert info.group == \"LoliHouse\"\n    assert info.title_zh == \"关于我转生变成史莱姆这档事\"\n    assert info.title_en == \"Tensei shitara Slime Datta Ken 2nd Season\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 1\n    assert info.season == 2\n\n    # 4K resolution (2160p) — RESOLUTION_RE covers 2160 but untested\n    content = \"[NC-Raws] 葬送的芙莉莲 / Sousou no Frieren - 03 [B-Global][WEB-DL][2160p][AVC AAC][Multi Sub][MKV]\"\n    info = raw_parser(content)\n    assert info.group == \"NC-Raws\"\n    assert info.title_zh == \"葬送的芙莉莲\"\n    assert info.title_en == \"Sousou no Frieren\"\n    assert info.resolution == \"2160p\"\n    assert info.episode == 3\n    assert info.season == 1\n\n    # English \"Season N\" format (bracketed) — season_rule \"Season \\d{1,2}\" branch\n    content = \"[LoliHouse] 狼与香辛料 [Season 2] / Spice and Wolf - 01 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\"\n    info = raw_parser(content)\n    assert info.group == \"LoliHouse\"\n    assert info.title_zh == \"狼与香辛料\"\n    assert info.title_en == \"Spice and Wolf\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 1\n    assert info.season == 2\n\n    # Multi-group, Chinese punctuation in title, single-letter Latin prefix in EN title\n    content = \"[北宇治字幕组&LoliHouse] 地。-关于地球的运动- / Chi. Chikyuu no Undou ni Tsuite - 03 [WebRip 1080p HEVC-10bit AAC ASSx2][简繁日内封字幕]\"\n    info = raw_parser(content)\n    assert info.group == \"北宇治字幕组&LoliHouse\"\n    assert info.title_zh == \"地。-关于地球的运动-\"\n    assert info.title_en == \"Chi. Chikyuu no Undou ni Tsuite\"\n    assert info.resolution == \"1080p\"\n    assert info.episode == 3\n    assert info.season == 1\n\n    # English-only title — name_process returns title_zh=None when no CJK chars\n    content = \"[动漫国字幕组&LoliHouse] THE MARGINAL SERVICE - 08 [WebRip 1080p HEVC-10bit AAC][简繁内封字幕]\"\n    info = raw_parser(content)\n    assert info.group == \"动漫国字幕组&LoliHouse\"\n    assert info.title_en == \"THE MARGINAL SERVICE\"\n    assert info.title_zh is None\n    assert info.resolution == \"1080p\"\n    assert info.episode == 8\n    assert info.season == 1\n\n    # Issue #990: Title starting with number — should not misparse \"29\" as episode\n    content = \"[ANi] 29 岁单身中坚冒险家的日常 - 07 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]\"\n    info = raw_parser(content)\n    assert info.group == \"ANi\"\n    assert info.title_zh == \"29 岁单身中坚冒险家的日常\"\n    assert info.resolution == \"1080P\"\n    assert info.episode == 7\n    assert info.season == 1\n\n\n# ---------------------------------------------------------------------------\n# Issue-specific regression tests\n# ---------------------------------------------------------------------------\n\n\nclass TestIssue924SpecialPunctuation:\n    \"\"\"Issue #924: Title with full-width parentheses and exclamation marks.\"\"\"\n\n    def test_parse_title_with_fullwidth_parens(self):\n        content = \"[御坂字幕组] 男女之间存在纯友情吗？（不，不存在!!）-01 [WebRip 1080p HEVC10-bit AAC] [简繁日内封] [急招翻校轴]\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"御坂字幕组\"\n        assert info.title_zh == \"男女之间存在纯友情吗？（不，不存在!!）\"\n        assert info.episode == 1\n        assert info.resolution == \"1080p\"\n        assert info.sub == \"简繁日内封\"\n        assert info.source == \"WebRip\"\n\n\nclass TestIssue910NeoQswFormat:\n    \"\"\"Issue #910: NEO·QSW group format with inline episode number.\"\"\"\n\n    TITLE = \" [NEO·QSW]想星的阿克艾利昂 情感神话 想星のアクエリオン Aquarion: Myth of Emotions 02[WEBRIP AVC 1080P]（搜索用：想星的大天使）\"\n\n    def test_parse_neo_qsw_format(self):\n        info = raw_parser(self.TITLE)\n        assert info is not None\n        assert info.title_zh == \"想星的阿克艾利昂\"\n        assert info.episode == 2\n\n\nclass TestIssue876NoSeparator:\n    \"\"\"Issue #876: Episode number without dash separator.\n\n    Note: the dash-separated variant \"- 03\" already works (tested in test_raw_parser).\n    This tests the space-only variant \"Tsuite 03\" which the fallback parser handles.\n    \"\"\"\n\n    TITLE = \"[北宇治字幕组&LoliHouse] 地。-关于地球的运动- / Chi. Chikyuu no Undou ni Tsuite 03 [WebRip 1080p HEVC-10bit AAC ASSx2][简繁日内封字幕]\"\n\n    def test_parse_without_dash(self):\n        info = raw_parser(self.TITLE)\n        assert info is not None\n        assert info.title_zh == \"地。-关于地球的运动-\"\n        assert info.title_en == \"Chi. Chikyuu no Undou ni Tsuite\"\n        assert info.episode == 3\n\n\nclass TestIssue819ChineseEpisodeMarker:\n    \"\"\"Issue #819: [Doomdos] format with 第N话 episode marker.\"\"\"\n\n    def test_parse_chinese_episode_marker(self):\n        content = \"[Doomdos] - 白色闪电 - 第02话 - [1080P].mp4\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"Doomdos\"\n        assert info.episode == 2\n        assert info.resolution == \"1080P\"\n        # BUG: title_zh includes leading/trailing dashes from the separator\n        assert info.title_zh == \"- 白色闪电 -\"\n\n\nclass TestIssue811ColonInTitle:\n    \"\"\"Issue #811: Title with colon and degree symbol in group name.\"\"\"\n\n    def test_parse_colon_in_english_title(self):\n        content = \"[Up to 21°C] 鬼灭之刃 柱训练篇 / Kimetsu no Yaiba: Hashira Geiko-hen - 03 (CR 1920x1080 AVC AAC MKV)\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"Up to 21°C\"\n        assert info.title_zh == \"鬼灭之刃 柱训练篇\"\n        assert info.title_en == \"Kimetsu no Yaiba: Hashira Geiko-hen\"\n        assert info.episode == 3\n        assert info.season == 1\n\n\nclass TestIssue798VTuberTitle:\n    \"\"\"Issue #798: Title with 'VTuber' split incorrectly by name_process.\"\"\"\n\n    def test_parse_vtuber_title(self):\n        content = \"[ANi] 身为 VTuber 的我因为忘记关台而成了传说 - 01 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4][379.34 MB]\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"ANi\"\n        assert info.episode == 1\n        assert info.resolution == \"1080P\"\n        assert info.source == \"Baha\"\n        # BUG: name_process splits on space and only keeps first Chinese word\n        assert info.title_zh == \"身为\"\n        assert info.title_en == \"VTuber 的我因为忘记关台而成了传说\"\n\n\nclass TestIssue794PreEpisodeFormat:\n    \"\"\"Issue #794/#800: [01Pre] episode format not recognized.\"\"\"\n\n    TITLES = [\n        \"[KitaujiSub] Shikanoko Nokonoko Koshitantan [01Pre][WebRip][HEVC_AAC][CHS_JP].mp4\",\n        \"[KitaujiSub] Shikanoko Nokonoko Koshitantan [01Pre][WebRip][HEVC_AAC][CHT_JP].mp4\",\n    ]\n\n    @pytest.mark.xfail(reason=\"[01Pre] episode format not supported by TITLE_RE\")\n    def test_parse_pre_episode(self):\n        info = raw_parser(self.TITLES[0])\n        assert info is not None\n        assert info.title_en == \"Shikanoko Nokonoko Koshitantan\"\n        assert info.episode == 1\n\n    @pytest.mark.parametrize(\"title\", TITLES)\n    def test_returns_none(self, title):\n        \"\"\"Parser cannot handle [01Pre] format currently.\"\"\"\n        assert raw_parser(title) is None\n\n\nclass TestIssue766Lv2InTitle:\n    \"\"\"Issue #766: Title with 'Lv2' causing incorrect name split.\"\"\"\n\n    def test_parse_lv2_title(self):\n        content = \"[ANi]  从 Lv2 开始开外挂的前勇者候补过著悠哉异世界生活 - 04 [1080P][Baha][WEB-DL][AAC AVC][CHT][MP4]\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"ANi\"\n        assert info.episode == 4\n        assert info.resolution == \"1080P\"\n        assert info.source == \"Baha\"\n        # BUG: name_process splits on space, loses the \"从 Lv2\" prefix\n        assert info.title_zh == \"开始开外挂的前勇者候补过著悠哉异世界生活\"\n\n\nclass TestIssue764WesternFormat:\n    \"\"\"Issue #764: Western release format without group brackets.\"\"\"\n\n    def test_parse_western_format(self):\n        content = \"Girls Band Cry S01E05 VOSTFR 1080p WEB x264 AAC -Tsundere-Raws (ADN)\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.episode == 5\n        assert info.season == 1\n        assert info.resolution == \"1080p\"\n        # No brackets → group detection fails\n        assert info.group == \"\"\n        # No CJK chars → no title_zh/jp; EN detection also fails (short segments)\n        assert info.title_en is None\n        assert info.title_zh is None\n\n\nclass TestIssue986AtlasFormat:\n    \"\"\"Issue #986: Atlas subtitle group bracket-delimited format.\"\"\"\n\n    TITLES = [\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][04_半神们的卡农曲][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC]\",\n        \"[阿特拉斯字幕组·雪原市出差所][命运-奇异赝品_Fate／strange Fake][07_神自黄昏归来][简繁日内封PGS][日语配音版_Japanese Dub][Web-DL Remux][1080p AVC AAC]\",\n    ]\n\n    @pytest.mark.xfail(reason=\"Atlas bracket-delimited format not supported by TITLE_RE\")\n    def test_parse_atlas_format(self):\n        info = raw_parser(self.TITLES[0])\n        assert info is not None\n        assert info.title_zh == \"命运-奇异赝品\"\n        assert info.episode == 4\n\n    @pytest.mark.parametrize(\"title\", TITLES)\n    def test_returns_none(self, title):\n        \"\"\"Parser cannot handle Atlas format currently.\"\"\"\n        assert raw_parser(title) is None\n\n\nclass TestIssue773CompoundEpisode:\n    \"\"\"Issue #773: Compound episode number [02(57)] not recognized.\"\"\"\n\n    TITLE = \"【豌豆字幕组&风之圣殿字幕组】★04月新番[鬼灭之刃 柱训练篇 / Kimetsu_no_Yaiba-Hashira_Geiko_Hen][02(57)][简体][1080P][MP4]\"\n\n    def test_parse_compound_episode(self):\n        info = raw_parser(self.TITLE)\n        assert info is not None\n        assert info.title_zh == \"鬼灭之刃 柱训练篇\"\n        assert info.episode == 2\n\n\nclass TestIssue805TitleWithCht:\n    \"\"\"Issue #805: Traditional Chinese title parses correctly.\"\"\"\n\n    def test_parse_cht_title(self):\n        content = \"[ANi] 不時輕聲地以俄語遮羞的鄰座艾莉同學 - 02 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4\"\n        info = raw_parser(content)\n        assert info is not None\n        assert info.group == \"ANi\"\n        assert info.title_zh == \"不時輕聲地以俄語遮羞的鄰座艾莉同學\"\n        assert info.episode == 2\n        assert info.resolution == \"1080P\"\n        assert info.source == \"Baha\"\n        assert info.sub == \"CHT\"\n\n"
  },
  {
    "path": "backend/src/test/test_renamer.py",
    "content": "\"\"\"Tests for Renamer: gen_path, rename_file, rename_collection, rename flow.\"\"\"\n\nfrom unittest.mock import AsyncMock, MagicMock, patch\n\nimport pytest\n\nfrom module.manager.renamer import Renamer\nfrom module.models import EpisodeFile, Notification, SubtitleFile\n\n# ---------------------------------------------------------------------------\n# gen_path\n# ---------------------------------------------------------------------------\n\n\nclass TestGenPath:\n    def test_pn_method(self):\n        \"\"\"pn method: {title} S{ss}E{ee}{suffix}\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi Name\", method=\"pn\")\n        assert result == \"My Anime S01E05.mkv\"\n\n    def test_advance_method(self):\n        \"\"\"advance method: {bangumi_name} S{ss}E{ee}{suffix}\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=2, episode=12, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi Name\", method=\"advance\")\n        assert result == \"Bangumi Name S02E12.mkv\"\n\n    def test_none_method(self):\n        \"\"\"none method: returns original media_path unchanged.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"original/path/file.mkv\",\n            title=\"Test\",\n            season=1,\n            episode=1,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"none\")\n        assert result == \"original/path/file.mkv\"\n\n    def test_subtitle_none_method(self):\n        \"\"\"subtitle_none: returns original path unchanged.\"\"\"\n        sub = SubtitleFile(\n            media_path=\"sub.ass\",\n            title=\"Test\",\n            season=1,\n            episode=1,\n            language=\"zh\",\n            suffix=\".ass\",\n        )\n        result = Renamer.gen_path(sub, \"Bangumi\", method=\"subtitle_none\")\n        assert result == \"sub.ass\"\n\n    def test_subtitle_pn_method(self):\n        \"\"\"subtitle_pn: {title} S{ss}E{ee}.{language}{suffix}\"\"\"\n        sub = SubtitleFile(\n            media_path=\"sub.ass\",\n            title=\"My Anime\",\n            season=1,\n            episode=3,\n            language=\"zh\",\n            suffix=\".ass\",\n        )\n        result = Renamer.gen_path(sub, \"Bangumi\", method=\"subtitle_pn\")\n        assert result == \"My Anime S01E03.zh.ass\"\n\n    def test_subtitle_advance_method(self):\n        \"\"\"subtitle_advance: {bangumi_name} S{ss}E{ee}.{language}{suffix}\"\"\"\n        sub = SubtitleFile(\n            media_path=\"sub.srt\",\n            title=\"My Anime\",\n            season=2,\n            episode=7,\n            language=\"zh-tw\",\n            suffix=\".srt\",\n        )\n        result = Renamer.gen_path(sub, \"Bangumi Name\", method=\"subtitle_advance\")\n        assert result == \"Bangumi Name S02E07.zh-tw.srt\"\n\n    def test_zero_padding_single_digit(self):\n        \"\"\"Season and episode < 10 get zero-padded.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"Test\", season=1, episode=9, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Test\", method=\"pn\")\n        assert \"S01E09\" in result\n\n    def test_no_padding_double_digit(self):\n        \"\"\"Season and episode >= 10 are NOT zero-padded.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"Test\", season=10, episode=12, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Test\", method=\"pn\")\n        assert \"S10E12\" in result\n\n    def test_unknown_method_returns_original(self):\n        \"\"\"Unknown method returns original media_path.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"original.mkv\", title=\"Test\", season=1, episode=1, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Test\", method=\"invalid_method\")\n        assert result == \"original.mkv\"\n\n    def test_mp4_suffix(self):\n        \"\"\"Works with .mp4 suffix too.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mp4\", title=\"Test\", season=1, episode=1, suffix=\".mp4\"\n        )\n        result = Renamer.gen_path(ep, \"Test\", method=\"pn\")\n        assert result.endswith(\".mp4\")\n\n\n# ---------------------------------------------------------------------------\n# rename_file\n# ---------------------------------------------------------------------------\n\n\nclass TestRenameFile:\n    @pytest.fixture\n    def renamer(self, mock_qb_client):\n        \"\"\"Create Renamer with mocked internals.\"\"\"\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost:8080\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            mock_settings.bangumi_manage.remove_bad_torrent = False\n            mock_settings.bangumi_manage.rename_method = \"pn\"\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                r = Renamer()\n        r.client = mock_qb_client\n        return r\n\n    async def test_successful_rename(self, renamer):\n        \"\"\"rename_file parses, generates new path, renames, returns Notification.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=5, suffix=\".mkv\"\n        )\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=ep):\n            renamer.client.torrents_rename_file.return_value = True\n            result = await renamer.rename_file(\n                torrent_name=\"[Sub] My Anime - 05.mkv\",\n                media_path=\"old.mkv\",\n                bangumi_name=\"My Anime\",\n                method=\"pn\",\n                season=1,\n                _hash=\"hash123\",\n            )\n\n        assert result is not None\n        assert isinstance(result, Notification)\n        assert result.official_title == \"My Anime\"\n        assert result.season == 1\n        assert result.episode == 5\n\n    async def test_parse_fails_no_remove(self, renamer):\n        \"\"\"When parser returns None and remove_bad_torrent=False, returns None.\"\"\"\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=None):\n            with patch(\"module.manager.renamer.settings\") as mock_settings:\n                mock_settings.bangumi_manage.remove_bad_torrent = False\n                result = await renamer.rename_file(\n                    torrent_name=\"garbage\",\n                    media_path=\"bad.mkv\",\n                    bangumi_name=\"Test\",\n                    method=\"pn\",\n                    season=1,\n                    _hash=\"hash123\",\n                )\n\n        assert result is None\n        renamer.client.torrents_delete.assert_not_called()\n\n    async def test_parse_fails_remove_bad(self, renamer):\n        \"\"\"When parser fails and remove_bad_torrent=True, deletes torrent.\"\"\"\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=None):\n            with patch(\"module.manager.renamer.settings\") as mock_settings:\n                mock_settings.bangumi_manage.remove_bad_torrent = True\n                await renamer.rename_file(\n                    torrent_name=\"garbage\",\n                    media_path=\"bad.mkv\",\n                    bangumi_name=\"Test\",\n                    method=\"pn\",\n                    season=1,\n                    _hash=\"hash_bad\",\n                )\n\n        renamer.client.torrents_delete.assert_called_once_with(\n            \"hash_bad\", delete_files=True\n        )\n\n    async def test_same_path_skipped(self, renamer):\n        \"\"\"When generated path equals current path, no rename occurs.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"My Anime S01E05.mkv\",\n            title=\"My Anime\",\n            season=1,\n            episode=5,\n            suffix=\".mkv\",\n        )\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=ep):\n            result = await renamer.rename_file(\n                torrent_name=\"test\",\n                media_path=\"My Anime S01E05.mkv\",\n                bangumi_name=\"My Anime\",\n                method=\"pn\",\n                season=1,\n                _hash=\"hash123\",\n            )\n\n        assert result is None\n        renamer.client.torrents_rename_file.assert_not_called()\n\n\n# ---------------------------------------------------------------------------\n# rename_collection\n# ---------------------------------------------------------------------------\n\n\nclass TestRenameCollection:\n    @pytest.fixture\n    def renamer(self, mock_qb_client):\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost:8080\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            mock_settings.bangumi_manage.remove_bad_torrent = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                r = Renamer()\n        r.client = mock_qb_client\n        return r\n\n    async def test_renames_each_file(self, renamer):\n        \"\"\"rename_collection iterates media_list and renames each valid file.\"\"\"\n        media_list = [\"ep01.mkv\", \"ep02.mkv\", \"ep03.mkv\"]\n\n        def mock_parser(torrent_path, season, **kwargs):\n            ep_num = int(torrent_path.replace(\"ep\", \"\").replace(\".mkv\", \"\"))\n            return EpisodeFile(\n                media_path=torrent_path,\n                title=\"Anime\",\n                season=season,\n                episode=ep_num,\n                suffix=\".mkv\",\n            )\n\n        with patch.object(renamer._parser, \"torrent_parser\", side_effect=mock_parser):\n            renamer.client.torrents_rename_file.return_value = True\n            await renamer.rename_collection(\n                media_list=media_list,\n                bangumi_name=\"Anime\",\n                season=1,\n                method=\"pn\",\n                _hash=\"hash123\",\n            )\n\n        assert renamer.client.torrents_rename_file.call_count == 3\n\n    async def test_skips_deep_files(self, renamer):\n        \"\"\"Files deeper than 2 levels are skipped (not is_ep).\"\"\"\n        media_list = [\"ep01.mkv\", \"extras/bonus/ep_sp.mkv\"]\n\n        ep = EpisodeFile(\n            media_path=\"ep01.mkv\",\n            title=\"Anime\",\n            season=1,\n            episode=1,\n            suffix=\".mkv\",\n        )\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=ep):\n            renamer.client.torrents_rename_file.return_value = True\n            await renamer.rename_collection(\n                media_list=media_list,\n                bangumi_name=\"Anime\",\n                season=1,\n                method=\"pn\",\n                _hash=\"hash123\",\n            )\n\n        # Only called once for ep01.mkv (depth 1)\n        assert renamer.client.torrents_rename_file.call_count == 1\n\n\n# ---------------------------------------------------------------------------\n# rename_subtitles\n# ---------------------------------------------------------------------------\n\n\nclass TestRenameSubtitles:\n    @pytest.fixture\n    def renamer(self, mock_qb_client):\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost:8080\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                r = Renamer()\n        r.client = mock_qb_client\n        return r\n\n    async def test_renames_subtitles_with_language(self, renamer):\n        \"\"\"rename_subtitles prepends subtitle_ to method and renames files.\"\"\"\n        sub = SubtitleFile(\n            media_path=\"sub.ass\",\n            title=\"Anime\",\n            season=1,\n            episode=1,\n            language=\"zh\",\n            suffix=\".ass\",\n        )\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=sub):\n            renamer.client.torrents_rename_file.return_value = True\n            await renamer.rename_subtitles(\n                subtitle_list=[\"sub.ass\"],\n                torrent_name=\"[Sub] Anime - 01.mkv\",\n                bangumi_name=\"Anime\",\n                season=1,\n                method=\"pn\",\n                _hash=\"hash123\",\n            )\n\n        renamer.client.torrents_rename_file.assert_called_once()\n        call_args = renamer.client.torrents_rename_file.call_args\n        new_path = (\n            call_args[1][\"new_path\"]\n            if \"new_path\" in (call_args[1] or {})\n            else call_args[0][2]\n        )\n        assert \".zh.\" in new_path\n\n\n# ---------------------------------------------------------------------------\n# rename (full flow)\n# ---------------------------------------------------------------------------\n\n\nclass TestRenameFlow:\n    @pytest.fixture\n    def renamer(self, mock_qb_client):\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost:8080\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            mock_settings.bangumi_manage.remove_bad_torrent = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                r = Renamer()\n        r.client = mock_qb_client\n        return r\n\n    async def test_single_file_rename(self, renamer):\n        \"\"\"Full rename flow for a single-file torrent.\"\"\"\n        renamer.client.torrents_info.return_value = [\n            {\n                \"hash\": \"h1\",\n                \"name\": \"[Sub] Anime - 01.mkv\",\n                \"save_path\": \"/downloads/Bangumi/Anime (2024)/Season 1\",\n            }\n        ]\n        renamer.client.torrents_files.return_value = [{\"name\": \"[Sub] Anime - 01.mkv\"}]\n        renamer.client.torrents_rename_file.return_value = True\n\n        ep = EpisodeFile(\n            media_path=\"[Sub] Anime - 01.mkv\",\n            title=\"Anime\",\n            season=1,\n            episode=1,\n            suffix=\".mkv\",\n        )\n        with patch.object(renamer._parser, \"torrent_parser\", return_value=ep):\n            with patch(\"module.manager.renamer.settings\") as mock_settings:\n                mock_settings.bangumi_manage.rename_method = \"pn\"\n                mock_settings.bangumi_manage.remove_bad_torrent = False\n                with patch(\"module.downloader.path.settings\") as mock_path_settings:\n                    mock_path_settings.downloader.path = \"/downloads/Bangumi\"\n                    result = await renamer.rename()\n\n        assert len(result) == 1\n        assert result[0].episode == 1\n\n    async def test_collection_sets_category(self, renamer):\n        \"\"\"Multi-file torrent triggers collection rename and set_category.\"\"\"\n        renamer.client.torrents_info.return_value = [\n            {\n                \"hash\": \"h1\",\n                \"name\": \"Anime Collection\",\n                \"save_path\": \"/downloads/Bangumi/Anime (2024)/Season 1\",\n            }\n        ]\n        renamer.client.torrents_files.return_value = [\n            {\"name\": \"ep01.mkv\"},\n            {\"name\": \"ep02.mkv\"},\n            {\"name\": \"ep03.mkv\"},\n        ]\n        renamer.client.torrents_rename_file.return_value = True\n\n        def mock_parser(torrent_path, season, **kwargs):\n            ep_num = int(torrent_path.replace(\"ep\", \"\").replace(\".mkv\", \"\"))\n            return EpisodeFile(\n                media_path=torrent_path,\n                title=\"Anime\",\n                season=season,\n                episode=ep_num,\n                suffix=\".mkv\",\n            )\n\n        with patch.object(renamer._parser, \"torrent_parser\", side_effect=mock_parser):\n            with patch(\"module.manager.renamer.settings\") as mock_settings:\n                mock_settings.bangumi_manage.rename_method = \"pn\"\n                mock_settings.bangumi_manage.remove_bad_torrent = False\n                with patch(\"module.downloader.path.settings\") as mock_path_settings:\n                    mock_path_settings.downloader.path = \"/downloads/Bangumi\"\n                    await renamer.rename()\n\n        renamer.client.set_category.assert_called_once_with(\"h1\", \"BangumiCollection\")\n\n    async def test_no_media_files_no_crash(self, renamer):\n        \"\"\"When torrent has no media files, logs warning but doesn't crash.\"\"\"\n        renamer.client.torrents_info.return_value = [\n            {\n                \"hash\": \"h1\",\n                \"name\": \"No Media\",\n                \"save_path\": \"/downloads/Bangumi/Anime/Season 1\",\n            }\n        ]\n        renamer.client.torrents_files.return_value = [\n            {\"name\": \"readme.txt\"},\n            {\"name\": \"info.nfo\"},\n        ]\n        with patch(\"module.manager.renamer.settings\") as mock_settings:\n            mock_settings.bangumi_manage.rename_method = \"pn\"\n            with patch(\"module.downloader.path.settings\") as mock_path_settings:\n                mock_path_settings.downloader.path = \"/downloads/Bangumi\"\n                result = await renamer.rename()\n\n        assert result == []\n        renamer.client.torrents_rename_file.assert_not_called()\n\n\n# ---------------------------------------------------------------------------\n# _parse_bangumi_id_from_tags\n# ---------------------------------------------------------------------------\n\n\nclass TestParseBangumiIdFromTags:\n    \"\"\"Tests for Renamer._parse_bangumi_id_from_tags static method.\"\"\"\n\n    def test_single_ab_tag(self):\n        \"\"\"Parses 'ab:123' format correctly.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"ab:123\")\n        assert result == 123\n\n    def test_ab_tag_with_other_tags(self):\n        \"\"\"Extracts ab tag from comma-separated list.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"anime,ab:456,downloaded\")\n        assert result == 456\n\n    def test_ab_tag_with_spaces(self):\n        \"\"\"Handles whitespace around tags.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"  ab:789 , other_tag \")\n        assert result == 789\n\n    def test_empty_string(self):\n        \"\"\"Returns None for empty string.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"\")\n        assert result is None\n\n    def test_none_input(self):\n        \"\"\"Returns None for None input.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(None)\n        assert result is None\n\n    def test_no_ab_tag(self):\n        \"\"\"Returns None when no ab: tag present.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"anime,downloaded,HD\")\n        assert result is None\n\n    def test_invalid_ab_tag_non_numeric(self):\n        \"\"\"Returns None when ab: tag has non-numeric value.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"ab:not_a_number\")\n        assert result is None\n\n    def test_ab_tag_first_match(self):\n        \"\"\"Returns first ab: tag if multiple present.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"ab:111,ab:222\")\n        assert result == 111\n\n    def test_ab_tag_zero(self):\n        \"\"\"Handles ab:0 correctly.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"ab:0\")\n        assert result == 0\n\n    def test_ab_tag_large_number(self):\n        \"\"\"Handles large bangumi IDs.\"\"\"\n        result = Renamer._parse_bangumi_id_from_tags(\"ab:999999\")\n        assert result == 999999\n\n\n# ---------------------------------------------------------------------------\n# gen_path with offsets\n# ---------------------------------------------------------------------------\n\n\nclass TestGenPathWithOffsets:\n    \"\"\"Tests for gen_path with episode_offset and season_offset parameters.\"\"\"\n\n    def test_episode_offset_positive(self):\n        \"\"\"Episode offset adds to episode number.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", episode_offset=12)\n        assert \"E17\" in result  # 5 + 12 = 17\n\n    def test_episode_offset_negative(self):\n        \"\"\"Negative episode offset subtracts from episode number.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=15, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", episode_offset=-12)\n        assert \"E03\" in result  # 15 - 12 = 3\n\n    def test_episode_offset_negative_below_zero_ignored(self):\n        \"\"\"Negative offset that would go below 0 is ignored.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", episode_offset=-10)\n        assert \"E05\" in result  # Would be -5, so offset ignored\n\n    def test_episode_offset_producing_zero_ignored(self):\n        \"\"\"Offset that would make a positive episode become 0 is ignored (off-by-one guard).\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=12, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", episode_offset=-12)\n        assert \"E12\" in result  # Would be 0, so offset ignored\n\n    def test_episode_zero_preserved_without_offset(self):\n        \"\"\"Episode 0 (specials/OVAs) is preserved when no offset is applied.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=0, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", episode_offset=0)\n        assert \"E00\" in result  # Episode 0 is valid for specials\n\n    def test_season_offset_positive(self):\n        \"\"\"Season offset is now applied to folder path, not filename.\n\n        The season_offset parameter is kept for API compatibility but no longer\n        affects the filename. The folder path (generated by _gen_save_path)\n        already includes the offset, so the season from the folder is used directly.\n        \"\"\"\n        # Simulate file in Season 2 folder (offset already applied to folder)\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=2, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", season_offset=1)\n        assert (\n            \"S02\" in result\n        )  # Season from folder used directly, offset not re-applied\n\n    def test_season_offset_negative(self):\n        \"\"\"Season offset is now applied to folder path, not filename.\"\"\"\n        # Simulate file in Season 2 folder (offset already applied to folder)\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=2, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", season_offset=-1)\n        assert (\n            \"S02\" in result\n        )  # Season from folder used directly, offset not re-applied\n\n    def test_season_offset_negative_below_one_ignored(self):\n        \"\"\"Season offset parameter no longer affects filename.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=5, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"pn\", season_offset=-5)\n        assert \"S01\" in result  # Season from folder used directly\n\n    def test_both_offsets_combined(self):\n        \"\"\"Episode offset applied to filename, season offset applied to folder path.\n\n        The folder path already includes season_offset (Season 2 in this case).\n        Only episode_offset is applied during filename generation.\n        \"\"\"\n        # Simulate file in Season 2 folder (season_offset=1 applied to folder: 1+1=2)\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=2, episode=13, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(\n            ep, \"Bangumi\", method=\"pn\", episode_offset=-12, season_offset=1\n        )\n        assert \"S02E01\" in result  # Season 2 from folder, Episode 13-12=1\n\n    def test_offset_with_advance_method(self):\n        \"\"\"Offset works with advance rename method.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"old.mkv\", title=\"My Anime\", season=1, episode=25, suffix=\".mkv\"\n        )\n        result = Renamer.gen_path(\n            ep, \"Bangumi Name\", method=\"advance\", episode_offset=-12\n        )\n        assert result == \"Bangumi Name S01E13.mkv\"\n\n    def test_offset_with_subtitle_method(self):\n        \"\"\"Offset works with subtitle rename methods.\"\"\"\n        sub = SubtitleFile(\n            media_path=\"sub.ass\",\n            title=\"My Anime\",\n            season=1,\n            episode=25,\n            language=\"zh\",\n            suffix=\".ass\",\n        )\n        result = Renamer.gen_path(\n            sub, \"Bangumi\", method=\"subtitle_pn\", episode_offset=-12\n        )\n        assert \"E13\" in result  # 25 - 12 = 13\n\n    def test_offset_none_method_unchanged(self):\n        \"\"\"None method returns original path regardless of offset.\"\"\"\n        ep = EpisodeFile(\n            media_path=\"original/path/file.mkv\",\n            title=\"Test\",\n            season=1,\n            episode=1,\n            suffix=\".mkv\",\n        )\n        result = Renamer.gen_path(ep, \"Bangumi\", method=\"none\", episode_offset=100)\n        assert result == \"original/path/file.mkv\"\n\n\n# ---------------------------------------------------------------------------\n# _lookup_offsets\n# ---------------------------------------------------------------------------\n\n\nclass TestLookupOffsets:\n    \"\"\"Tests for Renamer._lookup_offsets method with multi-tier lookup.\"\"\"\n\n    @pytest.fixture\n    def renamer(self, mock_qb_client):\n        \"\"\"Create Renamer with mocked internals.\"\"\"\n        with patch(\"module.downloader.download_client.settings\") as mock_settings:\n            mock_settings.downloader.type = \"qbittorrent\"\n            mock_settings.downloader.host = \"localhost:8080\"\n            mock_settings.downloader.username = \"admin\"\n            mock_settings.downloader.password = \"admin\"\n            mock_settings.downloader.ssl = False\n            mock_settings.downloader.path = \"/downloads/Bangumi\"\n            mock_settings.bangumi_manage.group_tag = False\n            with patch(\n                \"module.downloader.download_client.DownloadClient._DownloadClient__getClient\",\n                return_value=mock_qb_client,\n            ):\n                r = Renamer()\n        r.client = mock_qb_client\n        return r\n\n    def test_lookup_by_qb_hash(self, renamer, db_session):\n        \"\"\"First priority: lookup by qb_hash in Torrent table.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi, Torrent\n\n        # Create bangumi with offsets\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Test Anime\",\n            year=\"2024\",\n            title_raw=\"test_raw\",\n            season=1,\n            episode_offset=-12,\n            season_offset=1,\n        )\n        bangumi_db.add(bangumi)\n\n        # Create torrent linked to bangumi\n        torrent_db = TorrentDatabase(db_session)\n        torrent = Torrent(\n            name=\"Test Torrent\",\n            url=\"https://example.com/torrent\",\n            bangumi_id=bangumi.id,\n            qb_hash=\"abc123hash\",\n        )\n        torrent_db.add(torrent)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"abc123hash\",\n                torrent_name=\"irrelevant\",\n                save_path=\"/irrelevant/path\",\n                tags=\"\",\n            )\n\n        assert episode_offset == -12\n        assert season_offset == 1\n\n    def test_lookup_by_tag_when_hash_not_found(self, renamer, db_session):\n        \"\"\"Second priority: lookup by ab:ID tag when qb_hash not found.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create bangumi with offsets\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Tagged Anime\",\n            year=\"2024\",\n            title_raw=\"tagged_raw\",\n            season=1,\n            episode_offset=5,\n            season_offset=0,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent_hash\",\n                torrent_name=\"irrelevant\",\n                save_path=\"/irrelevant/path\",\n                tags=f\"ab:{bangumi.id}\",\n            )\n\n        assert episode_offset == 5\n        assert season_offset == 0\n\n    def test_lookup_by_torrent_name(self, renamer, db_session):\n        \"\"\"Third priority: lookup by torrent name matching title_raw.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create bangumi with offsets\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Name Match Anime\",\n            year=\"2024\",\n            title_raw=\"[SubGroup] Name Match\",\n            season=1,\n            episode_offset=-6,\n            season_offset=2,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent_hash\",\n                torrent_name=\"[SubGroup] Name Match - 01 [1080p].mkv\",\n                save_path=\"/irrelevant/path\",\n                tags=\"\",\n            )\n\n        assert episode_offset == -6\n        assert season_offset == 2\n\n    def test_lookup_by_save_path_fallback(self, renamer, db_session):\n        \"\"\"Fourth priority: lookup by save_path when other methods fail.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create bangumi with offsets and save_path\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Path Match Anime\",\n            year=\"2024\",\n            title_raw=\"unique_raw_that_wont_match\",\n            season=1,\n            save_path=\"/downloads/Bangumi/Path Match Anime (2024)/Season 1\",\n            episode_offset=10,\n            season_offset=-1,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent_hash\",\n                torrent_name=\"completely_different_name.mkv\",\n                save_path=\"/downloads/Bangumi/Path Match Anime (2024)/Season 1\",\n                tags=\"\",\n            )\n\n        assert episode_offset == 10\n        assert season_offset == -1\n\n    def test_lookup_returns_zero_when_not_found(self, renamer, db_session):\n        \"\"\"Returns (0, 0) when no matching bangumi found.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent\",\n                torrent_name=\"no_match\",\n                save_path=\"/no/match/path\",\n                tags=\"\",\n            )\n\n        assert episode_offset == 0\n        assert season_offset == 0\n\n    def test_lookup_skips_deleted_bangumi(self, renamer, db_session):\n        \"\"\"Skips deleted bangumi even if hash/tag matches.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create deleted bangumi\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Deleted Anime\",\n            year=\"2024\",\n            title_raw=\"deleted_raw\",\n            season=1,\n            episode_offset=99,\n            season_offset=99,\n            deleted=True,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent\",\n                torrent_name=\"no_match\",\n                save_path=\"/no/match\",\n                tags=f\"ab:{bangumi.id}\",\n            )\n\n        # Should return (0, 0) because bangumi is deleted\n        assert episode_offset == 0\n        assert season_offset == 0\n\n    def test_lookup_handles_database_exception(self, renamer):\n        \"\"\"Returns (0, 0) when database throws exception.\"\"\"\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            MockDatabase.side_effect = Exception(\"Database connection failed\")\n\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"any\",\n                torrent_name=\"any\",\n                save_path=\"/any\",\n                tags=\"\",\n            )\n\n        assert episode_offset == 0\n        assert season_offset == 0\n\n    def test_lookup_by_save_path_with_trailing_slash(self, renamer, db_session):\n        \"\"\"Save path matching works with trailing slashes.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create bangumi with save_path WITHOUT trailing slash\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Trailing Slash Test\",\n            year=\"2024\",\n            title_raw=\"unique_raw_trailing\",\n            season=1,\n            save_path=\"/downloads/Bangumi/Test (2024)/Season 1\",\n            episode_offset=5,\n            season_offset=2,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            # Query WITH trailing slash - should still match\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent\",\n                torrent_name=\"no_match\",\n                save_path=\"/downloads/Bangumi/Test (2024)/Season 1/\",\n                tags=\"\",\n            )\n\n        assert episode_offset == 5\n        assert season_offset == 2\n\n    def test_lookup_by_save_path_with_backslashes(self, renamer, db_session):\n        \"\"\"Save path matching works with Windows-style backslashes.\"\"\"\n        from module.database.bangumi import BangumiDatabase\n        from module.database.torrent import TorrentDatabase\n        from module.models import Bangumi\n\n        # Create bangumi with forward slashes\n        bangumi_db = BangumiDatabase(db_session)\n        bangumi = Bangumi(\n            official_title=\"Backslash Test\",\n            year=\"2024\",\n            title_raw=\"unique_raw_backslash\",\n            season=1,\n            save_path=\"/downloads/Bangumi/Test (2024)/Season 1\",\n            episode_offset=3,\n            season_offset=1,\n        )\n        bangumi_db.add(bangumi)\n\n        with patch(\"module.manager.renamer.Database\") as MockDatabase:\n            mock_db = MagicMock()\n            mock_db.__enter__ = MagicMock(return_value=mock_db)\n            mock_db.__exit__ = MagicMock(return_value=False)\n            mock_db.torrent = TorrentDatabase(db_session)\n            mock_db.bangumi = BangumiDatabase(db_session)\n            MockDatabase.return_value = mock_db\n\n            # Query with backslashes - should still match after normalization\n            episode_offset, season_offset = renamer._lookup_offsets(\n                torrent_hash=\"nonexistent\",\n                torrent_name=\"no_match\",\n                save_path=\"\\\\downloads\\\\Bangumi\\\\Test (2024)\\\\Season 1\",\n                tags=\"\",\n            )\n\n        assert episode_offset == 3\n        assert season_offset == 1\n\n\nclass TestNormalizePath:\n    \"\"\"Tests for Renamer._normalize_path static method.\"\"\"\n\n    def test_empty_path(self):\n        from module.manager.renamer import Renamer\n\n        assert Renamer._normalize_path(\"\") == \"\"\n\n    def test_removes_trailing_slash(self):\n        from module.manager.renamer import Renamer\n\n        assert Renamer._normalize_path(\"/path/to/dir/\") == \"/path/to/dir\"\n\n    def test_removes_trailing_backslash(self):\n        from module.manager.renamer import Renamer\n\n        assert Renamer._normalize_path(\"C:\\\\path\\\\to\\\\dir\\\\\") == \"C:/path/to/dir\"\n\n    def test_converts_backslashes(self):\n        from module.manager.renamer import Renamer\n\n        assert Renamer._normalize_path(\"C:\\\\path\\\\to\\\\dir\") == \"C:/path/to/dir\"\n\n    def test_preserves_forward_slashes(self):\n        from module.manager.renamer import Renamer\n\n        assert Renamer._normalize_path(\"/path/to/dir\") == \"/path/to/dir\"\n"
  },
  {
    "path": "backend/src/test/test_rss_engine.py",
    "content": "import pytest\n\n# Skip the entire module as it requires network access and complex setup\npytestmark = pytest.mark.skip(reason=\"RSS engine tests require network access and complex async setup\")\n\n\n@pytest.mark.asyncio\nasync def test_rss_engine():\n    \"\"\"\n    This test requires:\n    1. Network access to mikanani.me\n    2. A properly configured async database\n    3. The RSS feed to be available\n\n    To run this test, you need to set up a proper test environment.\n    \"\"\"\n    pass\n"
  },
  {
    "path": "backend/src/test/test_rss_engine_new.py",
    "content": "\"\"\"Tests for RSS engine: pull_rss, match_torrent, refresh_rss, add_rss.\"\"\"\n\nimport pytest\nfrom unittest.mock import AsyncMock, patch\n\nfrom sqlmodel import Session\n\nfrom module.database.bangumi import BangumiDatabase, _invalidate_bangumi_cache\nfrom module.database.rss import RSSDatabase\nfrom module.database.torrent import TorrentDatabase\nfrom module.models import Bangumi, RSSItem, Torrent\nfrom module.rss.engine import RSSEngine\n\nfrom test.factories import make_bangumi, make_torrent, make_rss_item\n\n\n@pytest.fixture\ndef rss_engine(db_engine):\n    \"\"\"RSSEngine backed by in-memory database.\"\"\"\n    engine = RSSEngine(_engine=db_engine)\n    return engine\n\n\n@pytest.fixture(autouse=True)\ndef clear_bangumi_cache():\n    \"\"\"Invalidate bangumi cache before each test.\"\"\"\n    _invalidate_bangumi_cache()\n    yield\n    _invalidate_bangumi_cache()\n\n\n# ---------------------------------------------------------------------------\n# pull_rss\n# ---------------------------------------------------------------------------\n\n\nclass TestPullRss:\n    async def test_returns_only_new_torrents(self, rss_engine):\n        \"\"\"pull_rss filters out torrents already in the database.\"\"\"\n        rss_item = make_rss_item()\n        rss_engine.rss.add(rss_item)\n        rss_item = rss_engine.rss.search_id(1)\n\n        # Pre-insert one torrent into DB\n        existing = make_torrent(url=\"https://example.com/existing.torrent\", rss_id=1)\n        rss_engine.torrent.add(existing)\n\n        # Mock _get_torrents to return 3 torrents (1 existing + 2 new)\n        all_torrents = [\n            Torrent(name=\"existing\", url=\"https://example.com/existing.torrent\"),\n            Torrent(name=\"new1\", url=\"https://example.com/new1.torrent\"),\n            Torrent(name=\"new2\", url=\"https://example.com/new2.torrent\"),\n        ]\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = all_torrents\n            result = await rss_engine.pull_rss(rss_item)\n\n        assert len(result) == 2\n        assert all(t.url != \"https://example.com/existing.torrent\" for t in result)\n\n    async def test_all_existing_returns_empty(self, rss_engine):\n        \"\"\"When all torrents already exist, returns empty list.\"\"\"\n        rss_item = make_rss_item()\n        rss_engine.rss.add(rss_item)\n        rss_item = rss_engine.rss.search_id(1)\n\n        existing = make_torrent(url=\"https://example.com/only.torrent\", rss_id=1)\n        rss_engine.torrent.add(existing)\n\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = [\n                Torrent(name=\"only\", url=\"https://example.com/only.torrent\")\n            ]\n            result = await rss_engine.pull_rss(rss_item)\n\n        assert result == []\n\n    async def test_empty_feed_returns_empty(self, rss_engine):\n        \"\"\"When RSS feed has no torrents, returns empty list.\"\"\"\n        rss_item = make_rss_item()\n        rss_engine.rss.add(rss_item)\n        rss_item = rss_engine.rss.search_id(1)\n\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = []\n            result = await rss_engine.pull_rss(rss_item)\n\n        assert result == []\n\n\n# ---------------------------------------------------------------------------\n# match_torrent\n# ---------------------------------------------------------------------------\n\n\nclass TestMatchTorrent:\n    def test_matches_by_title_raw_substring(self, rss_engine):\n        \"\"\"match_torrent finds Bangumi when title_raw is a substring of torrent name.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"\")\n        rss_engine.bangumi.add(bangumi)\n\n        torrent = make_torrent(\n            name=\"[Lilith-Raws] Mushoku Tensei - 11 [1080p].mkv\"\n        )\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is not None\n        assert result.title_raw == \"Mushoku Tensei\"\n\n    def test_no_match_returns_none(self, rss_engine):\n        \"\"\"Returns None when no Bangumi matches the torrent name.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"\")\n        rss_engine.bangumi.add(bangumi)\n\n        torrent = make_torrent(name=\"[Sub] Completely Different Anime - 01.mkv\")\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is None\n\n    def test_filter_excludes_matching_torrent(self, rss_engine):\n        \"\"\"When torrent name matches the filter regex, returns None.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"720\")\n        rss_engine.bangumi.add(bangumi)\n\n        torrent = make_torrent(\n            name=\"[Sub] Mushoku Tensei - 01 [720p].mkv\"\n        )\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is None\n\n    def test_empty_filter_allows_match(self, rss_engine):\n        \"\"\"When filter is empty string, all matching torrents pass.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"\")\n        rss_engine.bangumi.add(bangumi)\n\n        torrent = make_torrent(\n            name=\"[Sub] Mushoku Tensei - 01 [720p].mkv\"\n        )\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is not None\n\n    def test_filter_case_insensitive(self, rss_engine):\n        \"\"\"Filter regex matching is case-insensitive.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"HEVC\")\n        rss_engine.bangumi.add(bangumi)\n\n        # Torrent has \"hevc\" in lowercase - should still be filtered\n        torrent = make_torrent(\n            name=\"[Sub] Mushoku Tensei - 01 [1080p][hevc].mkv\"\n        )\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is None\n\n    def test_deleted_bangumi_not_matched(self, rss_engine):\n        \"\"\"Bangumi with deleted=True should not match.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"\", deleted=True)\n        rss_engine.bangumi.add(bangumi)\n\n        torrent = make_torrent(name=\"[Sub] Mushoku Tensei - 01 [1080p].mkv\")\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is None\n\n    def test_comma_separated_filters(self, rss_engine):\n        \"\"\"Multiple comma-separated filters are joined with | for OR matching.\"\"\"\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"720,480\")\n        rss_engine.bangumi.add(bangumi)\n\n        # Matches one of the filters\n        torrent = make_torrent(name=\"[Sub] Mushoku Tensei - 01 [480p].mkv\")\n        result = rss_engine.match_torrent(torrent)\n\n        assert result is None\n\n        # Doesn't match any filter\n        torrent2 = make_torrent(name=\"[Sub] Mushoku Tensei - 01 [1080p].mkv\")\n        result2 = rss_engine.match_torrent(torrent2)\n\n        assert result2 is not None\n\n\n# ---------------------------------------------------------------------------\n# refresh_rss\n# ---------------------------------------------------------------------------\n\n\nclass TestRefreshRss:\n    async def test_downloads_matched_torrents(self, rss_engine, mock_qb_client):\n        \"\"\"refresh_rss downloads torrents that match a bangumi rule.\"\"\"\n        # Setup DB\n        rss_item = make_rss_item(enabled=True)\n        rss_engine.rss.add(rss_item)\n        bangumi = make_bangumi(title_raw=\"Mushoku Tensei\", filter=\"\")\n        rss_engine.bangumi.add(bangumi)\n\n        # Mock network\n        new_torrent = Torrent(\n            name=\"[Sub] Mushoku Tensei - 12 [1080p].mkv\",\n            url=\"https://example.com/ep12.torrent\",\n        )\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = [new_torrent]\n\n            # Create a mock client\n            client = AsyncMock()\n            client.add_torrent = AsyncMock(return_value=True)\n\n            await rss_engine.refresh_rss(client)\n\n        # Verify download was attempted\n        client.add_torrent.assert_called_once()\n        # Verify torrent stored in DB\n        all_torrents = rss_engine.torrent.search_all()\n        assert len(all_torrents) == 1\n        assert all_torrents[0].downloaded is True\n\n    async def test_unmatched_torrents_stored_not_downloaded(self, rss_engine):\n        \"\"\"Unmatched torrents are stored in DB but not marked downloaded.\"\"\"\n        rss_item = make_rss_item(enabled=True)\n        rss_engine.rss.add(rss_item)\n        # No bangumi in DB to match\n\n        unmatched = Torrent(\n            name=\"[Sub] Unknown Anime - 01 [1080p].mkv\",\n            url=\"https://example.com/unknown.torrent\",\n        )\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = [unmatched]\n            client = AsyncMock()\n            await rss_engine.refresh_rss(client)\n\n        client.add_torrent.assert_not_called()\n        all_torrents = rss_engine.torrent.search_all()\n        assert len(all_torrents) == 1\n        assert all_torrents[0].downloaded is False\n\n    async def test_refresh_specific_rss_id(self, rss_engine):\n        \"\"\"refresh_rss with rss_id only processes that specific feed.\"\"\"\n        rss1 = make_rss_item(name=\"Feed 1\", url=\"https://feed1.com/rss\")\n        rss2 = make_rss_item(name=\"Feed 2\", url=\"https://feed2.com/rss\")\n        rss_engine.rss.add(rss1)\n        rss_engine.rss.add(rss2)\n\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            mock_get.return_value = []\n            client = AsyncMock()\n            await rss_engine.refresh_rss(client, rss_id=2)\n\n        # Only called once (for rss_id=2)\n        mock_get.assert_called_once()\n\n    async def test_refresh_nonexistent_rss_id(self, rss_engine):\n        \"\"\"refresh_rss with non-existent rss_id does nothing.\"\"\"\n        with patch.object(RSSEngine, \"_get_torrents\", new_callable=AsyncMock) as mock_get:\n            client = AsyncMock()\n            await rss_engine.refresh_rss(client, rss_id=999)\n\n        mock_get.assert_not_called()\n\n\n# ---------------------------------------------------------------------------\n# add_rss\n# ---------------------------------------------------------------------------\n\n\nclass TestAddRss:\n    async def test_add_with_name(self, rss_engine):\n        \"\"\"add_rss with explicit name skips HTTP fetch and creates record.\"\"\"\n        result = await rss_engine.add_rss(\n            rss_link=\"https://mikanani.me/RSS/test\",\n            name=\"My Feed\",\n            aggregate=True,\n            parser=\"mikan\",\n        )\n\n        assert result.status is True\n        assert result.status_code == 200\n        rss = rss_engine.rss.search_id(1)\n        assert rss.name == \"My Feed\"\n        assert rss.url == \"https://mikanani.me/RSS/test\"\n\n    async def test_add_without_name_fetches_title(self, rss_engine):\n        \"\"\"add_rss without name calls get_rss_title to auto-discover title.\"\"\"\n        with patch(\n            \"module.rss.engine.RequestContent\"\n        ) as MockReq:\n            mock_instance = AsyncMock()\n            mock_instance.get_rss_title = AsyncMock(return_value=\"Fetched Title\")\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await rss_engine.add_rss(\n                rss_link=\"https://mikanani.me/RSS/auto\",\n                name=None,\n            )\n\n        assert result.status is True\n        rss = rss_engine.rss.search_id(1)\n        assert rss.name == \"Fetched Title\"\n\n    async def test_add_without_name_fetch_fails(self, rss_engine):\n        \"\"\"add_rss returns error when title fetch fails.\"\"\"\n        with patch(\n            \"module.rss.engine.RequestContent\"\n        ) as MockReq:\n            mock_instance = AsyncMock()\n            mock_instance.get_rss_title = AsyncMock(return_value=None)\n            MockReq.return_value.__aenter__ = AsyncMock(return_value=mock_instance)\n            MockReq.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            result = await rss_engine.add_rss(\n                rss_link=\"https://mikanani.me/RSS/broken\",\n                name=None,\n            )\n\n        assert result.status is False\n        assert result.status_code == 406\n\n    async def test_add_duplicate_url_fails(self, rss_engine):\n        \"\"\"add_rss with an already-existing URL returns failure.\"\"\"\n        await rss_engine.add_rss(\n            rss_link=\"https://mikanani.me/RSS/dup\",\n            name=\"First\",\n        )\n        result = await rss_engine.add_rss(\n            rss_link=\"https://mikanani.me/RSS/dup\",\n            name=\"Second\",\n        )\n\n        assert result.status is False\n        assert result.status_code == 406\n"
  },
  {
    "path": "backend/src/test/test_searcher.py",
    "content": "\"\"\"Tests for search providers: URL construction, keyword handling.\"\"\"\n\nimport pytest\nfrom unittest.mock import patch\n\nfrom module.models import Bangumi, RSSItem\nfrom module.searcher.provider import search_url\n\n\n# ---------------------------------------------------------------------------\n# search_url\n# ---------------------------------------------------------------------------\n\n\nclass TestSearchUrl:\n    @pytest.fixture(autouse=True)\n    def mock_search_config(self):\n        \"\"\"Ensure SEARCH_CONFIG has default providers.\"\"\"\n        config = {\n            \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n            \"nyaa\": \"https://nyaa.si/?page=rss&q=%s&c=0_0&f=0\",\n            \"dmhy\": \"http://dmhy.org/topics/rss/rss.xml?keyword=%s\",\n        }\n        with patch(\"module.searcher.provider.SEARCH_CONFIG\", config):\n            yield\n\n    def test_mikan_url(self):\n        \"\"\"Mikan search URL is constructed correctly.\"\"\"\n        result = search_url(\"mikan\", [\"Mushoku\", \"Tensei\"])\n        assert isinstance(result, RSSItem)\n        assert \"mikanani.me\" in result.url\n        assert \"Mushoku\" in result.url\n        assert \"Tensei\" in result.url\n        assert result.parser == \"mikan\"\n\n    def test_nyaa_url(self):\n        \"\"\"Nyaa search URL is constructed correctly.\"\"\"\n        result = search_url(\"nyaa\", [\"Mushoku\", \"Tensei\"])\n        assert \"nyaa.si\" in result.url\n        assert result.parser == \"tmdb\"\n\n    def test_dmhy_url(self):\n        \"\"\"DMHY search URL is constructed correctly.\"\"\"\n        result = search_url(\"dmhy\", [\"Mushoku\", \"Tensei\"])\n        assert \"dmhy.org\" in result.url\n        assert result.parser == \"tmdb\"\n\n    def test_unsupported_site_raises(self):\n        \"\"\"Unknown site raises ValueError.\"\"\"\n        with pytest.raises(ValueError, match=\"not supported\"):\n            search_url(\"unknown_site\", [\"test\"])\n\n    def test_keyword_sanitization(self):\n        \"\"\"Non-word characters are replaced with +.\"\"\"\n        result = search_url(\"mikan\", [\"Test Anime (2024)\"])\n        # Spaces and parentheses should be replaced with +\n        assert \"(\" not in result.url\n        assert \")\" not in result.url\n\n    def test_multiple_keywords_joined(self):\n        \"\"\"Multiple keywords are joined with +.\"\"\"\n        result = search_url(\"mikan\", [\"word1\", \"word2\", \"word3\"])\n        # All keywords should appear in the URL\n        url = result.url\n        assert \"word1\" in url\n        assert \"word2\" in url\n        assert \"word3\" in url\n\n    def test_aggregate_is_false(self):\n        \"\"\"Search RSS items have aggregate=False.\"\"\"\n        result = search_url(\"mikan\", [\"test\"])\n        assert result.aggregate is False\n\n\n# ---------------------------------------------------------------------------\n# SearchTorrent.special_url\n# ---------------------------------------------------------------------------\n\n\nclass TestSpecialUrl:\n    def test_uses_bangumi_fields(self):\n        \"\"\"special_url builds keywords from SEARCH_KEY fields of Bangumi.\"\"\"\n        from module.searcher.searcher import SearchTorrent, SEARCH_KEY\n        from test.factories import make_bangumi\n\n        bangumi = make_bangumi(\n            group_name=\"SubGroup\",\n            title_raw=\"Test Raw\",\n            season_raw=\"S2\",\n            dpi=\"1080p\",\n            source=\"Web\",\n            subtitle=\"CHT\",\n        )\n\n        with patch(\"module.searcher.provider.SEARCH_CONFIG\", {\n            \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n        }):\n            result = SearchTorrent.special_url(bangumi, \"mikan\")\n\n        assert isinstance(result, RSSItem)\n        # All non-None SEARCH_KEY fields should contribute to the URL\n        assert \"SubGroup\" in result.url\n        assert \"Test\" in result.url\n\n    def test_skips_none_fields(self):\n        \"\"\"special_url skips fields that are None.\"\"\"\n        from module.searcher.searcher import SearchTorrent\n        from test.factories import make_bangumi\n\n        bangumi = make_bangumi(\n            group_name=None,\n            title_raw=\"Test\",\n            season_raw=None,\n            dpi=None,\n            source=None,\n            subtitle=None,\n        )\n\n        with patch(\"module.searcher.provider.SEARCH_CONFIG\", {\n            \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n        }):\n            result = SearchTorrent.special_url(bangumi, \"mikan\")\n\n        # Only title_raw should be in the URL\n        assert \"Test\" in result.url\n"
  },
  {
    "path": "backend/src/test/test_setup.py",
    "content": "from pathlib import Path\nfrom unittest.mock import AsyncMock, patch\n\nimport pytest\nfrom fastapi.testclient import TestClient\n\nfrom module.api.setup import SENTINEL_PATH, router\n\n\n@pytest.fixture\ndef client():\n    \"\"\"Create a test client for the FastAPI app.\"\"\"\n    from fastapi import FastAPI\n\n    app = FastAPI()\n    app.include_router(router, prefix=\"/api/v1\")\n    return TestClient(app)\n\n\n@pytest.fixture\ndef mock_first_run():\n    \"\"\"Mock conditions for first run: sentinel doesn't exist, config matches defaults.\"\"\"\n    with (\n        patch(\"module.api.setup.SENTINEL_PATH\") as mock_sentinel,\n        patch(\"module.api.setup.settings\") as mock_settings,\n        patch(\"module.api.setup.Config\") as mock_config,\n    ):\n        mock_sentinel.exists.return_value = False\n        mock_settings.dict.return_value = {\"test\": \"default\"}\n        mock_config.return_value.dict.return_value = {\"test\": \"default\"}\n        yield\n\n\n@pytest.fixture\ndef mock_setup_complete():\n    \"\"\"Mock conditions for setup already complete: sentinel exists.\"\"\"\n    with patch(\"module.api.setup.SENTINEL_PATH\") as mock_sentinel:\n        mock_sentinel.exists.return_value = True\n        yield\n\n\nclass TestSetupStatus:\n    def test_status_first_run(self, client, mock_first_run):\n        response = client.get(\"/api/v1/setup/status\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"need_setup\"] is True\n        assert \"version\" in data\n\n    def test_status_setup_complete(self, client, mock_setup_complete):\n        response = client.get(\"/api/v1/setup/status\")\n        assert response.status_code == 200\n        data = response.json()\n        assert data[\"need_setup\"] is False\n\n    def test_status_config_changed(self, client):\n        \"\"\"When config differs from defaults, need_setup should be False.\"\"\"\n        with (\n            patch(\"module.api.setup.SENTINEL_PATH\") as mock_sentinel,\n            patch(\"module.api.setup.settings\") as mock_settings,\n            patch(\"module.api.setup.Config\") as mock_config,\n            patch(\n                \"module.api.setup.VERSION\", \"3.2.0\"\n            ),  # Non-dev version to test config check\n        ):\n            mock_sentinel.exists.return_value = False\n            mock_settings.dict.return_value = {\"test\": \"changed\"}\n            mock_config.return_value.dict.return_value = {\"test\": \"default\"}\n            response = client.get(\"/api/v1/setup/status\")\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"need_setup\"] is False\n\n\nclass TestSetupGuard:\n    def test_test_downloader_blocked_after_setup(self, client, mock_setup_complete):\n        response = client.post(\n            \"/api/v1/setup/test-downloader\",\n            json={\n                \"type\": \"qbittorrent\",\n                \"host\": \"localhost:8080\",\n                \"username\": \"admin\",\n                \"password\": \"admin\",\n                \"ssl\": False,\n            },\n        )\n        assert response.status_code == 403\n\n    def test_test_rss_blocked_after_setup(self, client, mock_setup_complete):\n        response = client.post(\n            \"/api/v1/setup/test-rss\",\n            json={\"url\": \"https://example.com/rss\"},\n        )\n        assert response.status_code == 403\n\n    def test_test_notification_blocked_after_setup(self, client, mock_setup_complete):\n        response = client.post(\n            \"/api/v1/setup/test-notification\",\n            json={\"type\": \"telegram\", \"token\": \"test\", \"chat_id\": \"123\"},\n        )\n        assert response.status_code == 403\n\n    def test_complete_blocked_after_setup(self, client, mock_setup_complete):\n        response = client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"testuser\",\n                \"password\": \"testpassword123\",\n                \"downloader_type\": \"qbittorrent\",\n                \"downloader_host\": \"localhost:8080\",\n                \"downloader_username\": \"admin\",\n                \"downloader_password\": \"admin\",\n                \"downloader_path\": \"/downloads\",\n                \"downloader_ssl\": False,\n                \"rss_url\": \"\",\n                \"rss_name\": \"\",\n                \"notification_enable\": False,\n                \"notification_type\": \"telegram\",\n                \"notification_token\": \"\",\n                \"notification_chat_id\": \"\",\n            },\n        )\n        assert response.status_code == 403\n\n\nclass TestTestDownloader:\n    def test_private_ip_accepted(self, client, mock_first_run):\n        \"\"\"Issue #1001: Private IPs must not be rejected for downloader test.\"\"\"\n        import httpx\n\n        with patch(\"module.api.setup.httpx.AsyncClient\") as mock_client_cls:\n            mock_instance = AsyncMock()\n            mock_instance.get.side_effect = httpx.ConnectError(\"refused\")\n            mock_client_cls.return_value.__aenter__ = AsyncMock(\n                return_value=mock_instance\n            )\n            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = client.post(\n                \"/api/v1/setup/test-downloader\",\n                json={\n                    \"type\": \"qbittorrent\",\n                    \"host\": \"192.168.1.100:8080\",\n                    \"username\": \"admin\",\n                    \"password\": \"admin\",\n                    \"ssl\": False,\n                },\n            )\n            # Should reach the connection attempt, not get blocked by IP validation\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"success\"] is False\n            assert \"connect\" in data[\"message_en\"].lower()\n\n    def test_loopback_ip_accepted(self, client, mock_first_run):\n        \"\"\"Issue #1001: Loopback IPs must not be rejected for downloader test.\"\"\"\n        import httpx\n\n        with patch(\"module.api.setup.httpx.AsyncClient\") as mock_client_cls:\n            mock_instance = AsyncMock()\n            mock_instance.get.side_effect = httpx.ConnectError(\"refused\")\n            mock_client_cls.return_value.__aenter__ = AsyncMock(\n                return_value=mock_instance\n            )\n            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = client.post(\n                \"/api/v1/setup/test-downloader\",\n                json={\n                    \"type\": \"qbittorrent\",\n                    \"host\": \"127.0.0.1:8080\",\n                    \"username\": \"admin\",\n                    \"password\": \"admin\",\n                    \"ssl\": False,\n                },\n            )\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"success\"] is False\n\n    def test_connection_timeout(self, client, mock_first_run):\n        import httpx\n\n        with patch(\"module.api.setup.httpx.AsyncClient\") as mock_client_cls:\n            mock_instance = AsyncMock()\n            mock_instance.get.side_effect = httpx.TimeoutException(\"timeout\")\n            mock_client_cls.return_value.__aenter__ = AsyncMock(\n                return_value=mock_instance\n            )\n            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = client.post(\n                \"/api/v1/setup/test-downloader\",\n                json={\n                    \"type\": \"qbittorrent\",\n                    \"host\": \"localhost:8080\",\n                    \"username\": \"admin\",\n                    \"password\": \"admin\",\n                    \"ssl\": False,\n                },\n            )\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"success\"] is False\n\n    def test_connection_refused(self, client, mock_first_run):\n        import httpx\n\n        with patch(\"module.api.setup.httpx.AsyncClient\") as mock_client_cls:\n            mock_instance = AsyncMock()\n            mock_instance.get.side_effect = httpx.ConnectError(\"refused\")\n            mock_client_cls.return_value.__aenter__ = AsyncMock(\n                return_value=mock_instance\n            )\n            mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)\n\n            response = client.post(\n                \"/api/v1/setup/test-downloader\",\n                json={\n                    \"type\": \"qbittorrent\",\n                    \"host\": \"localhost:8080\",\n                    \"username\": \"admin\",\n                    \"password\": \"admin\",\n                    \"ssl\": False,\n                },\n            )\n            assert response.status_code == 200\n            data = response.json()\n            assert data[\"success\"] is False\n\n\nclass TestTestRSS:\n    def test_invalid_url(self, client, mock_first_run):\n        with patch(\"module.api.setup._validate_url\"):\n            with patch(\"module.api.setup.RequestContent\") as mock_rc:\n                mock_instance = AsyncMock()\n                mock_instance.get_xml = AsyncMock(return_value=None)\n                mock_rc.return_value.__aenter__ = AsyncMock(return_value=mock_instance)\n                mock_rc.return_value.__aexit__ = AsyncMock(return_value=False)\n\n                response = client.post(\n                    \"/api/v1/setup/test-rss\",\n                    json={\"url\": \"https://invalid.example.com/rss\"},\n                )\n                assert response.status_code == 200\n                data = response.json()\n                assert data[\"success\"] is False\n\n\nclass TestRequestValidation:\n    def test_username_too_short(self, client, mock_first_run):\n        response = client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"ab\",\n                \"password\": \"testpassword123\",\n                \"downloader_type\": \"qbittorrent\",\n                \"downloader_host\": \"localhost:8080\",\n                \"downloader_username\": \"admin\",\n                \"downloader_password\": \"admin\",\n                \"downloader_path\": \"/downloads\",\n                \"downloader_ssl\": False,\n                \"rss_url\": \"\",\n                \"rss_name\": \"\",\n                \"notification_enable\": False,\n                \"notification_type\": \"telegram\",\n                \"notification_token\": \"\",\n                \"notification_chat_id\": \"\",\n            },\n        )\n        assert response.status_code == 422\n\n    def test_password_too_short(self, client, mock_first_run):\n        response = client.post(\n            \"/api/v1/setup/complete\",\n            json={\n                \"username\": \"testuser\",\n                \"password\": \"short\",\n                \"downloader_type\": \"qbittorrent\",\n                \"downloader_host\": \"localhost:8080\",\n                \"downloader_username\": \"admin\",\n                \"downloader_password\": \"admin\",\n                \"downloader_path\": \"/downloads\",\n                \"downloader_ssl\": False,\n                \"rss_url\": \"\",\n                \"rss_name\": \"\",\n                \"notification_enable\": False,\n                \"notification_type\": \"telegram\",\n                \"notification_token\": \"\",\n                \"notification_chat_id\": \"\",\n            },\n        )\n        assert response.status_code == 422\n\n\nclass TestSentinelPath:\n    def test_sentinel_path_is_in_config_dir(self):\n        assert str(SENTINEL_PATH) == \"config/.setup_complete\"\n        assert SENTINEL_PATH.parent == Path(\"config\")\n"
  },
  {
    "path": "backend/src/test/test_title_parser.py",
    "content": "import pytest\nfrom module.conf import settings\nfrom module.parser.title_parser import TitleParser\n\n\nclass TestTitleParser:\n    def test_parse_without_openai(self):\n        text = \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]\"\n        result = TitleParser.raw_parser(text)\n        assert result.group_name == \"梦蓝字幕组\"\n        assert result.title_raw == \"New Doraemon\"\n        assert result.dpi == \"1080P\"\n        assert result.season == 1\n        assert result.subtitle == \"GB_JP\"\n\n    @pytest.mark.skipif(\n        not settings.experimental_openai.enable,\n        reason=\"OpenAI is not enabled in settings\",\n    )\n    def test_parse_with_openai(self):\n        text = \"[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]\"\n        result = TitleParser.raw_parser(text)\n        assert result.group_name == \"梦蓝字幕组\"\n        assert result.title_raw == \"New Doraemon\"\n        assert result.dpi == \"1080P\"\n        assert result.season == 1\n        assert result.subtitle == \"GB_JP\"\n"
  },
  {
    "path": "backend/src/test/test_tmdb.py",
    "content": "from module.parser.analyser.tmdb_parser import tmdb_parser\n\n\nasync def test_tmdb_parser():\n    bangumi_title = \"海盗战记\"\n    bangumi_year = \"2019\"\n    bangumi_season = 2\n\n    tmdb_info = await tmdb_parser(bangumi_title, \"zh\", test=True)\n\n    assert tmdb_info.title == \"冰海战记\"\n    assert tmdb_info.year == bangumi_year\n    assert tmdb_info.last_season == bangumi_season\n"
  },
  {
    "path": "backend/src/test/test_torrent_parser.py",
    "content": "import sys\n\nimport pytest\nfrom module.parser.analyser import torrent_parser\nfrom module.parser.analyser.torrent_parser import get_path_basename\n\n\ndef test_torrent_parser():\n    file_name = \"[Lilith-Raws] Boku no Kokoro no Yabai Yatsu - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"Boku no Kokoro no Yabai Yatsu\"\n    assert bf.group == \"Lilith-Raws\"\n    assert bf.episode == 1\n    assert bf.season == 1\n\n    file_name = \"[Sakurato] Tonikaku Kawaii S2 [01][AVC-8bit 1080p AAC][CHS].mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"Tonikaku Kawaii\"\n    assert bf.group == \"Sakurato\"\n    assert bf.episode == 1\n    assert bf.season == 2\n\n    file_name = \"[SweetSub&LoliHouse] Heavenly Delusion - 01 [WebRip 1080p HEVC-10bit AAC ASSx2].mkv\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"Heavenly Delusion\"\n    assert bf.group == \"SweetSub&LoliHouse\"\n    assert bf.episode == 1\n    assert bf.season == 1\n\n    file_name = \"[SBSUB][CONAN][1082][V2][1080P][AVC_AAC][CHS_JP](C1E4E331).mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"CONAN\"\n    assert bf.group == \"SBSUB\"\n    assert bf.episode == 1082\n    assert bf.season == 1\n\n    file_name = \"海盗战记 (2019) S01E01.mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"海盗战记 (2019)\"\n    assert bf.episode == 1\n    assert bf.season == 1\n\n    file_name = \"海盗战记/海盗战记 S01E01.mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"海盗战记\"\n    assert bf.episode == 1\n    assert bf.season == 1\n\n    file_name = \"海盗战记 S01E01.zh-tw.ass\"\n    sf = torrent_parser(file_name, file_type=\"subtitle\")\n    assert sf.title == \"海盗战记\"\n    assert sf.episode == 1\n    assert sf.season == 1\n    assert sf.language == \"zh-tw\"\n\n    file_name = \"海盗战记 S01E01.SC.ass\"\n    sf = torrent_parser(file_name, file_type=\"subtitle\")\n    assert sf.title == \"海盗战记\"\n    assert sf.season == 1\n    assert sf.episode == 1\n    assert sf.language == \"zh\"\n\n    file_name = \"水星的魔女(2022) S00E19.mp4\"\n    bf = torrent_parser(file_name, season=0)\n    assert bf.title == \"水星的魔女(2022)\"\n    assert bf.season == 0\n    assert bf.episode == 19\n\n    file_name = \"【失眠搬运组】放学后失眠的你-Kimi wa Houkago Insomnia - 06 [bilibili - 1080p AVC1 CHS-JP].mp4\"\n    bf = torrent_parser(file_name, season=1)\n    assert bf.title == \"放学后失眠的你-Kimi wa Houkago Insomnia\"\n    assert bf.season == 1\n    assert bf.episode == 6\n\n    file_name = \"不时用俄语小声说真心话的邻桌艾莉同学 S01E02.mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"不时用俄语小声说真心话的邻桌艾莉同学\"\n    assert bf.season == 1\n    assert bf.episode == 2\n\n    file_name = \"[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].mp4\"\n    bf = torrent_parser(file_name, season=3)\n    assert bf.title == \"關於我轉生變成史萊姆這檔事 第三季\"\n    assert bf.season == 3\n    assert bf.episode == 48.5\n\n    file_name = \"[ANi] 關於我轉生變成史萊姆這檔事 第三季 - 48.5 [1080P][Baha][WEB-DL][AAC AVC][CHT].srt\"\n    sf = torrent_parser(file_name, season=3, file_type=\"subtitle\")\n    assert sf.title == \"關於我轉生變成史萊姆這檔事 第三季\"\n    assert sf.episode == 48.5\n    assert sf.season == 3\n    assert sf.language == \"zh-tw\"\n\n    # EP number format — RULES[4] EP? branch (untested without dash separator)\n    file_name = \"[Ohys-Raws] Kamen Rider Gaim EP33 (TV-Asahi 1280x720 x264 AAC).mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"Kamen Rider Gaim\"\n    assert bf.group == \"Ohys-Raws\"\n    assert bf.episode == 33\n    assert bf.season == 1\n\n    # \"tc\" language code → zh-tw (SUBTITLE_LANG[\"zh-tw\"] includes \"tc\")\n    file_name = \"葬送的芙莉莲 S01E05.tc.ass\"\n    sf = torrent_parser(file_name, file_type=\"subtitle\")\n    assert sf.title == \"葬送的芙莉莲\"\n    assert sf.season == 1\n    assert sf.episode == 5\n    assert sf.language == \"zh-tw\"\n\n    # Subtitle with no language code → SubtitleFile.language requires str; raises ValidationError\n    file_name = \"Dungeon Meshi S01E01.srt\"\n    with pytest.raises(Exception):\n        torrent_parser(file_name, file_type=\"subtitle\")\n\n    # Full absolute multi-level path — get_path_basename strips all directories\n    file_name = \"/downloads/Bangumi/葬送的芙莉莲 (2023)/Season 1/葬送的芙莉莲 S01E05.mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"葬送的芙莉莲\"\n    assert bf.season == 1\n    assert bf.episode == 5\n\n    # Version suffix [NNvN] — RULES[1] (?:v\\d{1,2})? branch (v2 supported but untested)\n    file_name = \"[Sakurato] Kaguya-sama wa Kokurasetai [12v2][AVC-8bit 1080p AAC][CHS].mp4\"\n    bf = torrent_parser(file_name)\n    assert bf.title == \"Kaguya-sama wa Kokurasetai\"\n    assert bf.group == \"Sakurato\"\n    assert bf.episode == 12\n    assert bf.season == 1\n\n\nclass TestGetPathBasename:\n    def test_regular_path(self):\n        assert get_path_basename(\"/path/to/file.txt\") == \"file.txt\"\n\n    def test_empty_path(self):\n        assert get_path_basename(\"\") == \"\"\n\n    def test_path_with_trailing_slash(self):\n        assert get_path_basename(\"/path/to/folder/\") == \"folder\"\n\n    @pytest.mark.skipif(not sys.platform.startswith(\"win\"), reason=\"Windows specific\")\n    def test_windows_path(self):\n        assert get_path_basename(\"C:\\\\path\\\\to\\\\file.txt\") == \"file.txt\"\n"
  },
  {
    "path": "backend/src/test_passkey_server.py",
    "content": "\"\"\"\nMinimal test server for passkey development.\nUses the real auth and passkey API routes without the downloader check.\nRun with: uv run python test_passkey_server.py\n\"\"\"\nimport uvicorn\nfrom fastapi import FastAPI\n\nfrom module.api.auth import router as auth_router\nfrom module.api.passkey import router as passkey_router\nfrom module.database import Database\nfrom module.update.startup import first_run\n\napp = FastAPI(title=\"AutoBangumi Passkey Test\")\n\n# Mount real routers\napp.include_router(auth_router, prefix=\"/api/v1\")\napp.include_router(passkey_router, prefix=\"/api/v1\")\n\n\n@app.on_event(\"startup\")\nasync def startup():\n    \"\"\"Create tables and default user (no downloader check)\"\"\"\n    with Database() as db:\n        db.create_table()\n        db.user.add_default_user()\n\n\n@app.get(\"/\")\ndef index():\n    return {\"status\": \"Passkey test server running\"}\n\n\nif __name__ == \"__main__\":\n    uvicorn.run(app, host=\"0.0.0.0\", port=7892)\n"
  },
  {
    "path": "docs/.vitepress/config.ts",
    "content": "import { defineConfig } from 'vitepress'\n\nconst version = `v3.2`\n\n// Shared configuration\nconst sharedConfig = {\n  head: [\n    ['link', { rel: 'icon', type: 'image/svg+xml', href: '/light-logo.svg' }],\n    ['meta', { property: 'og:image', content: '/social.png' }],\n    ['meta', { property: 'og:site_name', content: 'AutoBangumi' }],\n    ['meta', { property: 'og:url', content: 'https://www.autobangumi.org' }],\n    ['script', { async: '', src: 'https://www.googletagmanager.com/gtag/js?id=G-3Z8W6WMN7J' }],\n    ['script', {}, `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config','G-3Z8W6WMN7J');`],\n  ] as any,\n\n  themeConfig: {\n    logo: {\n      dark: '/dark-logo.svg',\n      light: '/light-logo.svg',\n    },\n    socialLinks: [\n      { icon: 'github', link: 'https://github.com/EstrellaXD/Auto_Bangumi' },\n      {\n        icon: {\n          svg: '<svg xmlns=\"http://www.w3.org/2000/svg\" role=\"img\" viewBox=\"0 0 24 24\"><title>Telegram</title><path d=\"M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z\"/></svg>',\n        },\n        link: 'https://t.me/autobangumi',\n      },\n    ],\n    search: {\n      provider: 'local',\n    },\n  },\n}\n\n// Chinese sidebar (default)\nconst zhSidebar = [\n  {\n    items: [\n      { text: '关于', link: '/home/' },\n      { text: '快速开始', link: '/deploy/quick-start' },\n      { text: '工作原理', link: '/home/pipline' },\n    ],\n  },\n  {\n    text: '部署',\n    items: [\n      { text: 'Docker CLI', link: '/deploy/docker-cli' },\n      { text: 'Docker Compose', link: '/deploy/docker-compose' },\n      { text: '群晖 NAS (DSM)', link: '/deploy/dsm' },\n      { text: '本地部署', link: '/deploy/local' },\n    ],\n  },\n  {\n    text: '配置',\n    items: [\n      { text: 'RSS 订阅设置', link: '/config/rss' },\n      { text: '程序设置', link: '/config/program' },\n      { text: '下载器设置', link: '/config/downloader' },\n      { text: '解析器设置', link: '/config/parser' },\n      { text: '通知设置', link: '/config/notifier' },\n      { text: '番剧管理设置', link: '/config/manager' },\n      { text: '代理设置', link: '/config/proxy' },\n      { text: '实验性功能', link: '/config/experimental' },\n    ],\n  },\n  {\n    text: '功能',\n    items: [\n      { text: 'RSS 管理', link: '/feature/rss' },\n      { text: '番剧管理', link: '/feature/bangumi' },\n      { text: '日历视图', link: '/feature/calendar' },\n      { text: '文件重命名', link: '/feature/rename' },\n      { text: '种子搜索', link: '/feature/search' },\n    ],\n  },\n  {\n    text: '常见问题',\n    items: [\n      { text: '常见问题', link: '/faq/' },\n      { text: '故障排除', link: '/faq/troubleshooting' },\n      { text: '网络问题', link: '/faq/network' },\n    ],\n  },\n  {\n    text: 'API 参考',\n    items: [\n      { text: 'REST API', link: '/api/' },\n    ],\n  },\n  {\n    text: '更新日志',\n    items: [\n      { text: '3.2 版本说明', link: '/changelog/3.2' },\n      { text: '3.1 版本说明', link: '/changelog/3.1' },\n      { text: '3.0 版本说明', link: '/changelog/3.0' },\n      { text: '2.6 版本说明', link: '/changelog/2.6' },\n    ],\n  },\n  {\n    text: '开发者指南',\n    items: [\n      { text: '参与贡献', link: '/dev/' },\n    ],\n  },\n]\n\n// Japanese sidebar\nconst jaSidebar = [\n  {\n    items: [\n      { text: '概要', link: '/ja/home/' },\n      { text: 'クイックスタート', link: '/ja/deploy/quick-start' },\n      { text: '仕組み', link: '/ja/home/pipline' },\n    ],\n  },\n  {\n    text: 'デプロイ',\n    items: [\n      { text: 'Docker CLI', link: '/ja/deploy/docker-cli' },\n      { text: 'Docker Compose', link: '/ja/deploy/docker-compose' },\n      { text: 'Synology NAS (DSM)', link: '/ja/deploy/dsm' },\n      { text: 'ローカルデプロイ', link: '/ja/deploy/local' },\n    ],\n  },\n  {\n    text: '設定',\n    items: [\n      { text: 'RSS購読設定', link: '/ja/config/rss' },\n      { text: 'プログラム設定', link: '/ja/config/program' },\n      { text: 'ダウンローダー設定', link: '/ja/config/downloader' },\n      { text: 'パーサー設定', link: '/ja/config/parser' },\n      { text: '通知設定', link: '/ja/config/notifier' },\n      { text: 'アニメ管理設定', link: '/ja/config/manager' },\n      { text: 'プロキシ設定', link: '/ja/config/proxy' },\n      { text: '実験的機能', link: '/ja/config/experimental' },\n    ],\n  },\n  {\n    text: '機能',\n    items: [\n      { text: 'RSS管理', link: '/ja/feature/rss' },\n      { text: 'アニメ管理', link: '/ja/feature/bangumi' },\n      { text: 'カレンダー表示', link: '/ja/feature/calendar' },\n      { text: 'ファイルリネーム', link: '/ja/feature/rename' },\n      { text: 'トレント検索', link: '/ja/feature/search' },\n    ],\n  },\n  {\n    text: 'FAQ',\n    items: [\n      { text: 'よくある質問', link: '/ja/faq/' },\n      { text: 'トラブルシューティング', link: '/ja/faq/troubleshooting' },\n      { text: 'ネットワーク問題', link: '/ja/faq/network' },\n    ],\n  },\n  {\n    text: 'APIリファレンス',\n    items: [\n      { text: 'REST API', link: '/ja/api/' },\n    ],\n  },\n  {\n    text: '更新履歴',\n    items: [\n      { text: '3.2 リリースノート', link: '/ja/changelog/3.2' },\n      { text: '3.1 リリースノート', link: '/ja/changelog/3.1' },\n      { text: '3.0 リリースノート', link: '/ja/changelog/3.0' },\n      { text: '2.6 リリースノート', link: '/ja/changelog/2.6' },\n    ],\n  },\n  {\n    text: '開発者ガイド',\n    items: [\n      { text: 'コントリビュート', link: '/ja/dev/' },\n    ],\n  },\n]\n\n// English sidebar\nconst enSidebar = [\n  {\n    items: [\n      { text: 'About', link: '/en/home/' },\n      { text: 'Quick Start', link: '/en/deploy/quick-start' },\n      { text: 'How It Works', link: '/en/home/pipline' },\n    ],\n  },\n  {\n    text: 'Deployment',\n    items: [\n      { text: 'Docker CLI', link: '/en/deploy/docker-cli' },\n      { text: 'Docker Compose', link: '/en/deploy/docker-compose' },\n      { text: 'Synology NAS (DSM)', link: '/en/deploy/dsm' },\n      { text: 'Local Deployment', link: '/en/deploy/local' },\n    ],\n  },\n  {\n    text: 'Configuration',\n    items: [\n      { text: 'RSS Feed Setup', link: '/en/config/rss' },\n      { text: 'Program Settings', link: '/en/config/program' },\n      { text: 'Downloader Settings', link: '/en/config/downloader' },\n      { text: 'Parser Settings', link: '/en/config/parser' },\n      { text: 'Notification Settings', link: '/en/config/notifier' },\n      { text: 'Bangumi Manager', link: '/en/config/manager' },\n      { text: 'Proxy Settings', link: '/en/config/proxy' },\n      { text: 'Experimental Features', link: '/en/config/experimental' },\n    ],\n  },\n  {\n    text: 'Features',\n    items: [\n      { text: 'RSS Management', link: '/en/feature/rss' },\n      { text: 'Bangumi Management', link: '/en/feature/bangumi' },\n      { text: 'Calendar View', link: '/en/feature/calendar' },\n      { text: 'File Renaming', link: '/en/feature/rename' },\n      { text: 'Torrent Search', link: '/en/feature/search' },\n    ],\n  },\n  {\n    text: 'FAQ',\n    items: [\n      { text: 'Common Questions', link: '/en/faq/' },\n      { text: 'Troubleshooting', link: '/en/faq/troubleshooting' },\n      { text: 'Network Issues', link: '/en/faq/network' },\n    ],\n  },\n  {\n    text: 'API Reference',\n    items: [\n      { text: 'REST API', link: '/en/api/' },\n    ],\n  },\n  {\n    text: 'Changelog',\n    items: [\n      { text: '3.2 Release Notes', link: '/en/changelog/3.2' },\n      { text: '3.1 Release Notes', link: '/en/changelog/3.1' },\n      { text: '3.0 Release Notes', link: '/en/changelog/3.0' },\n      { text: '2.6 Release Notes', link: '/en/changelog/2.6' },\n    ],\n  },\n  {\n    text: 'Developer Guide',\n    items: [\n      { text: 'Contributing', link: '/en/dev/' },\n    ],\n  },\n]\n\nexport default defineConfig({\n  title: 'AutoBangumi',\n  description: '基于 RSS 的全自动番剧下载与整理工具',\n  ...sharedConfig,\n\n  locales: {\n    root: {\n      label: '简体中文',\n      lang: 'zh-CN',\n      themeConfig: {\n        nav: [\n          { text: '关于', link: '/home/' },\n          { text: '快速开始', link: '/deploy/quick-start' },\n          { text: '常见问题', link: '/faq/' },\n          { text: 'API', link: '/api/' },\n        ],\n        sidebar: zhSidebar,\n        editLink: {\n          pattern: 'https://github.com/EstrellaXD/Auto_Bangumi/edit/main/docs/:path',\n          text: '在 GitHub 上编辑此页',\n        },\n        footer: {\n          message: `AutoBangumi 基于 MIT 许可证发布。(最新版本: ${version})`,\n          copyright: 'Copyright © 2021-present @EstrellaXD & AutoBangumi Contributors',\n        },\n        docFooter: {\n          prev: '上一页',\n          next: '下一页',\n        },\n        outline: {\n          label: '目录',\n        },\n        lastUpdated: {\n          text: '最后更新于',\n        },\n        returnToTopLabel: '返回顶部',\n        sidebarMenuLabel: '菜单',\n        darkModeSwitchLabel: '主题',\n        langMenuLabel: '切换语言',\n      },\n    },\n    en: {\n      label: 'English',\n      lang: 'en-US',\n      link: '/en/',\n      themeConfig: {\n        nav: [\n          { text: 'About', link: '/en/home/' },\n          { text: 'Quick Start', link: '/en/deploy/quick-start' },\n          { text: 'FAQ', link: '/en/faq/' },\n          { text: 'API', link: '/en/api/' },\n        ],\n        sidebar: enSidebar,\n        editLink: {\n          pattern: 'https://github.com/EstrellaXD/Auto_Bangumi/edit/main/docs/:path',\n          text: 'Edit this page on GitHub',\n        },\n        footer: {\n          message: `AutoBangumi is released under the MIT License. (latest: ${version})`,\n          copyright: 'Copyright © 2021-present @EstrellaXD & AutoBangumi Contributors',\n        },\n      },\n    },\n    ja: {\n      label: '日本語',\n      lang: 'ja-JP',\n      link: '/ja/',\n      themeConfig: {\n        nav: [\n          { text: '概要', link: '/ja/home/' },\n          { text: 'クイックスタート', link: '/ja/deploy/quick-start' },\n          { text: 'FAQ', link: '/ja/faq/' },\n          { text: 'API', link: '/ja/api/' },\n        ],\n        sidebar: jaSidebar,\n        editLink: {\n          pattern: 'https://github.com/EstrellaXD/Auto_Bangumi/edit/main/docs/:path',\n          text: 'GitHubでこのページを編集',\n        },\n        footer: {\n          message: `AutoBangumiはMITライセンスの下で公開されています。(最新版: ${version})`,\n          copyright: 'Copyright © 2021-present @EstrellaXD & AutoBangumi Contributors',\n        },\n        docFooter: {\n          prev: '前のページ',\n          next: '次のページ',\n        },\n        outline: {\n          label: '目次',\n        },\n        lastUpdated: {\n          text: '最終更新',\n        },\n        returnToTopLabel: 'トップに戻る',\n        sidebarMenuLabel: 'メニュー',\n        darkModeSwitchLabel: 'テーマ',\n        langMenuLabel: '言語を切り替え',\n      },\n    },\n  },\n})\n"
  },
  {
    "path": "docs/.vitepress/theme/components/HomePreviewWebUI.vue",
    "content": "<script setup lang=\"ts\">\n</script>\n\n<template>\n  <div class=\"container\">\n    <img\n      src=\"/image/preview/window.png\"\n      alt=\"AutoBangumi WebUI Preview\"\n      class=\"webui-preview\"\n      data-zoomable\n    />\n  </div>\n</template>\n\n<style scoped>\n\n.container {\n  display: flex;\n  justify-content: center;\n  margin: 0 auto;\n  padding-inline: 24px;\n  padding-block: 60px 20px;\n  /**\n   * same as VPHero.vue\n   * https://github.com/vuejs/vitepress/blob/v1.0.0-beta.5/src/client/theme-default/components/VPHero.vue#L83\n   */\n  max-width: 1280px;\n}\n\n@media (min-width: 640px) {\n  .container {\n    padding-inline: 48px;\n  }\n}\n\n@media (min-width: 960px) {\n  .container {\n    padding-inline: 64px;\n  }\n}\n\n.webui-preview {\n  width: 100%;\n  height: auto;\n  border-radius: 10px;\n\n  box-shadow: none;\n}\n\n</style>"
  },
  {
    "path": "docs/.vitepress/theme/index.ts",
    "content": "import { h, onMounted, watch, nextTick } from 'vue'\nimport Theme from 'vitepress/theme'\nimport { useRoute } from 'vitepress'\nimport mediumZoom from 'medium-zoom'\nimport HomePreviewWebUI from './components/HomePreviewWebUI.vue'\n\nimport './style.css'\n\nexport default {\n  extends: Theme,\n  Layout: () => {\n    return h(Theme.Layout, null, {\n      'home-features-after': () => h(HomePreviewWebUI),\n    })\n  },\n  setup() {\n    const route = useRoute()\n    const initZoom = () => {\n      mediumZoom('[data-zoomable]', { background: 'var(--vp-c-bg)' })\n    }\n\n    onMounted(() => {\n      initZoom()\n    })\n\n    watch(\n      () => route.path,\n      () => nextTick(initZoom),\n    )\n  },\n}\n"
  },
  {
    "path": "docs/.vitepress/theme/style.css",
    "content": "/**\n * Customize default theme styling by overriding CSS variables:\n * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css\n */\n\n/**\n * Colors\n * -------------------------------------------------------------------------- */\n\n:root {\n  --vp-c-brand-1: #7B65D6;\n  --vp-c-brand-2: #7162AE;\n  --vp-c-brand-3: #8D7FC2;\n  --vp-c-brand-soft: rgba(100, 108, 255, 0.08);\n}\n\n/**\n * Component: Button\n * -------------------------------------------------------------------------- */\n\n:root {\n  --vp-button-brand-border: transparent;\n  --vp-button-brand-text: var(--vp-c-white);\n  --vp-button-brand-bg: var(--vp-c-brand-1);\n  --vp-button-brand-hover-border: transparent;\n  --vp-button-brand-hover-text: var(--vp-c-white);\n  --vp-button-brand-hover-bg: var(--vp-c-brand-2);\n  --vp-button-brand-active-border: transparent;\n  --vp-button-brand-active-text: var(--vp-c-white);\n  --vp-button-brand-active-bg: var(--vp-c-brand-1);\n}\n\n/**\n * Component: Home\n * -------------------------------------------------------------------------- */\n\n:root {\n  --vp-home-hero-name-color: transparent;\n  --vp-home-hero-name-background: -webkit-linear-gradient(\n    120deg,\n    #b42ff1 30%,\n    #441bd9\n  );\n\n  --vp-home-hero-image-background-image: linear-gradient(\n    -45deg,\n    #b42ff1bb 50%,\n    #4794ffbb 50%\n  );\n  --vp-home-hero-image-filter: blur(44px);\n}\n\n@media (min-width: 640px) {\n  :root {\n    --vp-home-hero-image-filter: blur(56px);\n  }\n}\n\n@media (min-width: 960px) {\n  :root {\n    --vp-home-hero-image-filter: blur(72px);\n  }\n}\n\n/**\n * Component: Custom Block\n * -------------------------------------------------------------------------- */\n\n:root {\n  --vp-custom-block-tip-border: transparent;\n  --vp-custom-block-tip-text: var(--vp-c-text-1);\n  --vp-custom-block-tip-bg: var(--vp-c-brand-soft);\n  --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft);\n}\n\n/**\n * Component: medium-zoom\n * -------------------------------------------------------------------------- */\n\n.medium-zoom--opened .medium-zoom-overlay {\n  z-index: 20;\n}\n\n.medium-zoom--opened .medium-zoom-image {\n  z-index: 21;\n}\n\n.vp-doc .ab-shadow-card {\n  box-shadow: 0 10px 30px -10px rgba(0,0,0,0.2),\n              0 0 2px rgba(0,0,0,0.2),\n              0 20px 30px -20px rgba(0,0,0,0.4);\n  border-radius: 10px;\n}\n"
  },
  {
    "path": "docs/api/index.md",
    "content": "# REST API 参考\n\nAutoBangumi 在 `/api/v1` 路径下提供 REST API。除登录和设置向导外，所有端点都需要 JWT 认证。\n\n**基础 URL：** `http://your-host:7892/api/v1`\n\n**认证：** 将 JWT 令牌作为 cookie 或 `Authorization: Bearer <token>` 请求头传入。\n\n**交互式文档：** 在开发模式下运行时，可在 `http://your-host:7892/docs` 访问 Swagger UI。\n\n---\n\n## 认证\n\n### 登录\n\n```\nPOST /auth/login\n```\n\n使用用户名和密码进行认证。\n\n**请求体：**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n**响应：** 设置包含 JWT 令牌的认证 cookie。\n\n### 刷新令牌\n\n```\nGET /auth/refresh_token\n```\n\n刷新当前的认证令牌。\n\n### 登出\n\n```\nGET /auth/logout\n```\n\n清除认证 cookie 并登出。\n\n### 更新凭据\n\n```\nPOST /auth/update\n```\n\n更新用户名和/或密码。\n\n**请求体：**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n---\n\n## Passkey / WebAuthn <Badge type=\"tip\" text=\"v3.2+\" />\n\n使用 WebAuthn/FIDO2 Passkey 进行无密码认证。\n\n### 注册 Passkey\n\n```\nPOST /passkey/register/options\n```\n\n获取 WebAuthn 注册选项（质询、依赖方信息）。\n\n```\nPOST /passkey/register/verify\n```\n\n验证并保存来自浏览器的 Passkey 注册响应。\n\n### 使用 Passkey 认证\n\n```\nPOST /passkey/auth/options\n```\n\n获取 WebAuthn 认证质询选项。\n\n```\nPOST /passkey/auth/verify\n```\n\n验证 Passkey 认证响应并签发 JWT 令牌。\n\n### 管理 Passkey\n\n```\nGET /passkey/list\n```\n\n列出当前用户所有已注册的 Passkey。\n\n```\nPOST /passkey/delete\n```\n\n通过凭据 ID 删除已注册的 Passkey。\n\n---\n\n## 配置\n\n### 获取配置\n\n```\nGET /config/get\n```\n\n获取当前应用程序配置。\n\n**响应：** 完整配置对象，包括 `program`、`downloader`、`rss_parser`、`bangumi_manager`、`notification`、`proxy` 和 `experimental_openai` 部分。\n\n### 更新配置\n\n```\nPATCH /config/update\n```\n\n部分更新应用程序配置。只需包含您想要更改的字段。\n\n**请求体：** 部分配置对象。\n\n---\n\n## 番剧（动画规则）\n\n### 列出所有番剧\n\n```\nGET /bangumi/get/all\n```\n\n获取所有动画下载规则。\n\n### 通过 ID 获取番剧\n\n```\nGET /bangumi/get/{bangumi_id}\n```\n\n通过 ID 获取特定动画规则。\n\n### 更新番剧\n\n```\nPATCH /bangumi/update/{bangumi_id}\n```\n\n更新动画规则的元数据（标题、季度、集数偏移等）。\n\n### 删除番剧\n\n```\nDELETE /bangumi/delete/{bangumi_id}\n```\n\n删除单个动画规则及其关联的种子。\n\n```\nDELETE /bangumi/delete/many/\n```\n\n批量删除多个动画规则。\n\n**请求体：**\n```json\n{\n  \"bangumi_ids\": [1, 2, 3]\n}\n```\n\n### 禁用/启用番剧\n\n```\nDELETE /bangumi/disable/{bangumi_id}\n```\n\n禁用动画规则（保留文件，停止下载）。\n\n```\nDELETE /bangumi/disable/many/\n```\n\n批量禁用多个动画规则。\n\n```\nGET /bangumi/enable/{bangumi_id}\n```\n\n重新启用之前禁用的动画规则。\n\n### 刷新海报\n\n```\nGET /bangumi/refresh/poster/all\n```\n\n从 TMDB 刷新所有动画的海报图片。\n\n```\nGET /bangumi/refresh/poster/{bangumi_id}\n```\n\n刷新特定动画的海报图片。\n\n### 日历\n\n```\nGET /bangumi/refresh/calendar\n```\n\n从 Bangumi.tv 刷新动画放送日历数据。\n\n### 重置全部\n\n```\nGET /bangumi/reset/all\n```\n\n删除所有动画规则。请谨慎使用。\n\n---\n\n## RSS 订阅源\n\n### 列出所有订阅源\n\n```\nGET /rss\n```\n\n获取所有已配置的 RSS 订阅源。\n\n### 添加订阅源\n\n```\nPOST /rss/add\n```\n\n添加新的 RSS 订阅源。\n\n**请求体：**\n```json\n{\n  \"url\": \"string\",\n  \"aggregate\": true,\n  \"parser\": \"mikan\"\n}\n```\n\n### 启用/禁用订阅源\n\n```\nPOST /rss/enable/many\n```\n\n启用多个 RSS 订阅源。\n\n```\nPATCH /rss/disable/{rss_id}\n```\n\n禁用单个 RSS 订阅源。\n\n```\nPOST /rss/disable/many\n```\n\n批量禁用多个 RSS 订阅源。\n\n### 删除订阅源\n\n```\nDELETE /rss/delete/{rss_id}\n```\n\n删除单个 RSS 订阅源。\n\n```\nPOST /rss/delete/many\n```\n\n批量删除多个 RSS 订阅源。\n\n### 更新订阅源\n\n```\nPATCH /rss/update/{rss_id}\n```\n\n更新 RSS 订阅源的配置。\n\n### 刷新订阅源\n\n```\nGET /rss/refresh/all\n```\n\n手动触发刷新所有 RSS 订阅源。\n\n```\nGET /rss/refresh/{rss_id}\n```\n\n刷新特定的 RSS 订阅源。\n\n### 获取订阅源中的种子\n\n```\nGET /rss/torrent/{rss_id}\n```\n\n获取从特定 RSS 订阅源解析的种子列表。\n\n### 分析与订阅\n\n```\nPOST /rss/analysis\n```\n\n分析 RSS URL 并提取动画元数据，但不订阅。\n\n**请求体：**\n```json\n{\n  \"url\": \"string\"\n}\n```\n\n```\nPOST /rss/collect\n```\n\n从 RSS 订阅源下载所有剧集（用于已完结动画）。\n\n```\nPOST /rss/subscribe\n```\n\n订阅 RSS 订阅源以自动下载连载中的动画。\n\n---\n\n## 搜索\n\n### 搜索番剧（Server-Sent Events）\n\n```\nGET /search/bangumi?keyword={keyword}&provider={provider}\n```\n\n搜索动画种子。以 Server-Sent Events (SSE) 流的形式返回结果，提供实时更新。\n\n**查询参数：**\n- `keyword` — 搜索关键词\n- `provider` — 搜索提供者（例如 `mikan`、`nyaa`、`dmhy`）\n\n**响应：** 包含解析后搜索结果的 SSE 流。\n\n### 列出搜索提供者\n\n```\nGET /search/provider\n```\n\n获取可用搜索提供者的列表。\n\n---\n\n## 程序控制\n\n### 获取状态\n\n```\nGET /status\n```\n\n获取程序状态，包括版本、运行状态和 first_run 标志。\n\n**响应：**\n```json\n{\n  \"status\": \"running\",\n  \"version\": \"3.2.0\",\n  \"first_run\": false\n}\n```\n\n### 启动程序\n\n```\nGET /start\n```\n\n启动主程序（RSS 检查、下载、重命名）。\n\n### 重启程序\n\n```\nGET /restart\n```\n\n重启主程序。\n\n### 停止程序\n\n```\nGET /stop\n```\n\n停止主程序（WebUI 仍可访问）。\n\n### 关闭\n\n```\nGET /shutdown\n```\n\n关闭整个应用程序（重启 Docker 容器）。\n\n### 检查下载器\n\n```\nGET /check/downloader\n```\n\n测试与已配置的下载器（qBittorrent）的连接。\n\n---\n\n## 下载器管理 <Badge type=\"tip\" text=\"v3.2+\" />\n\n直接从 AutoBangumi 管理下载器中的种子。\n\n### 列出种子\n\n```\nGET /downloader/torrents\n```\n\n获取 Bangumi 分类中的所有种子。\n\n### 暂停种子\n\n```\nPOST /downloader/torrents/pause\n```\n\n通过哈希暂停种子。\n\n**请求体：**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### 恢复种子\n\n```\nPOST /downloader/torrents/resume\n```\n\n通过哈希恢复已暂停的种子。\n\n**请求体：**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### 删除种子\n\n```\nPOST /downloader/torrents/delete\n```\n\n删除种子，可选择是否删除文件。\n\n**请求体：**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"],\n  \"delete_files\": false\n}\n```\n\n---\n\n## 设置向导 <Badge type=\"tip\" text=\"v3.2+\" />\n\n这些端点仅在首次运行设置期间可用（设置完成前）。它们**不**需要认证。设置完成后，所有端点返回 `403 Forbidden`。\n\n### 检查设置状态\n\n```\nGET /setup/status\n```\n\n检查是否需要设置向导（首次运行）。\n\n**响应：**\n```json\n{\n  \"need_setup\": true\n}\n```\n\n### 测试下载器连接\n\n```\nPOST /setup/test-downloader\n```\n\n使用提供的凭据测试与下载器的连接。\n\n**请求体：**\n```json\n{\n  \"type\": \"qbittorrent\",\n  \"host\": \"172.17.0.1:8080\",\n  \"username\": \"admin\",\n  \"password\": \"adminadmin\",\n  \"ssl\": false\n}\n```\n\n### 测试 RSS 订阅源\n\n```\nPOST /setup/test-rss\n```\n\n验证 RSS 订阅源 URL 是否可访问和可解析。\n\n**请求体：**\n```json\n{\n  \"url\": \"https://mikanime.tv/RSS/MyBangumi?token=xxx\"\n}\n```\n\n### 测试通知\n\n```\nPOST /setup/test-notification\n```\n\n使用提供的设置发送测试通知。\n\n**请求体：**\n```json\n{\n  \"type\": \"telegram\",\n  \"token\": \"bot_token\",\n  \"chat_id\": \"chat_id\"\n}\n```\n\n### 完成设置\n\n```\nPOST /setup/complete\n```\n\n保存所有配置并将设置标记为完成。创建标记文件 `config/.setup_complete`。\n\n**请求体：** 完整配置对象。\n\n---\n\n## 日志\n\n### 获取日志\n\n```\nGET /log\n```\n\n获取完整的应用程序日志文件。\n\n### 清除日志\n\n```\nGET /log/clear\n```\n\n清除日志文件。\n\n---\n\n## 响应格式\n\n所有 API 响应遵循统一格式：\n\n```json\n{\n  \"msg_en\": \"Success message in English\",\n  \"msg_zh\": \"成功消息（中文）\",\n  \"status\": true\n}\n```\n\n错误响应包含适当的 HTTP 状态码（400、401、403、404、500）以及中英文错误消息。\n"
  },
  {
    "path": "docs/changelog/2.6.md",
    "content": "# [2.6] 发布说明\n\n## 从旧版本升级说明\n\n从 2.6 版本开始，AutoBangumi (AB) 配置已从环境变量迁移到 `config.json`。升级前请注意以下事项。\n\n### 环境变量迁移\n\n升级到 2.6 后首次启动时，旧环境变量会自动转换为 `config.json`。生成的 `config.json` 放置在 `/app/config` 文件夹中。\n一旦您映射了 `/app/config` 文件夹，旧环境变量将不再影响 AB 的运行。您可以删除 `config.json` 以从环境变量重新生成。\n\n### 容器卷映射\n\n2.6 版本之后，需要映射以下文件夹：\n\n- `/app/config`：配置文件夹，包含 `config.json`\n- `/app/data`：数据文件夹，包含 `bangumi.json` 等\n\n### 数据文件\n\n由于重大更新，我们不建议使用旧数据文件。AB 会在 `/app/data` 中自动生成新的 `bangumi.json`。\n\n不用担心 — QB 不会重新下载之前已下载的动画。\n\n### 后续配置更改\n\nAB 现在可以直接在 WebUI 中编辑配置。编辑后重启容器即可生效。\n\n## 如何升级\n\n### Docker Compose\n\n您可以使用现有的 docker-compose.yml 文件进行升级：\n\n```bash\ndocker compose stop autobangumi\ndocker compose pull autobangumi\n```\n\n然后修改 docker-compose.yml 添加卷映射：\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  autobangumi:\n    image: estrellaxd/auto_bangumi:latest\n    container_name: autobangumi\n    restart: unless-stopped\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=Asia/Shanghai\n    volumes:\n      - /path/to/config:/app/config\n      - /path/to/data:/app/data\n    networks:\n      - bridge\n    dns:\n      - 8.8.8.8\n```\n\n然后启动 AB：\n\n```bash\ndocker compose up -d autobangumi\n```\n\n### Portainer\n\n在 Portainer 中，修改卷映射并点击 `Recreate` 完成升级。\n\n### 升级导致问题怎么办\n\n由于配置可能各不相同，升级可能会导致程序失败。删除所有之前的数据和生成的配置文件，然后重启容器并在 WebUI 中重新配置。\n\n\n## 新功能\n\n### 配置方式变更\n\nv2.6 之后，程序配置已从 Docker 环境变量迁移到 `config.json`。\n新版 WebUI 还提供了基于 Web 的配置编辑器。访问 AB URL 并在侧边栏找到 `设置` 来修改配置。编辑后重启容器。\n\n### 自定义反向代理 URL 和 AB 作为代理中继\n\n为了处理 [Mikan Project](https://mikanani.me) 无法访问的情况，AB 提供了三种方法：\n\n1. HTTP 和 SOCKS 代理\n\n    此功能在旧版本中就存在。升级到 2.6 后，只需在 WebUI 中检查代理配置即可正常访问 Mikan Project。\n\n    但是，qBittorrent 仍然无法直接访问 Mikan 的 RSS 和种子 URL，因此您也需要在 qBittorrent 中添加代理。详见 #198。\n\n2. 自定义反向代理 URL\n\n    2.6 版本添加了 `custom_url` 选项用于自定义反向代理 URL。\n    将其设置为您正确配置的反向代理 URL。AB 将使用此自定义 URL 访问 Mikan Project，QB 可以正常下载。\n\n3. AB 作为代理中继\n\n    在 AB 中配置代理后，AB 可以作为本地代理中继（目前仅用于 RSS 相关功能）。\n    将 `custom_url` 设置为 `http://abhost:abport`，其中 `abhost` 是 AB 的 IP，`abport` 是 AB 的端口。\n    AB 将把自己的地址推送给 qBittorrent，qBittorrent 将使用 AB 作为代理来访问 Mikan Project。\n\n    注意：如果您没有使用 Nginx 或类似工具为 AB 设置反向代理，请包含 `http://` 以确保正常运行。\n\n**重要说明**\n\n如果 AB 和 QB 在同一个容器中，不要使用 `127.0.0.1` 或 `localhost`，它们无法通过这种方式通信。\n如果在同一网络中，使用容器名称寻址，例如 `http://autobangumi:7892`。\n\n您也可以使用 Docker 网关地址，例如 `http://172.17.0.1:7892`。\n\n如果在不同主机上，使用主机的 IP 地址。\n\n### 合集和文件夹重命名\n\nAB 现在可以重命名合集和文件夹中的文件，将媒体文件移回根目录。\n请注意，AB 仍然依赖保存路径来确定季度和集数信息，因此请按照 AB 的标准放置合集文件。\n\n**2.6.4** 版本之后，AB 可以重命名文件夹中的字幕（功能仍在完善中）。合集和字幕默认使用 `pn` 格式重命名；调整选项尚未提供。\n\n**标准路径**\n\n```\n/downloads/Bangumi/Title/Season 1/xxx\n```\n\n### 推送通知\n\nAB 现在可以通过 `Telegram` 和 `ServerChan` 发送重命名完成通知。\n\n在 WebUI 中启用推送通知并填写所需参数。\n\n- Telegram 需要 Bot Token 和 Chat ID。获取方法请参考各种教程。\n- ServerChan 需要 Token。获取方法请参考各种教程。\n"
  },
  {
    "path": "docs/changelog/3.0.md",
    "content": "# [3.0] 发布说明\n\n### 新版 WebUI\n\n- 登录功能 — AB 现在支持用户名/密码认证。部分操作需要登录。\n- 新海报墙\n- 番剧管理功能\n  - 编辑动画季度信息和名称。更改会自动更新**下载规则** / **已下载文件路径**并触发重命名。\n  - 新链接解析器 — 解析链接后，您可以手动调整下载信息、选择下载季度或添加自动下载规则。\n  - 删除动画 — 一键删除动画及其种子文件。\n  - 每个动画的自定义下载规则，独立于全局规则。\n- 新配置界面，更易于配置应用程序规则\n- 添加首次启动引导的初始化页面\n- 下载器连接检查器，检查 qBittorrent 连接性\n- RSS URL 验证器，检查 RSS 订阅源是否有效\n- 添加程序管理按钮，可在 WebUI 中启动/停止程序和重启容器\n\n### 解析器\n\n- 新解析器，支持不同源类型以获取官方标题和海报 URL\n- 支持更换 RSS 订阅源而无需重新生成数据库\n\n### 通知模块\n\n- 添加 `Bark` 通知模块\n- 新通知格式 — 现在可以向 Telegram 推送海报、动画名称和更新的集数编号\n\n### 数据迁移\n\n- 从旧版本升级时自动进行数据迁移\n- 迁移的数据也会自动匹配海报\n\n## 修复\n\n- 修复 Windows 路径可能导致的重命名 bug\n\n## 变更\n\n- 数据存储从 `json` 迁移到 `sqlite`\n- 从多进程迁移到多线程\n  - 重构主程序\n  - 改进启动/关闭时间\n- 重构解析器模块\n- 重构重命名模块\n  - 暂时移除 `normal` 模式\n- 添加 `ghcr.io` 镜像仓库\n"
  },
  {
    "path": "docs/changelog/3.1.md",
    "content": "# [3.1] - 2023-08\n\n- 合并后端和前端仓库，优化项目目录结构\n- 优化版本发布工作流程\n- Wiki 迁移到 VitePress：https://autobangumi.org\n\n## 后端\n\n### 功能\n\n- 添加 `RSS Engine` 模块 — AB 现在可以独立更新和管理 RSS 订阅，并将种子发送到下载器\n  - 支持多个聚合 RSS 订阅源，通过 RSS Engine 模块管理\n  - 下载去重 — 重复订阅的种子不会重复下载\n  - 添加 RSS 订阅手动刷新 API\n  - 添加 RSS 订阅管理 API\n- 添加 `Search Engine` 模块 — 按关键词搜索种子并将结果解析为收集或订阅任务\n  - 插件式搜索引擎，支持 `mikan`、`dmhy` 和 `nyaa`\n- 添加字幕组特定规则，用于单独配置各字幕组\n- 添加 IPv6 监听支持（在环境变量中设置 `IPV6=1`）\n- 添加批量操作 API，用于批量管理规则和 RSS 订阅\n\n### 变更\n\n- 数据库结构改为使用 `sqlmodel` 进行数据库管理\n- 添加版本管理，实现软件数据无缝更新\n- 统一 API 格式\n- 添加 API 响应语言选项\n- 添加数据库 mock 测试\n- 代码优化\n\n### Bug 修复\n\n- 修复各种小问题\n- 引入了一些重大问题\n\n## 前端\n\n### 功能\n\n- 添加 `i18n` 支持 — 目前支持 `zh-CN` 和 `en-US`\n- 添加 PWA 支持\n- 添加 RSS 管理页面\n- 添加搜索顶栏\n\n### 变更\n\n- 调整各种 UI 细节\n"
  },
  {
    "path": "docs/changelog/3.2-zh.md",
    "content": "# [3.2] - 2025-01\n\n## 后端\n\n### 新功能\n\n- 新增 WebAuthn Passkey 无密码登录支持\n  - 支持注册、验证和管理 Passkey 凭证\n  - 多设备凭证备份检测（iCloud 钥匙串等）\n  - 克隆攻击防护（sign_count 验证）\n  - 认证策略模式统一密码和 Passkey 登录接口\n  - 支持无用户名登录（可发现凭证/resident keys）\n- 新增季度/集数偏移自动检测\n  - 通过分析 TMDB 剧集播出日期检测「虚拟季度」（如芙莉莲第一季分两部分播出）\n  - 当播出间隔超过 6 个月时自动识别为不同部分\n  - 自动计算集数偏移量（如 RSS 显示 S2E1 → TMDB S1E29）\n  - 后台扫描线程自动检测已有订阅的偏移问题\n  - 新增 API 端点：`POST /bangumi/detect-offset`、`PATCH /bangumi/dismiss-review/{id}`\n- 新增番剧归档功能\n  - 支持手动归档/取消归档\n  - 已完结番剧自动归档\n  - 新增 API 端点：`PATCH /bangumi/archive/{id}`、`PATCH /bangumi/unarchive/{id}`、`GET /bangumi/refresh/metadata`\n- 新增搜索源配置 API\n  - `GET /search/provider/config` - 获取搜索源配置\n  - `PUT /search/provider/config` - 更新搜索源配置\n- 偏移检查面板新增建议值显示（解析的季度/集数和建议的偏移量）\n- 修复季度偏移未应用到下载文件夹路径的问题\n  - 设置季度偏移后，qBittorrent 保存路径会自动更新（如 `Season 2` → `Season 1`）\n  - RSS 规则的保存路径也会同步更新\n- 优化集数偏移建议逻辑\n  - 简单季度不匹配时不再建议集数偏移（仅虚拟季度需要）\n  - 改进提示信息，明确说明是否需要调整集数\n- 新增 RSS 连接状态追踪\n  - 每次刷新后记录 `connection_status`（healthy/error）、`last_checked_at` 和 `last_error`\n- 新增首次运行设置向导\n  - 7 步引导配置：账户、下载器、RSS 源、媒体路径、通知\n  - 下载器连接测试、RSS 源验证\n  - 可选步骤可跳过，稍后在设置中配置\n  - 哨兵文件机制（`config/.setup_complete`）防止重复触发\n  - 未认证的设置 API（仅首次运行可用，完成后返回 403）\n- 新增日历视图，集成 Bangumi.tv 放送时间表\n- 新增下载器 API 和管理界面\n- 全面异步迁移\n  - 数据库层异步支持（aiosqlite），Passkey 操作非阻塞 I/O\n  - `UserDatabase` 支持同步/异步双模式，向后兼容\n  - `Database` 上下文管理器支持 `with`（同步）和 `async with`（异步）\n  - RSS 引擎、下载器、检查器、解析器全面转换为异步\n  - 网络请求从 `requests` 迁移到 `httpx`（AsyncClient）\n- 后端迁移到 `uv` 包管理器（pyproject.toml + uv.lock）\n- 服务器启动使用后台任务避免阻塞（修复 #891、#929）\n- 数据库迁移自动填充 NULL 值为模型默认值\n- 数据库新增 `needs_review` 和 `needs_review_reason` 字段用于偏移检测\n\n### 性能优化\n\n- 共享 HTTP 客户端连接池，复用 TCP/SSL 连接\n- RSS 刷新改为并发拉取（`asyncio.gather`），多源场景下速度提升约 10 倍\n- 种子文件下载改为并发获取，下载多个种子时速度提升约 5 倍\n- 重命名模块并发获取文件列表，速度提升约 20 倍\n- 通知发送改为并发执行，移除 2 秒硬编码延迟\n- 新增 TMDB 和 Mikan 解析结果缓存，避免重复 API 调用\n- 为 `Torrent.url`、`Torrent.rss_id`、`Bangumi.title_raw`、`Bangumi.deleted`、`RSSItem.url` 添加数据库索引\n- RSS 批量启用/禁用改为单次事务操作，替代逐条提交\n- 预编译正则表达式（种子名解析规则、过滤器匹配）\n- `SeasonCollector` 在循环外创建，复用单次认证\n- RSS 解析去重从 O(n²) 列表查找改为 O(1) 集合查找\n- `Episode`/`SeasonInfo` 数据类添加 `__slots__`，减少内存占用\n\n### 变更\n\n- 升级 WebAuthn 依赖到 py_webauthn 2.7.0\n- `_get_webauthn_from_request` 优先使用浏览器 Origin 头，修复跨端口开发环境验证问题\n- `auth_user` 和 `update_user_info` 转换为异步函数\n- `TitleParser.tmdb_parser` 转换为异步函数\n- `RSSEngine` 方法全面异步化（`pull_rss`、`refresh_rss`、`download_bangumi`、`add_rss`）\n- `Checker.check_downloader` 转换为异步函数\n- `ProgramStatus` 从 threading 迁移到 asyncio（Event、Lock）\n\n### 问题修复\n\n- 修复下载器连接检查添加最大重试次数\n- 修复添加种子时的网络瞬态错误，添加重试逻辑\n- 修复搜索和订阅流程中的多个问题\n- 改进种子获取可靠性和错误处理\n- 修复 `aaguid` 类型错误（py_webauthn 2.7.0 中现为 `str`，不再是 `bytes`）\n- 修复缺失的 `credential_backup_eligible` 字段（替换为 `credential_device_type`）\n- 修复 `verify_authentication_response` 接收无效 `credential_id` 参数导致 TypeError\n- 修复程序启动阻塞服务器（修复 #891、#929、#886、#917、#946）\n- 修复搜索接口导出与组件期望不匹配\n- 修复海报端点路径检查错误拦截所有请求（修复 #933、#934）\n- 修复 OpenAI 解析器安全问题\n- 修复数据库测试使用异步会话与同步代码不匹配\n- 修复从 3.1.x 升级到 3.2 时配置字段冲突导致设置丢失（修复 #956）\n  - `program.sleep_time` / `program.times` 自动迁移到 `rss_time` / `rename_time`\n  - 移除废弃的 `rss_parser` 字段（`type`、`custom_url`、`token`、`enable_tmdb`）\n  - 修复 `ENV_TO_ATTR` 环境变量映射指向不存在的模型字段\n  - 修复 `DEFAULT_SETTINGS` 与当前配置模型不一致\n- 修复版本升级迁移逻辑错误（所有升级都调用 3.0→3.1 迁移）\n  - 新增基于源版本的版本感知迁移分发\n  - 新增 `from_31_to_32()` 迁移函数处理数据库架构变更\n\n## 前端\n\n### 新功能\n\n- 全新 UI 设计系统重构\n  - 统一设计令牌（颜色、字体、间距、阴影、动画）\n  - 支持深色/浅色主题切换\n  - 全面的无障碍支持（ARIA、键盘导航、焦点管理）\n  - 移动端响应式布局\n- 新增首次运行设置向导页面\n  - 多步骤向导组件（进度条 + 步骤导航）\n  - 路由守卫自动检测并重定向到设置页面\n  - 下载器/RSS/通知连接测试反馈\n  - 中英文 i18n 支持\n- 新增 Passkey 管理面板（设置页面）\n  - WebAuthn 浏览器支持检测\n  - 自动识别设备名称\n  - Passkey 列表显示和删除\n- 登录页面新增 Passkey 指纹登录按钮（支持无用户名登录）\n- 新增日历视图页面\n- 新增下载器管理页面\n- 番剧卡片新增悬停遮罩层（显示标题和标签）\n- 新增 `resolvePosterUrl` 工具函数，统一处理外部 URL 和本地路径（修复 #934）\n- 重新设计搜索面板，新增模态框和过滤系统\n  - 新增筛选区域，支持按字幕组、分辨率、字幕类型、季度分类筛选\n  - 多选筛选器，智能禁用不兼容的选项（灰色显示）\n  - 结果项标签改为非点击式彩色药丸样式\n  - 统一标签样式（药丸形状、12px 字体）\n  - 标签值标准化（分辨率：FHD/HD/4K，字幕：简/繁/双语）\n  - 筛选分类和结果变体支持展开/收起\n  - 海报高度自动匹配 4 行变体项（168px）\n  - 点击弹窗外部自动关闭\n- 重新设计登录面板，采用现代毛玻璃风格\n- 日志页面新增日志级别过滤功能\n- 重新设计 LLM 设置面板（修复 #938）\n- 重新设计设置、下载器、播放器、日志页面样式\n- 新增搜索源设置面板\n  - 支持查看、添加、编辑、删除搜索源\n  - 默认搜索源（mikan、nyaa、dmhy）不可删除\n  - URL 模板验证，确保包含 `%s` 占位符\n- 新增 iOS 风格通知角标系统\n  - 黄色角标 + 紫色边框显示需要检查的订阅\n  - 支持组合显示（如 `! | 2` 表示有警告且有多个规则）\n  - 卡片黄色发光动画提示需要注意\n- 编辑弹窗新增警告横幅，支持一键自动检测和忽略\n- 规则选择弹窗高亮显示有警告的规则\n- 日历页面番剧分组：相同番剧的多个规则合并显示，点击可选择具体规则\n- 番剧列表页新增可折叠的「已归档」分区\n- 番剧列表页新增骨架屏加载动画\n- 规则编辑器新增剧集偏移字段和「自动检测」按钮\n- 首页空状态新增「添加 RSS 订阅」按钮，引导新用户快速上手\n- 日历页面海报图片添加懒加载，提升性能\n- 日历页面「未知播出日」独立为单独区块，优化视觉节奏\n- RSS 管理页面新增连接状态标签：健康时显示绿色「已连接」，错误时显示红色「错误」并通过 tooltip 显示错误详情\n- 全新移动端优先响应式设计\n  - 三层断点系统：移动端（<640px）、平板（640-1023px）、桌面端（≥1024px）\n  - 移动端底部导航栏（带图标和文字标签）\n  - 平板迷你侧边栏（56px 图标导航）\n  - 移动端弹窗自动切换为底部抽屉\n  - 支持下拉刷新\n  - 支持水平滑动容器\n  - 移动端卡片列表替代数据表格（RSS 页面）\n  - CSS Grid 响应式布局（番剧卡片网格）\n  - 移动端表单标签垂直堆叠，输入框全宽\n  - 触摸目标最小 44px，符合无障碍标准\n  - 安全区域支持（刘海屏设备）\n  - `100dvh` 动态视口高度（修复移动端浏览器地址栏问题）\n  - `viewport-fit=cover` 全屏设备支持\n\n### 新组件\n\n- `ab-bottom-sheet` — 触摸驱动的底部抽屉组件（拖动关闭、最大高度限制）\n- `ab-adaptive-modal` — 自适应弹窗（移动端底部抽屉 / 桌面端居中对话框）\n- `ab-pull-refresh` — 下拉刷新包装组件\n- `ab-swipe-container` — 水平滑动容器（CSS scroll-snap）\n- `ab-data-list` — 移动端友好的卡片列表（替代 NDataTable）\n- `ab-mobile-nav` — 增强版底部导航栏（图标 + 标签 + 激活指示器）\n- `useSafeArea` — 安全区域组合式函数\n\n### 性能优化\n\n- 下载器 store 使用 `shallowRef` 替代 `ref`，避免大数组的深层响应式代理\n- 表格列定义改为 `computed`，避免每次渲染重建\n- RSS 表格列与数据分离，数据变化时不重建列配置\n- 日历页移除重复的 `getAll()` 调用\n- `ab-select` 的 `watchEffect` 改为 `watch`，消除挂载时的无效 emit\n- `useClipboard` 提升到 store 顶层，避免每次 `copy()` 创建新实例\n- `setInterval` 替换为 `useIntervalFn`，自动生命周期管理\n\n### 变更\n\n- 重构搜索逻辑，移除 rxjs 依赖\n- 搜索 store 导出重构以匹配组件期望\n- 升级前端依赖\n- 断点系统从单一 1024px 扩展为 640px + 1024px 双层\n- `useBreakpointQuery` 新增 `isTablet`、`isMobileOrTablet`、`isTabletOrPC`\n- `media-query.vue` 新增 `#tablet` 插槽（回退到 `#mobile`）\n- UnoCSS 新增 `sm: 640px` 断点\n- `ab-input` 移动端全宽 + 增大触摸目标样式\n- 布局使用 `dvh` 单位替代 `vh`，支持 safe-area-inset\n- 修复日历页面未知列宽度问题\n- 统一下载器页面操作栏按钮尺寸\n- 修复移动端设置页面水平溢出问题\n  - 输入框添加 `max-width: 100%` 防止超出容器\n  - 折叠面板添加宽度约束和溢出隐藏\n  - 设置栅格添加 `min-width: 0` 允许收缩\n- 修复移动端顶栏布局\n  - 搜索按钮改为弹性布局，填充 Logo 和图标之间的空间\n  - 减小图标按钮尺寸和间距，优化紧凑型布局\n  - 添加「点击搜索」文字提示\n- 修复移动端搜索弹窗关闭按钮被截断问题\n  - 减小弹窗头部内边距和元素尺寸\n  - 搜索源选择按钮缩小至适配移动端\n- 修复设置页面保存/取消按钮缺少加载状态\n- 修复侧边栏展开动画抖动（rotateY → rotate）\n- 移动端底部导航标签字号从 10px 增至 11px，提升可读性\n- 登录页背景动画添加 `will-change: transform` 优化 GPU 性能\n\n## CI/基础设施\n\n- CI 新增 PR 打开时的构建测试（dev 分支 PR 到 main 自动触发构建）\n- CI 升级 `actions/upload-artifact` 和 `actions/download-artifact` 到 v4\n- Docker 构建移除 `linux/arm/v7` 平台（uv 镜像不支持）\n- 新增 CLAUDE.md 开发指南\n"
  },
  {
    "path": "docs/changelog/3.2.md",
    "content": "# [3.2] - 2025-01\n\n## Backend\n\n### Features\n\n- Added WebAuthn Passkey passwordless login support\n  - Register, authenticate, and manage Passkey credentials\n  - Multi-device credential backup detection (iCloud Keychain, etc.)\n  - Clone attack protection (sign_count verification)\n  - Authentication strategy pattern unifying password and Passkey login interfaces\n  - Usernameless login support via discoverable credentials (resident keys)\n- Added season/episode offset auto-detection\n  - Analyzes TMDB episode air dates to detect \"virtual seasons\" (e.g., Frieren S1 split into two parts)\n  - Auto-identifies different parts when broadcast gap exceeds 6 months\n  - Calculates episode offset (e.g., RSS shows S2E1 → TMDB S1E29)\n  - Background scan thread automatically detects offset issues in existing subscriptions\n  - New API endpoints: `POST /bangumi/detect-offset`, `PATCH /bangumi/dismiss-review/{id}`\n- Added bangumi archive functionality\n  - Manual archive/unarchive support\n  - Auto-archive completed series\n  - New API endpoints: `PATCH /bangumi/archive/{id}`, `PATCH /bangumi/unarchive/{id}`, `GET /bangumi/refresh/metadata`\n- Added search provider configuration API\n  - `GET /search/provider/config` - Get search provider config\n  - `PUT /search/provider/config` - Update search provider config\n- Offset detection panel now shows suggested values (parsed season/episode and recommended offset)\n- Fixed season offset not being applied to download folder path\n  - Setting season offset now auto-updates qBittorrent save path (e.g., `Season 2` → `Season 1`)\n  - RSS rule save paths also sync the update\n- Optimized episode offset suggestion logic\n  - Simple season mismatch no longer suggests episode offset (only virtual seasons need it)\n  - Improved prompt messages to clarify whether episode adjustment is needed\n- Added RSS connection status tracking\n  - Records `connection_status` (healthy/error), `last_checked_at`, and `last_error` after each refresh\n- Added first-run setup wizard\n  - 7-step guided configuration: account, downloader, RSS source, media path, notifications\n  - Downloader connection test, RSS source validation\n  - Optional steps can be skipped and configured later in settings\n  - Sentinel file mechanism (`config/.setup_complete`) prevents re-triggering\n  - Unauthenticated setup API (only available on first run, returns 403 after completion)\n- Added calendar view with Bangumi.tv broadcast schedule integration\n- Added downloader API and management interface\n- Full async migration\n  - Database layer async support (aiosqlite) for non-blocking I/O in Passkey operations\n  - `UserDatabase` supports both sync/async modes for backward compatibility\n  - `Database` context manager supports both `with` (sync) and `async with` (async)\n  - RSS engine, downloader, checker, and parser fully converted to async\n  - Network requests migrated from `requests` to `httpx` (AsyncClient)\n- Backend migrated to `uv` package manager (pyproject.toml + uv.lock)\n- Server startup uses background tasks to avoid blocking (fixes #891, #929)\n- Database migration auto-fills NULL values with model defaults\n- Database adds `needs_review` and `needs_review_reason` fields for offset detection\n\n### Performance\n\n- Shared HTTP client connection pool, reuses TCP/SSL connections\n- RSS refresh now concurrent (`asyncio.gather`), ~10x faster with multiple sources\n- Torrent file download now concurrent, ~5x faster for multiple torrents\n- Rename module concurrent file list fetching, ~20x faster\n- Notification sending now concurrent, removed 2-second hardcoded delay\n- Added TMDB and Mikan parser result caching to avoid duplicate API calls\n- Database indexes added for `Torrent.url`, `Torrent.rss_id`, `Bangumi.title_raw`, `Bangumi.deleted`, `RSSItem.url`\n- RSS batch enable/disable uses single transaction instead of per-item commits\n- Pre-compiled regex patterns for torrent name parsing and filter matching\n- `SeasonCollector` created outside loops, reuses single authentication\n- RSS parsing deduplication changed from O(n²) list lookup to O(1) set lookup\n- `Episode`/`SeasonInfo` dataclasses use `__slots__` for reduced memory footprint\n\n### Changes\n\n- Upgraded WebAuthn dependency to py_webauthn 2.7.0\n- `_get_webauthn_from_request` prioritizes browser Origin header, fixing verification issues in cross-port development environments\n- `auth_user` and `update_user_info` converted to async functions\n- `TitleParser.tmdb_parser` converted to async function\n- `RSSEngine` methods fully async (`pull_rss`, `refresh_rss`, `download_bangumi`, `add_rss`)\n- `Checker.check_downloader` converted to async function\n- `ProgramStatus` migrated from threading to asyncio (Event, Lock)\n\n### Bugfixes\n\n- Fixed downloader connection check with max retry limit\n- Fixed transient network errors when adding torrents with retry logic\n- Fixed multiple issues in search and subscription flow\n- Improved torrent fetch reliability and error handling\n- Fixed `aaguid` type error (now `str` in py_webauthn 2.7.0, no longer `bytes`)\n- Fixed missing `credential_backup_eligible` field (replaced with `credential_device_type`)\n- Fixed `verify_authentication_response` receiving invalid `credential_id` parameter causing TypeError\n- Fixed program startup blocking the server (fixes #891, #929, #886, #917, #946)\n- Fixed search interface export not matching component expectations\n- Fixed poster endpoint path check incorrectly intercepting all requests (fixes #933, #934)\n- Fixed OpenAI parser security issue\n- Fixed database tests using async sessions with sync code mismatch\n- Fixed config field conflicts when upgrading from 3.1.x to 3.2 causing settings loss (fixes #956)\n  - `program.sleep_time` / `program.times` auto-migrated to `rss_time` / `rename_time`\n  - Removed deprecated `rss_parser` fields (`type`, `custom_url`, `token`, `enable_tmdb`)\n  - Fixed `ENV_TO_ATTR` environment variable mapping pointing to non-existent model fields\n  - Fixed `DEFAULT_SETTINGS` inconsistency with current config model\n- Fixed version upgrade migration logic errors (all upgrades calling 3.0→3.1 migration)\n  - Added version-aware migration dispatch based on source version\n  - Added `from_31_to_32()` migration function for database schema changes\n\n## Frontend\n\n### Features\n\n- Complete UI design system redesign\n  - Unified design tokens (colors, fonts, spacing, shadows, animations)\n  - Light/dark theme toggle support\n  - Comprehensive accessibility support (ARIA, keyboard navigation, focus management)\n  - Responsive layout for mobile devices\n- Added first-run setup wizard page\n  - Multi-step wizard component (progress bar + step navigation)\n  - Route guard auto-detection and redirect to setup page\n  - Downloader/RSS/notification connection test feedback\n  - Chinese and English i18n support\n- Added Passkey management panel (settings page)\n  - WebAuthn browser support detection\n  - Automatic device name identification\n  - Passkey list display and deletion\n- Added Passkey fingerprint login button on login page (supports usernameless login)\n- Added calendar view page\n- Added downloader management page\n- Added Bangumi card hover overlay (showing title and tags)\n- Added `resolvePosterUrl` utility function for unified external URL and local path handling (fixes #934)\n- Redesigned search panel with modal and filter system\n  - Added filter section supporting fansub group, resolution, subtitle type, and season filtering\n  - Multi-select filters with smart disable for incompatible options (grayed out)\n  - Result item tags changed to non-clickable colored pill style\n  - Unified tag styling (pill shape, 12px font)\n  - Standardized tag values (resolution: FHD/HD/4K, subtitles: CHS/CHT/Dual)\n  - Filter categories and result variants support expand/collapse\n  - Poster height auto-matches 4 rows of variant items (168px)\n  - Click outside modal to auto-close\n- Redesigned login panel with modern glassmorphism style\n- Added log level filter in log view\n- Redesigned LLM settings panel (fixes #938)\n- Redesigned settings, downloader, player, and log page styles\n- Added search provider settings panel\n  - View, add, edit, delete search sources in UI\n  - Default sources (mikan, nyaa, dmhy) cannot be deleted\n  - URL template validation ensures `%s` placeholder\n- Added iOS-style notification badge system\n  - Yellow badge + purple border for subscriptions needing review\n  - Combined display support (e.g., `! | 2` for warning + multiple rules)\n  - Yellow glow animation on cards needing attention\n- Edit modal warning banner with one-click auto-detect and dismiss\n- Rule selection modal highlights rules with warnings\n- Calendar page bangumi grouping: same anime with multiple rules merged, click to select specific rule\n- Bangumi list page collapsible \"Archived\" section\n- Bangumi list page skeleton loading animation\n- Rule editor episode offset field with \"Auto Detect\" button\n- Empty state on home page now includes \"Add RSS Subscription\" button to guide new users\n- Calendar page poster images now use lazy loading for better performance\n- Calendar page \"Unknown Air Date\" section separated into its own block for better visual rhythm\n- RSS management page connection status labels: green \"Connected\" when healthy, red \"Error\" with tooltip for details\n- New mobile-first responsive design\n  - Three-tier breakpoint system: mobile (<640px), tablet (640-1023px), desktop (≥1024px)\n  - Mobile bottom navigation bar (with icons and text labels)\n  - Tablet mini sidebar (56px icon navigation)\n  - Mobile popups automatically switch to bottom sheets\n  - Pull-to-refresh support\n  - Horizontal swipe container support\n  - Mobile card list replacing data tables (RSS page)\n  - CSS Grid responsive layout (Bangumi card grid)\n  - Form labels stack vertically on mobile, full-width inputs\n  - Touch targets minimum 44px, meeting accessibility standards\n  - Safe area support (notched devices)\n  - `100dvh` dynamic viewport height (fixes mobile browser address bar issue)\n  - `viewport-fit=cover` for full-screen devices\n\n### New Components\n\n- `ab-bottom-sheet` — Touch-driven bottom sheet component (drag to close, max height limit)\n- `ab-adaptive-modal` — Adaptive modal (bottom sheet on mobile / centered dialog on desktop)\n- `ab-pull-refresh` — Pull-to-refresh wrapper component\n- `ab-swipe-container` — Horizontal swipe container (CSS scroll-snap)\n- `ab-data-list` — Mobile-friendly card list (replacing NDataTable)\n- `ab-mobile-nav` — Enhanced bottom navigation bar (icon + label + active indicator)\n- `useSafeArea` — Safe area composable\n\n### Performance\n\n- Downloader store uses `shallowRef` instead of `ref` to avoid deep reactive proxy on large arrays\n- Table column definitions moved to `computed` to avoid rebuilding on each render\n- RSS table columns separated from data, column config not rebuilt on data changes\n- Calendar page removed duplicate `getAll()` calls\n- `ab-select` `watchEffect` changed to `watch`, eliminates invalid emit on mount\n- `useClipboard` hoisted to store top level, avoids creating new instance on each `copy()`\n- `setInterval` replaced with `useIntervalFn` for automatic lifecycle management\n\n### Changes\n\n- Refactored search logic, removed rxjs dependency\n- Search store export refactored to match component expectations\n- Upgraded frontend dependencies\n- Breakpoint system expanded from single 1024px to 640px + 1024px two-tier\n- `useBreakpointQuery` added `isTablet`, `isMobileOrTablet`, `isTabletOrPC`\n- `media-query.vue` added `#tablet` slot (falls back to `#mobile`)\n- UnoCSS added `sm: 640px` breakpoint\n- `ab-input` mobile full-width + increased touch target styling\n- Layout uses `dvh` units instead of `vh`, supports safe-area-inset\n- Fixed calendar page unknown column width\n- Unified action bar button sizes in downloader page\n- Fixed mobile settings page horizontal overflow\n  - Added `max-width: 100%` to inputs to prevent container overflow\n  - Added width constraint and overflow hidden to fold panels\n  - Added `min-width: 0` to settings grid to allow shrinking\n- Fixed mobile top bar layout\n  - Search button changed to flex layout, filling space between logo and icons\n  - Reduced icon button size and spacing for compact layout\n  - Added \"Click to search\" text hint\n- Fixed mobile search modal close button being cut off\n  - Reduced modal header padding and element sizes\n  - Search source selection buttons scaled down for mobile\n- Fixed settings page save/cancel buttons missing loading state\n- Fixed sidebar expand animation jitter (rotateY → rotate)\n- Mobile bottom navigation label font size increased from 10px to 11px for better readability\n- Login page background animation added `will-change: transform` for GPU performance\n\n## CI/Infrastructure\n\n- CI added build test on PR open (dev branch PRs to main auto-trigger build)\n- CI upgraded `actions/upload-artifact` and `actions/download-artifact` to v4\n- Docker build removed `linux/arm/v7` platform (uv image doesn't support it)\n- Added CLAUDE.md development guide\n"
  },
  {
    "path": "docs/config/downloader.md",
    "content": "# 下载器设置\n\n## WebUI 配置\n\n![downloader](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **下载器类型** 为下载器类型。目前仅支持 qBittorrent。\n- **地址** 为下载器地址。[详见下方说明](#下载器地址)\n- **下载路径** 为下载器的映射下载路径。[详见下方说明](#下载路径问题)\n- **SSL** 启用下载器连接的 SSL。\n\n## 常见问题\n\n### 下载器地址\n\n::: warning 注意\n请勿使用 127.0.0.1 或 localhost 作为下载器地址。\n:::\n\n由于官方教程中 AB 在 Docker 的 **Bridge** 模式下运行，使用 127.0.0.1 或 localhost 会解析到 AB 自身，而非下载器。\n- 如果 qBittorrent 也在 Docker 中运行，建议使用 Docker **网关地址：172.17.0.1**。\n- 如果 qBittorrent 运行在宿主机上，请使用宿主机的 IP 地址。\n\n如果 AB 以 **Host** 模式运行，则可以使用 127.0.0.1 代替 Docker 网关地址。\n\n::: warning 注意\nMacvlan 会隔离容器网络。如果没有额外的网桥配置，容器无法访问其他容器或宿主机本身。\n:::\n\n### 下载路径问题\n\nAB 中配置的路径仅用于生成对应的番剧文件路径。AB 本身不会直接管理该路径下的文件。\n\n**下载路径应该填什么？**\n\n此参数只需与**下载器**的配置匹配：\n- Docker：如果 qB 使用 `/downloads`，则设置为 `/downloads/Bangumi`。`Bangumi` 可以改为任意名称。\n- Linux/macOS：如果是 `/home/usr/downloads` 或 `/User/UserName/Downloads`，只需在末尾添加 `/Bangumi`。\n- Windows：将 `D:\\Media\\` 改为 `D:\\Media\\Bangumi`\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`downloader`\n\n| 参数     | 说明         | 类型    | WebUI 选项      | 默认值              |\n|----------|--------------|---------|-----------------|---------------------|\n| type     | 下载器类型   | 字符串  | 下载器类型      | qbittorrent         |\n| host     | 下载器地址   | 字符串  | 下载器地址      | 172.17.0.1:8080     |\n| username | 下载器用户名 | 字符串  | 下载器用户名    | admin               |\n| password | 下载器密码   | 字符串  | 下载器密码      | adminadmin          |\n| path     | 下载路径     | 字符串  | 下载路径        | /downloads/Bangumi  |\n| ssl      | 启用 SSL     | 布尔值  | 启用 SSL        | false               |\n"
  },
  {
    "path": "docs/config/experimental.md",
    "content": "# 实验性功能\n\n::: warning\n实验性功能仍在测试中。启用后可能会导致意外问题，且可能在未来版本中被移除。请谨慎使用！\n:::\n\n## OpenAI ChatGPT\n\n使用 OpenAI ChatGPT 进行更好的结构化标题解析。例如：\n\n```\ninput: \"【喵萌奶茶屋】★04月新番★[夏日重现/Summer Time Rendering][11][1080p][繁日双语][招募翻译]\"\noutput: '{\"group\": \"喵萌奶茶屋\", \"title_en\": \"Summer Time Rendering\", \"resolution\": \"1080p\", \"episode\": 11, \"season\": 1, \"title_zh\": \"夏日重现\", \"sub\": \"\", \"title_jp\": \"\", \"season_raw\": \"\", \"source\": \"\"}'\n```\n\n![experimental OpenAI](/image/config/experimental-openai.png){width=500}{class=ab-shadow-card}\n\n- **启用 OpenAI** 启用 OpenAI 并使用 ChatGPT 进行标题解析。\n- **OpenAI API 类型** 默认为 OpenAI。\n- **OpenAI API Key** 为您的 OpenAI 账户 API 密钥。\n- **OpenAI API Base URL** 为 OpenAI 端点。默认为官方 OpenAI URL；您可以将其更改为兼容的第三方端点。\n- **OpenAI Model** 为 ChatGPT 模型参数。目前提供 `gpt-3.5-turbo`，价格实惠且在正确的提示下效果出色。\n\n## Microsoft Azure OpenAI\n\n\n![experimental Microsoft Azure OpenAI](/image/config/experimental-azure-openai.png){width=500}{class=ab-shadow-card}\n\n除了标准 OpenAI 外，[版本 3.1.8](https://github.com/EstrellaXD/Auto_Bangumi/releases/tag/3.1.8) 添加了 Microsoft Azure OpenAI 支持。使用方式与标准 OpenAI 类似，共享部分参数，但请注意以下几点：\n\n- **启用 OpenAI** 启用 OpenAI 并使用 ChatGPT 进行标题解析。\n- **OpenAI API 类型** — 选择 `azure` 以显示 Azure 专用选项。\n- **OpenAI API Key** 为您的 Microsoft Azure OpenAI API 密钥。\n- **OpenAI API Base URL** 对应 Microsoft Azure OpenAI 入口点。**需要手动填写**。\n- **Azure OpenAI 版本** 为 API 版本。默认为 `2023-05-15`。查看[支持的版本](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#completions)。\n- **Azure OpenAI Deployment ID** 为您的部署 ID，通常与模型名称相同。注意 Azure OpenAI 不支持 `_-` 以外的符号，因此 `gpt-3.5-turbo` 在 Azure 中变为 `gpt-35-turbo`。**需要手动填写**。\n\n参考文档：\n\n- [快速入门：开始使用 Azure OpenAI 服务的 GPT-35-Turbo 和 GPT-4](https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line&pivots=programming-language-python)\n- [了解如何使用 GPT-35-Turbo 和 GPT-4 模型](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions)\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`experimental_openai`\n\n| 参数          | 说明                       | 类型    | WebUI 选项                  | 默认值                     |\n|---------------|----------------------------|---------|----------------------------|---------------------------|\n| enable        | 启用 OpenAI 解析器         | 布尔值  | 启用 OpenAI                | false                     |\n| api_type      | OpenAI API 类型            | 字符串  | API 类型 (`openai`/`azure`) | openai                   |\n| api_key       | OpenAI API 密钥            | 字符串  | OpenAI API Key             |                           |\n| api_base      | API Base URL (Azure 入口点) | 字符串  | OpenAI API Base URL        | https://api.openai.com/v1 |\n| model         | OpenAI 模型                | 字符串  | OpenAI Model               | gpt-3.5-turbo             |\n| api_version   | Azure OpenAI API 版本      | 字符串  | Azure API 版本             | 2023-05-15                |\n| deployment_id | Azure Deployment ID        | 字符串  | Azure Deployment ID        |                           |\n"
  },
  {
    "path": "docs/config/manager.md",
    "content": "# 番剧管理器设置\n\n## WebUI 配置\n\n![proxy](/image/config/manager.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **启用** 启用番剧管理器。如果禁用，下方设置将不会生效。\n- **重命名方式** 为重命名方法。目前支持：\n  - `pn` — `种子标题 S0XE0X.mp4` 格式\n  - `advance` — `官方标题 S0XE0X.mp4` 格式\n  - `none` — 不重命名\n- **剧集补全** 启用当季剧集补全。如果启用，将下载缺失的剧集。\n- **添加字幕组标签** 为下载规则添加字幕组标签。\n- **删除错误种子** 删除出错的种子。\n- [关于文件路径][1]\n- [关于重命名][2]\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`bangumi_manager`\n\n| 参数               | 说明             | 类型    | WebUI 选项      | 默认值 |\n|--------------------|------------------|---------|-----------------|--------|\n| enable             | 启用番剧管理器   | 布尔值  | 启用管理器      | true   |\n| eps_complete       | 启用剧集补全     | 布尔值  | 剧集补全        | false  |\n| rename_method      | 重命名方式       | 字符串  | 重命名方式      | pn     |\n| group_tag          | 添加字幕组标签   | 布尔值  | 字幕组标签      | false  |\n| remove_bad_torrent | 删除错误种子     | 布尔值  | 删除错误种子    | false  |\n\n\n[1]: https://www.autobangumi.org/faq/#download-path\n[2]: https://www.autobangumi.org/faq/#file-renaming\n"
  },
  {
    "path": "docs/config/notifier.md",
    "content": "# 通知设置\n\n## WebUI 配置\n\n![notification](/image/config/notifier.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **启用** 启用通知功能。如果禁用，下方设置将不会生效。\n- **类型** 为通知类型。目前支持：\n  - Telegram\n  - Wecom\n  - Bark\n  - ServerChan\n- **Chat ID** 仅在使用 `telegram` 通知时需要填写。[如何获取 Telegram Bot Chat ID][1]\n- **Wecom**：在 Chat ID 字段填写自定义推送 URL，并在服务端添加[富文本消息][2]类型。[Wecom 配置指南][3]\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`notification`\n\n| 参数    | 说明          | 类型    | WebUI 选项      | 默认值   |\n|---------|---------------|---------|-----------------|----------|\n| enable  | 启用通知      | 布尔值  | 通知            | false    |\n| type    | 通知类型      | 字符串  | 通知类型        | telegram |\n| token   | 通知 Token    | 字符串  | 通知 Token      |          |\n| chat_id | 通知 Chat ID  | 字符串  | 通知 Chat ID    |          |\n\n\n[1]: https://core.telegram.org/bots#6-botfather\n[2]: https://github.com/umbors/wecomchan-alifun\n[3]: https://github.com/easychen/wecomchan\n"
  },
  {
    "path": "docs/config/parser.md",
    "content": "# 解析器设置\n\nAB 的解析器用于解析聚合 RSS 链接。当 RSS 订阅中出现新条目时，AB 会解析标题并生成自动下载规则。\n\n::: tip\n从 v3.1 开始，解析器设置已移至各个 RSS 的单独设置中。要配置**解析器类型**，请参阅 [RSS 解析器设置][add_rss]。\n:::\n\n## WebUI 中的解析器设置\n\n![parser](/image/config/parser.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **启用**：是否启用 RSS 解析器。\n- **语言** 为 RSS 解析器语言。目前支持 `zh`、`jp` 和 `en`。\n- **过滤** 为全局 RSS 解析器过滤规则。可以输入字符串或正则表达式，AB 会在 RSS 解析时过滤掉匹配的条目。\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`rss_parser`\n\n| 参数     | 说明             | 类型    | WebUI 选项        | 默认值         |\n|----------|------------------|---------|-------------------|----------------|\n| enable   | 启用 RSS 解析器  | 布尔值  | 启用 RSS 解析器   | true           |\n| filter   | RSS 解析器过滤   | 数组    | 过滤              | [720,\\d+-\\d+] |\n| language | RSS 解析器语言   | 字符串  | RSS 解析器语言    | zh             |\n\n\n[rss_token]: rss\n[add_rss]: /feature/rss#parser-settings\n[reproxy]: proxy#reverse-proxy\n"
  },
  {
    "path": "docs/config/program.md",
    "content": "# 程序设置\n\n## WebUI 配置\n\n![program](/image/config/program.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- 时间间隔参数的单位为秒。如需设置分钟，请换算为秒。\n- RSS 为 RSS 检查间隔，影响自动下载规则的生成频率。\n- 重命名为重命名检查间隔，如需调整重命名检查频率可修改此项。\n- WebUI 端口为端口号。注意：如果使用 Docker，更改端口后需要在 Docker 中重新映射端口。\n\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`program`\n\n| 参数        | 说明           | 类型          | WebUI 选项        | 默认值 |\n|-------------|----------------|---------------|-------------------|--------|\n| rss_time    | RSS 检查间隔   | 整数（秒）    | RSS 检查间隔      | 7200   |\n| rename_time | 重命名检查间隔 | 整数（秒）    | 重命名检查间隔    | 60     |\n| webui_port  | WebUI 端口     | 整数          | WebUI 端口        | 7892   |\n"
  },
  {
    "path": "docs/config/proxy.md",
    "content": "# 代理与反向代理\n\n## 代理\n\n![proxy](/image/config/proxy.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\nAB 支持 HTTP 和 SOCKS5 代理，以帮助解决网络问题。\n\n- **启用**：是否启用代理。\n- **类型** 为代理类型。\n- **地址** 为代理地址。\n- **端口** 为代理端口。\n\n::: tip\n在 **SOCKS5** 模式下，需要填写用户名和密码。\n:::\n\n## `config.json` 配置选项\n\n配置文件中的对应选项如下：\n\n配置节：`proxy`\n\n| 参数     | 说明       | 类型    | WebUI 选项   | 默认值 |\n|----------|------------|---------|--------------|--------|\n| enable   | 启用代理   | 布尔值  | 代理         | false  |\n| type     | 代理类型   | 字符串  | 代理类型     | http   |\n| host     | 代理地址   | 字符串  | 代理地址     |        |\n| port     | 代理端口   | 整数    | 代理端口     |        |\n| username | 代理用户名 | 字符串  | 代理用户名   |        |\n| password | 代理密码   | 字符串  | 代理密码     |        |\n\n## 反向代理\n\n- 使用 Mikan Project 备用域名 `mikanime.tv` 替换 RSS 订阅链接中的 `mikanani.me`。\n- 使用 Cloudflare Worker 作为反向代理，替换 RSS 订阅中所有的 `mikanani.me` 域名。\n\n## Cloudflare Workers\n\n参考绕过其他服务封锁的方法，您可以使用 Cloudflare Workers 搭建反向代理。如何注册域名并绑定到 Cloudflare 不在本指南范围内。在 Workers 中添加以下代码，即可使用自己的域名访问 Mikan Project 并从 RSS 链接下载种子：\n\n```js\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // Only perform replacement if content type is RSS\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n"
  },
  {
    "path": "docs/config/rss.md",
    "content": "# RSS 订阅设置\n\nAutoBangumi 可以自动解析聚合 RSS 订阅源，并根据字幕组和番剧名称生成下载规则，实现全自动追番。\n以下以 [Mikan Project][mikan-site] 为例，说明如何获取 RSS 订阅链接。\n\n请注意，Mikan Project 主站在部分地区可能被屏蔽。如果无法直接访问，请使用以下备用域名：\n\n[Mikan Project (备用)][mikan-cn-site]\n\n## 获取订阅链接\n\n本项目基于 Mikan Project 提供的 RSS 链接进行解析。要启用自动追番功能，您需要注册并获取 Mikan Project 的 RSS 链接：\n\n![image](/image/rss/rss-token.png){data-zoomable}\n\nRSS 链接格式如下：\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# 或\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n## Mikan Project 订阅注意事项\n\n由于 AutoBangumi 会解析接收到的所有 RSS 条目，订阅时请注意以下几点：\n\n![image](/image/rss/advanced-subscription.png){data-zoomable}\n\n- 在个人设置中启用高级选项。\n- 每部番剧只订阅一个字幕组。点击 Mikan Project 上的番剧海报打开子菜单，选择单个字幕组。\n- 如果字幕组同时提供简体和繁体中文字幕，Mikan Project 通常会提供选择方式，请选择其中一种。\n- 如果没有字幕类型选择选项，可以在 AutoBangumi 中设置 `filter` 进行过滤，或在规则生成后在 qBittorrent 中手动过滤。\n- 目前不支持解析 OVA 和剧场版订阅。\n\n\n[mikan-site]: https://mikanani.me/\n[mikan-cn-site]: https://mikanime.tv/\n"
  },
  {
    "path": "docs/deploy/docker-cli.md",
    "content": "# 使用 Docker CLI 部署\n\n## 新版本说明\n\n从 AutoBangumi 2.6 开始，您可以直接在 WebUI 中配置所有设置。您可以先启动容器，然后在 WebUI 中进行配置。旧版本的环境变量配置将自动迁移。环境变量仍然有效，但仅在首次启动时生效。\n\n## 创建数据和配置目录\n\n为确保 AB 的数据和配置在更新时持久化，我们建议使用 Docker 卷或绑定挂载。\n\n```shell\n# 使用绑定挂载\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\n选择绑定挂载或 Docker 卷：\n```shell\n# 使用 Docker 卷\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## 使用 Docker CLI 部署 AutoBangumi\n\n复制并运行以下命令。\n\n请确保您的工作目录为 AutoBangumi。\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Shanghai \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\n如果使用 Docker 卷，请相应替换绑定路径：\n```shell\n  -v AutoBangumi_config:/app/config \\\n  -v AutoBangumi_data:/app/data \\\n```\n\nAB WebUI 将自动启动，但主程序处于暂停状态。访问 `http://abhost:7892` 进行配置。\n\nAB 会自动将环境变量写入 `config.json` 并开始运行。\n\n我们建议使用 _[Portainer](https://www.portainer.io)_ 或类似的 Docker 管理界面进行高级部署。\n"
  },
  {
    "path": "docs/deploy/docker-compose.md",
    "content": "# 使用 Docker Compose 部署\n\n使用 `docker-compose.yml` 文件一键部署 **AutoBangumi**。\n\n## 安装 Docker Compose\n\nDocker Compose 通常与 Docker 捆绑安装。使用以下命令检查：\n\n```bash\ndocker compose -v\n```\n\n如果未安装，请使用以下命令安装：\n\n```bash\n$ sudo apt-get update\n$ sudo apt-get install docker-compose-plugin\n```\n\n## 部署 **AutoBangumi**\n\n### 创建 AutoBangumi 和数据目录\n\n```bash\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\n### 方式一：自定义 Docker Compose 配置\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\n将以上内容复制到 `docker-compose.yml` 文件中。\n\n### 方式二：下载 Docker Compose 配置文件\n\n如果您不想手动创建 `docker-compose.yml` 文件，项目提供了预制的配置：\n\n- 仅安装 **AutoBangumi**：\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/AutoBangumi/docker-compose.yml\n  ```\n- 安装 **qBittorrent** 和 **AutoBangumi**：\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml\n  ```\n\n选择您的安装方式并运行命令下载 `docker-compose.yml` 文件。如有需要，可以使用文本编辑器自定义参数。\n\n### 定义环境变量\n\n如果您使用的是下载的 AB+QB Docker Compose 文件，需要定义以下环境变量：\n\n```shell\nexport \\\nQB_PORT=<YOUR_PORT>\n```\n\n- `QB_PORT`：输入您现有的 qBittorrent 端口或您想要的自定义端口，例如 `8080`\n\n### 启动 Docker Compose\n\n```bash\ndocker compose up -d\n```\n"
  },
  {
    "path": "docs/deploy/dsm.md",
    "content": "# 群晖 NAS（DSM 7.2）部署（威联通类似）\n\nDSM 7.2 支持 Docker Compose，因此我们建议使用 Docker Compose 进行一键部署。\n\n## 创建配置和数据目录\n\n在 `/volume1/docker/` 下创建 `AutoBangumi` 文件夹，然后在其中创建 `config` 和 `data` 子文件夹。\n\n## 安装 Container Manager（Docker）套件\n\n打开套件中心，安装 Container Manager（Docker）套件。\n\n![install-docker](/image/dsm/install-docker.png){data-zoomable}\n\n## 通过 Docker Compose 安装 AB\n\n点击**项目**，然后点击**新增**，选择 **Docker Compose**。\n\n![new-compose](/image/dsm/new-compose.png){data-zoomable}\n\n将以下内容复制粘贴到 **Docker Compose** 中：\n```yaml\nversion: \"3.4\"\n\nservices:\n  ab:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: \"auto_bangumi\"\n    restart: unless-stopped\n    ports:\n      - \"7892:7892\"\n    volumes:\n      - \"./config:/app/config\"\n      - \"./data:/app/data\"\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - AB_METHOD=Advance\n      - PGID=1000\n      - PUID=1000\n      - UMASK=022\n```\n\n点击**下一步**，然后点击**完成**。\n\n![create](/image/dsm/create.png){data-zoomable}\n\n创建完成后，访问 `http://<NAS IP>:7892` 进入 AB 并进行配置。\n\n## 通过 Docker Compose 安装 AB 和 qBittorrent\n\n当您同时拥有代理和 IPv6 时，在群晖 NAS 的 Docker 中配置 IPv6 可能比较复杂。我们建议将 AB 和 qBittorrent 都安装在主机网络上以降低复杂性。\n\n以下配置假设您已在 Docker 中部署了 Clash 代理，可通过本地 IP 的指定端口访问。\n\n按照上一节的方法，调整并将以下内容粘贴到 **Docker Compose** 中：\n\n```yaml\n  qbittorrent:\n    container_name: qbittorrent\n    image: linuxserver/qbittorrent\n    hostname: qbittorrent\n    environment:\n      - PGID=1000  # 根据需要修改\n      - PUID=1000  # 根据需要修改\n      - WEBUI_PORT=8989\n      - TZ=Asia/Shanghai\n    volumes:\n      - ./qb_config:/config\n      - your_anime_path:/downloads # 修改为您的番剧存储目录。在 AB 中将下载路径设置为 /downloads\n    networks:\n      - host\n    restart: unless-stopped\n\n  auto_bangumi:\n    container_name: AutoBangumi\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=1000  # 根据需要修改\n      - PUID=1000  # 根据需要修改\n      - UMASK=022\n      - AB_DOWNLOADER_HOST=127.0.0.1:8989  # 根据需要修改端口\n    volumes:\n      - /volume1/docker/ab/config:/app/config\n      - /volume1/docker/ab/data:/app/data\n    network_mode: host\n    environment:\n      - AB_METHOD=Advance\n    dns:\n      - 8.8.8.8\n    restart: unless-stopped\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    depends_on:\n      - qbittorrent\n\n```\n\n## 附加说明\n\nPGID 和 PUID 的值需要根据您的系统确定。对于较新的群晖 NAS 设备，通常为：`PUID=1026, PGID=100`。修改 qBittorrent 端口时，请确保在所有位置都进行更新。\n\n代理设置请参阅：[代理设置](../config/proxy)\n\n在性能较低的设备上，默认配置可能会大量占用 CPU，导致 AB 无法连接到 qB，qB WebUI 也无法访问。\n\n对于 220+ 等设备，建议使用以下 qBittorrent 设置以降低 CPU 使用率：\n\n- 设置 -> 连接 -> 连接限制\n  - 全局最大连接数：300\n  - 每个种子最大连接数：60\n  - 全局上传槽位限制：15\n  - 每个种子上传槽位：4\n- BitTorrent\n  - 最大活动检查种子数：1\n  - 种子队列\n    - 最大活动下载数：3\n    - 最大活动上传数：5\n    - 最大活动种子数：10\n- RSS\n  - RSS 阅读器\n    - 每个订阅源最大文章数：50\n"
  },
  {
    "path": "docs/deploy/local.md",
    "content": "# 本地部署\n\n::: warning\n本地部署可能会导致意外问题。我们强烈建议使用 Docker 代替。\n\n此文档可能存在更新延迟。如有问题，请在 [Issues](https://github.com/EstrellaXD/Auto_Bangumi/issues) 中提出。\n:::\n\n## 下载最新版本\n\n```bash\nVERSION=$(curl -s \"https://api.github.com/repos/EstrellaXD/Auto_Bangumi/releases/latest\" | grep '\"tag_name\":' | sed -E 's/.*\"([^\"]+)\".*/\\1/')\ncurl -L -O \"https://github.com/EstrellaXD/Auto_Bangumi/releases/download/$VERSION/app-v$VERSION.zip\"\n```\n\n## 解压压缩包\n\n在 Unix/WSL 系统上，使用以下命令。在 Windows 上，请手动解压。\n\n```bash\nunzip app-v$VERSION.zip -d AutoBangumi\ncd AutoBangumi\n```\n\n\n## 创建虚拟环境并安装依赖\n\n确保本地已安装 Python 3.10+ 和 pip。\n\n```bash\ncd src\npython3 -m venv env\npython3 pip install -r requirements.txt\n```\n\n## 创建配置和数据目录\n\n```bash\nmkdir config\nmkdir data\n```\n\n## 运行 AutoBangumi\n\n```bash\npython3 main.py\n```\n\n\n## Windows 开机自启\n\n可以使用 `nssm` 实现开机自启。使用 `nssm` 的示例：\n\n```powershell\nnssm install AutoBangumi (Get-Command python).Source\nnssm set AutoBangumi AppParameters (Get-Item .\\main.py).FullName\nnssm set AutoBangumi AppDirectory (Get-Item ..).FullName\nnssm set AutoBangumi Start SERVICE_DELAYED_AUTO_START\n```\n"
  },
  {
    "path": "docs/deploy/quick-start.md",
    "content": "# 快速开始\n\n我们推荐使用 Docker 部署 AutoBangumi。\n部署前，请确保已安装 [Docker Engine][docker-engine] 或 [Docker Desktop][docker-desktop]。\n\n## 创建数据和配置目录\n\n为确保 AB 的数据和配置在更新时持久化，我们建议使用绑定挂载或 Docker 卷。\n\n```shell\n# 使用绑定挂载\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\n选择绑定挂载或 Docker 卷：\n\n```shell\n# 使用 Docker 卷\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## 使用 Docker 部署 AutoBangumi\n\n运行这些命令时，请确保您在 AutoBangumi 目录中。\n\n### 方式一：使用 Docker CLI 部署\n\n复制并运行以下命令：\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Shanghai \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\n### 方式二：使用 Docker Compose 部署\n\n将以下内容复制到 `docker-compose.yml` 文件中：\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    network_mode: bridge\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\n运行以下命令启动容器：\n\n```shell\ndocker compose up -d\n```\n\n## 安装 qBittorrent\n\n如果您尚未安装 qBittorrent，请先安装：\n\n- [在 Docker 中安装 qBittorrent][qbittorrent-docker]\n- [在 Windows/macOS 上安装 qBittorrent][qbittorrent-desktop]\n- [在 Linux 上安装 qBittorrent-nox][qbittorrent-nox]\n\n## 获取聚合 RSS 链接（以 Mikan Project 为例）\n\n访问 [Mikan Project][mikan-project]，注册账号并登录，然后点击右下角的 **RSS** 按钮并复制链接。\n\n![mikan-rss](/image/rss/rss-token.png){data-zoomable}\n\nRSS 链接格式如下：\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# 或\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n详细步骤请参阅 [Mikan RSS 设置][config-rss]。\n\n\n## 配置 AutoBangumi\n\n安装 AB 后，WebUI 将自动启动，但主程序处于暂停状态。您可以访问 `http://abhost:7892` 进行配置。\n\n1. 打开网页。默认用户名为 `admin`，默认密码为 `adminadmin`。首次登录后请立即修改。\n2. 输入下载器的地址、端口、用户名和密码。\n\n![ab-webui](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n3. 点击**应用**保存配置。AB 将重启，当右上角的圆点变为绿色时，表示 AB 正常运行。\n\n4. 点击右上角的 **+** 按钮，勾选**聚合 RSS**，选择解析器类型，然后输入您的 Mikan RSS 链接。\n\n![ab-rss](/image/config/add-rss.png){width=500}{class=ab-shadow-card}\n\n等待 AB 解析聚合 RSS。解析完成后，将自动添加番剧并管理下载。\n\n\n\n[docker-engine]: https://docs.docker.com/engine/install/\n[docker-desktop]: https://www.docker.com/products/docker-desktop\n[config-rss]: ../config/rss\n[mikan-project]: https://mikanani.me/\n[qbittorrent-docker]: https://hub.docker.com/r/superng6/qbittorrent\n[qbittorrent-desktop]: https://www.qbittorrent.org/download\n[qbittorrent-nox]: https://www.qbittorrent.org/download-nox\n"
  },
  {
    "path": "docs/dev/database.md",
    "content": "# 数据库开发指南\n\n本指南介绍 AutoBangumi 中的数据库架构、模型和操作。\n\n## 概述\n\nAutoBangumi 使用 **SQLite** 作为数据库，使用 **SQLModel**（Pydantic + SQLAlchemy 混合）作为 ORM。数据库文件位于 `data/data.db`。\n\n### 架构\n\n```\nmodule/database/\n├── engine.py       # SQLAlchemy 引擎配置\n├── combine.py      # Database 类、迁移、会话管理\n├── bangumi.py      # 番剧（动画订阅）操作\n├── rss.py          # RSS 订阅源操作\n├── torrent.py      # 种子跟踪操作\n└── user.py         # 用户认证操作\n```\n\n## 核心组件\n\n### Database 类\n\n`combine.py` 中的 `Database` 类是主入口点。它继承自 SQLModel 的 `Session`，并提供对所有子数据库的访问：\n\n```python\nfrom module.database import Database\n\nwith Database() as db:\n    # 访问子数据库\n    bangumis = db.bangumi.search_all()\n    rss_items = db.rss.search_active()\n    torrents = db.torrent.search_all()\n```\n\n### 子数据库类\n\n| 类 | 模型 | 用途 |\n|-------|-------|---------|\n| `BangumiDatabase` | `Bangumi` | 动画订阅规则 |\n| `RSSDatabase` | `RSSItem` | RSS 订阅源 |\n| `TorrentDatabase` | `Torrent` | 已下载种子跟踪 |\n| `UserDatabase` | `User` | 认证 |\n\n## 模型\n\n### Bangumi 模型\n\n动画订阅的核心模型：\n\n```python\nclass Bangumi(SQLModel, table=True):\n    id: int                          # 主键\n    official_title: str              # 显示名称（如\"无职转生\"）\n    title_raw: str                   # 用于种子匹配的原始标题（有索引）\n    season: int = 1                  # 季度编号\n    episode_offset: int = 0          # 集数编号调整\n    season_offset: int = 0           # 季度编号调整\n    rss_link: str                    # 逗号分隔的 RSS 订阅源 URL\n    filter: str                      # 排除过滤器（如 \"720,\\\\d+-\\\\d+\"）\n    poster_link: str                 # TMDB 海报 URL\n    save_path: str                   # 下载目标路径\n    rule_name: str                   # qBittorrent RSS 规则名称\n    added: bool = False              # 规则是否已添加到下载器\n    deleted: bool = False            # 软删除标志（有索引）\n    archived: bool = False           # 用于已完结系列（有索引）\n    needs_review: bool = False       # 检测到偏移不匹配\n    needs_review_reason: str         # 需要审核的原因\n    suggested_season_offset: int     # 建议的季度偏移\n    suggested_episode_offset: int    # 建议的集数偏移\n    air_weekday: int                 # 放送日（0=周日，6=周六）\n```\n\n### RSSItem 模型\n\nRSS 订阅源：\n\n```python\nclass RSSItem(SQLModel, table=True):\n    id: int                          # 主键\n    name: str                        # 显示名称\n    url: str                         # 订阅源 URL（唯一，有索引）\n    aggregate: bool = True           # 是否解析种子\n    parser: str = \"mikan\"            # 解析器类型：mikan、dmhy、nyaa\n    enabled: bool = True             # 启用标志\n    connection_status: str           # \"healthy\" 或 \"error\"\n    last_checked_at: str             # ISO 时间戳\n    last_error: str                  # 最后一次错误消息\n```\n\n### Torrent 模型\n\n跟踪已下载的种子：\n\n```python\nclass Torrent(SQLModel, table=True):\n    id: int                          # 主键\n    name: str                        # 种子名称（有索引）\n    url: str                         # 种子/磁力链接 URL（唯一，有索引）\n    rss_id: int                      # 来源 RSS 订阅源 ID\n    bangumi_id: int                  # 关联的番剧 ID（可为空）\n    qb_hash: str                     # qBittorrent 信息哈希（有索引）\n    downloaded: bool = False         # 下载完成\n```\n\n## 常用操作\n\n### BangumiDatabase\n\n```python\nwith Database() as db:\n    # 创建\n    db.bangumi.add(bangumi)              # 单条插入\n    db.bangumi.add_all(bangumi_list)     # 批量插入（去重）\n\n    # 读取\n    db.bangumi.search_all()              # 所有记录（缓存，5分钟 TTL）\n    db.bangumi.search_id(123)            # 按 ID 查询\n    db.bangumi.match_torrent(\"torrent name\")  # 按 title_raw 匹配查找\n    db.bangumi.not_complete()            # 未完结系列\n    db.bangumi.get_needs_review()        # 标记需要审核的\n\n    # 更新\n    db.bangumi.update(bangumi)           # 更新单条记录\n    db.bangumi.update_all(bangumi_list)  # 批量更新\n\n    # 删除\n    db.bangumi.delete_one(123)           # 硬删除\n    db.bangumi.disable_rule(123)         # 软删除（deleted=True）\n```\n\n### RSSDatabase\n\n```python\nwith Database() as db:\n    # 创建\n    db.rss.add(rss_item)                 # 单条插入\n    db.rss.add_all(rss_items)            # 批量插入（去重）\n\n    # 读取\n    db.rss.search_all()                  # 所有订阅源\n    db.rss.search_active()               # 仅启用的订阅源\n    db.rss.search_aggregate()            # 启用且 aggregate=True\n\n    # 更新\n    db.rss.update(id, rss_update)        # 部分更新\n    db.rss.enable(id)                    # 启用订阅源\n    db.rss.disable(id)                   # 禁用订阅源\n    db.rss.enable_batch([1, 2, 3])       # 批量启用\n    db.rss.disable_batch([1, 2, 3])      # 批量禁用\n```\n\n### TorrentDatabase\n\n```python\nwith Database() as db:\n    # 创建\n    db.torrent.add(torrent)              # 单条插入\n    db.torrent.add_all(torrents)         # 批量插入\n\n    # 读取\n    db.torrent.search_all()              # 所有种子\n    db.torrent.search_by_qb_hash(hash)   # 按 qBittorrent 哈希查询\n    db.torrent.search_by_url(url)        # 按 URL 查询\n    db.torrent.check_new(torrents)       # 过滤掉已存在的\n\n    # 更新\n    db.torrent.update_qb_hash(id, hash)  # 设置 qb_hash\n```\n\n## 缓存\n\n### 番剧缓存\n\n`search_all()` 的结果在模块级别缓存，TTL 为 5 分钟：\n\n```python\n# bangumi.py 中的模块级缓存\n_bangumi_cache: list[Bangumi] | None = None\n_bangumi_cache_time: float = 0\n_BANGUMI_CACHE_TTL: float = 300.0  # 5 分钟\n\n# 缓存失效\ndef _invalidate_bangumi_cache():\n    global _bangumi_cache, _bangumi_cache_time\n    _bangumi_cache = None\n    _bangumi_cache_time = 0\n```\n\n**重要：** 缓存在以下操作时自动失效：\n- `add()`、`add_all()`\n- `update()`、`update_all()`\n- `delete_one()`、`delete_all()`\n- `archive_one()`、`unarchive_one()`\n- 任何 RSS 链接更新操作\n\n### 会话分离\n\n缓存的对象会从会话中**分离**，以防止 `DetachedInstanceError`：\n\n```python\nfor b in bangumis:\n    self.session.expunge(b)  # 从会话中分离\n```\n\n## 迁移系统\n\n### Schema 版本控制\n\n迁移通过 `schema_version` 表跟踪：\n\n```python\nCURRENT_SCHEMA_VERSION = 7\n\n# 每个迁移：(版本号, 描述, [SQL 语句])\nMIGRATIONS = [\n    (1, \"add air_weekday column\", [...]),\n    (2, \"add connection status columns\", [...]),\n    (3, \"create passkey table\", [...]),\n    (4, \"add archived column\", [...]),\n    (5, \"rename offset to episode_offset\", [...]),\n    (6, \"add qb_hash column\", [...]),\n    (7, \"add suggested offset columns\", [...]),\n]\n```\n\n### 添加新迁移\n\n1. 在 `combine.py` 中增加 `CURRENT_SCHEMA_VERSION`\n2. 在 `MIGRATIONS` 列表中添加迁移元组：\n\n```python\nMIGRATIONS = [\n    # ... 现有迁移 ...\n    (\n        8,\n        \"add my_new_column to bangumi\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN my_new_column TEXT DEFAULT NULL\",\n        ],\n    ),\n]\n```\n\n3. 在 `run_migrations()` 中添加幂等性检查：\n\n```python\nif \"bangumi\" in tables and version == 8:\n    columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n    if \"my_new_column\" in columns:\n        needs_run = False\n```\n\n4. 更新 `module/models/` 中对应的 Pydantic 模型\n\n### 默认值回填\n\n迁移后，`_fill_null_with_defaults()` 会根据模型默认值自动填充 NULL 值：\n\n```python\n# 如果模型定义为：\nclass Bangumi(SQLModel, table=True):\n    my_field: bool = False\n\n# 那么现有记录中的 NULL 值将被更新为 False\n```\n\n## 性能模式\n\n### 批量查询\n\n`add_all()` 使用单个查询检查重复项，而不是 N 个查询：\n\n```python\n# 高效：单个 SELECT\nkeys_to_check = [(d.title_raw, d.group_name) for d in datas]\nconditions = [\n    and_(Bangumi.title_raw == tr, Bangumi.group_name == gn)\n    for tr, gn in keys_to_check\n]\nstatement = select(Bangumi.title_raw, Bangumi.group_name).where(or_(*conditions))\n```\n\n### 正则表达式匹配\n\n`match_list()` 为所有标题匹配编译单个正则表达式模式：\n\n```python\n# 编译一次，匹配多次\nsorted_titles = sorted(title_index.keys(), key=len, reverse=True)\npattern = \"|\".join(re.escape(title) for title in sorted_titles)\ntitle_regex = re.compile(pattern)\n\n# 每个种子 O(1) 查找而不是 O(n)\nfor torrent in torrent_list:\n    match = title_regex.search(torrent.name)\n```\n\n### 索引列\n\n以下列具有索引以实现快速查找：\n\n| 表 | 列 | 索引类型 |\n|-------|--------|------------|\n| `bangumi` | `title_raw` | 普通 |\n| `bangumi` | `deleted` | 普通 |\n| `bangumi` | `archived` | 普通 |\n| `rssitem` | `url` | 唯一 |\n| `torrent` | `name` | 普通 |\n| `torrent` | `url` | 唯一 |\n| `torrent` | `qb_hash` | 普通 |\n\n## 测试\n\n### 测试数据库设置\n\n测试使用内存中的 SQLite 数据库：\n\n```python\n# conftest.py\n@pytest.fixture\ndef db_engine():\n    engine = create_engine(\"sqlite:///:memory:\")\n    SQLModel.metadata.create_all(engine)\n    yield engine\n    engine.dispose()\n\n@pytest.fixture\ndef db_session(db_engine):\n    with Session(db_engine) as session:\n        yield session\n```\n\n### 工厂函数\n\n使用工厂函数创建测试数据：\n\n```python\nfrom test.factories import make_bangumi, make_torrent, make_rss_item\n\ndef test_bangumi_search():\n    bangumi = make_bangumi(title_raw=\"Test Title\", season=2)\n    # ... 测试逻辑\n```\n\n## 设计说明\n\n### 无外键\n\nSQLite 外键强制默认是禁用的。关系（如 `Torrent.bangumi_id`）在应用程序逻辑中管理，而不是通过数据库约束。\n\n### 软删除\n\n`Bangumi.deleted` 标志启用软删除。面向用户的数据查询应按 `deleted=False` 过滤：\n\n```python\nstatement = select(Bangumi).where(Bangumi.deleted == false())\n```\n\n### 种子标记\n\n种子在 qBittorrent 中使用 `ab:{bangumi_id}` 标记，用于重命名操作时的偏移查找。这使得无需数据库查询即可快速识别番剧。\n\n## 常见问题\n\n### DetachedInstanceError\n\n如果您从不同的会话访问缓存的对象：\n\n```python\n# 错误：在新会话中访问缓存的对象\nbangumis = db.bangumi.search_all()  # 已缓存\nwith Database() as new_db:\n    new_db.session.add(bangumis[0])  # 错误！\n\n# 正确：对象已分离，可独立工作\nbangumis = db.bangumi.search_all()\nbangumis[0].title_raw = \"New Title\"  # 可以，但不会持久化\n```\n\n### 缓存过期\n\n如果手动 SQL 更新绕过了 ORM，请使缓存失效：\n\n```python\nfrom module.database.bangumi import _invalidate_bangumi_cache\n\nwith engine.connect() as conn:\n    conn.execute(text(\"UPDATE bangumi SET ...\"))\n    conn.commit()\n\n_invalidate_bangumi_cache()  # 重要！\n```\n"
  },
  {
    "path": "docs/dev/e2e-test-guide.md",
    "content": "# E2E Integration Test Guide\n\nEnd-to-end tests that exercise the full AutoBangumi workflow against real\nDocker services (qBittorrent + mock RSS server).\n\n## Prerequisites\n\n- **Docker** with `docker compose` (v2)\n- **uv** for Python dependency management\n- Ports **7892**, **18080**, **18888** must be free\n\n## Quick Start\n\n```bash\n# 1. Build the mock RSS server image\ncd backend/src/test/e2e\ndocker build -f Dockerfile.mock-rss -t ab-mock-rss .\n\n# 2. Start test infrastructure\ndocker compose -f docker-compose.test.yml up -d --wait\n\n# 3. Verify services are healthy\ndocker compose -f docker-compose.test.yml ps\n\n# 4. Run E2E tests\ncd backend && uv run pytest -m e2e -v --tb=long\n\n# 5. Cleanup\ndocker compose -f backend/src/test/e2e/docker-compose.test.yml down -v\n```\n\n## Architecture\n\n```\nHost machine\n├── pytest (test runner)\n│   └── Drives HTTP requests to AutoBangumi at localhost:7892\n├── AutoBangumi subprocess\n│   ├── Isolated config/ and data/ in temp directory\n│   └── Uses mock downloader (no real qB coupling during setup)\n├── qBittorrent container (localhost:18080)\n│   └── linuxserver/qbittorrent:latest\n└── Mock RSS server container (localhost:18888)\n    └── Serves static XML fixtures from fixtures/\n```\n\n## Test Phases\n\n| Phase | Tests | What It Validates |\n|-------|-------|-------------------|\n| 1. Setup Wizard | `test_01` - `test_06` | First-run detection, mock downloader, setup completion, 403 guard |\n| 2. Authentication | `test_10` - `test_13` | Login, cookie-based JWT, token refresh, logout |\n| 3. Configuration | `test_20` - `test_22` | Config CRUD, password masking |\n| 4. RSS Management | `test_30` - `test_32` | Add, list, delete RSS feeds |\n| 5. Program Lifecycle | `test_40` - `test_41` | Status check, restart |\n| 6. Downloader | `test_50` - `test_51` | Mock downloader health, direct qB connectivity |\n| 7. Cleanup | `test_90` | Logout |\n\n## Key Design Decisions\n\n### Mock Downloader for Setup\n\nThe setup wizard's `_validate_url()` blocks private/loopback IPs (SSRF\nprotection). Since the Docker qBittorrent instance is on `localhost`, the\nsetup wizard's \"test downloader\" endpoint would reject it. Instead:\n\n1. Setup uses `downloader_type: \"mock\"` (bypasses URL validation)\n2. Config can be updated to point to real qBittorrent after auth\n3. Direct qBittorrent connectivity is tested independently (`test_51`)\n\n### DEV_VERSION Auth Bypass\n\nWhen running from source, `VERSION == \"DEV_VERSION\"` which bypasses JWT\nvalidation (`get_current_user` returns `\"dev_user\"` unconditionally). Tests\ndocument this behavior: login/refresh/logout endpoints still work, but\nunauthenticated access is also allowed. In production builds, test_13\nwould expect HTTP 401.\n\n### CWD-Based Isolation\n\nAutoBangumi resolves all paths relative to the working directory:\n- `config/` - config files, JWT secret, setup sentinel\n- `data/` - SQLite database, posters, logs\n\nThe `ab_process` fixture creates a temp directory with these subdirs and\nruns `main.py` from there, ensuring complete isolation from any existing\ninstallation.\n\n### qBittorrent Password Extraction\n\nRecent `linuxserver/qbittorrent` images generate a random temporary\npassword on first start. The `qb_password` fixture polls `docker logs`\nuntil it finds the line:\n\n```\nA temporary password is provided for this session: XXXXXXXX\n```\n\n## Debugging Failures\n\n### AutoBangumi won't start\n\n```bash\n# Check if port 7892 is in use\nlsof -i :7892\n\n# Run manually to see startup logs\ncd /tmp/test-workdir && uv run python /path/to/backend/src/main.py\n```\n\n### qBittorrent issues\n\n```bash\ndocker logs ab-test-qbittorrent\ndocker exec ab-test-qbittorrent curl -s http://localhost:18080\n```\n\n### Mock RSS server issues\n\n```bash\ndocker logs ab-test-mock-rss\ncurl http://localhost:18888/health\ncurl http://localhost:18888/rss/mikan.xml\n```\n\n### Test infrastructure stuck\n\n```bash\n# Force cleanup\ndocker compose -f backend/src/test/e2e/docker-compose.test.yml down -v --remove-orphans\n```\n\n## Adding New Test Scenarios\n\n1. Add new test methods to `TestE2EWorkflow` in definition order\n2. Use `api_client` for HTTP requests (cookies persist across tests)\n3. Use `e2e_state` dict to share data between tests\n4. For new RSS fixtures, add XML files to `fixtures/` directory\n5. Keep test names ordered: `test_XX_description` where XX reflects the phase\n\n### Adding a new fixture feed\n\n1. Create `backend/src/test/e2e/fixtures/your_feed.xml`\n2. Access via `http://localhost:18888/rss/your_feed.xml`\n3. Rebuild the mock RSS image: `docker compose ... build mock-rss`\n"
  },
  {
    "path": "docs/dev/index.md",
    "content": "# 贡献指南\n\n我们欢迎贡献者帮助改进 AutoBangumi，更好地解决用户遇到的问题。\n\n本指南将引导您了解如何向 AutoBangumi 贡献代码。请在提交 Pull Request 之前花几分钟阅读。\n\n本文涵盖：\n\n- [项目路线图](#项目路线图)\n  - [征求意见稿 (RFC)](#征求意见稿-rfc)\n- [Git 分支管理](#git-分支管理)\n  - [版本号规则](#版本号规则)\n  - [分支开发，主干发布](#分支开发主干发布)\n  - [分支生命周期](#分支生命周期)\n  - [Git 工作流程概述](#git-工作流程概述)\n- [Pull Request](#pull-request)\n- [发布流程](#发布流程)\n\n\n## 项目路线图\n\nAutoBangumi 开发团队使用 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板来管理计划中的开发、正在进行的修复及其进度。\n\n这可以帮助您了解：\n- 开发团队正在做什么\n- 哪些内容与您想要的贡献一致，以便您直接参与\n- 哪些工作已经在进行中，避免重复工作\n\n在 [Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 中，除了常见的 `[Feature Request]`、`[BUG]` 和小改进外，您还会看到 **`[RFC]`** 条目。\n\n### 征求意见稿 (RFC)\n\n> 通过 issues 中的 `RFC` 标签查找现有的 [AutoBangumi RFC](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+label%3ARFC)。\n\n对于小改进或 bug 修复，可以直接调整代码并提交 Pull Request。只需阅读[分支管理](#git-分支管理)部分以确保基于正确的分支进行工作，以及 [Pull Request](#pull-request) 部分了解 PR 如何被合并。\n\n<br/>\n\n对于涉及范围较广的**较大**功能重构，请首先通过 [Issue: Feature Proposal](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=RFC&projects=&template=rfc.yml&title=%5BRFC%5D%3A+) 编写 RFC 提案，简要描述您的方法并寻求开发者讨论和共识。\n\n某些提案可能与开发团队已经做出的决定冲突，此步骤有助于避免浪费精力。\n\n> 如果您只是想讨论是否添加或改进某个功能（而不是\"如何实现\"），请使用 -> [Issue: Feature Request](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)\n\n\n<br/>\n\n[RFC 提案](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+is%3Aopen+label%3ARFC)是**\"在功能/重构的具体开发之前，供开发者审查技术设计/方法的文档\"**。\n\n其目的是确保协作的开发者清楚地知道\"要做什么\"和\"如何完成\"，所有开发者都可以参与公开讨论。\n\n这有助于评估影响（被忽略的考虑因素、向后兼容性、与现有功能的冲突）。\n\n因此，提案重点描述解决问题的**方法、设计和步骤**。\n\n\n## Git 分支管理\n\n### 版本号规则\n\nAutoBangumi 项目中的 Git 分支与发布版本规则密切相关。\n\nAutoBangumi 遵循[语义化版本控制 (SemVer)](https://semver.org/)，使用 `<Major>.<Minor>.<Patch>` 格式：\n\n- **Major**：主版本更新，可能包含不兼容的配置/API 变更\n- **Minor**：向后兼容的新功能\n- **Patch**：向后兼容的 bug 修复/小改进\n\n### 分支开发，主干发布\n\nAutoBangumi 使用\"分支开发，主干发布\"模式。\n\n[**`main`**](https://github.com/EstrellaXD/Auto_Bangumi/commits/main) 是稳定的**主干分支**，仅用于发布，不直接用于开发。\n\n每个 Minor 版本都有对应的**开发分支**，用于新功能和发布后的维护。\n\n开发分支命名为 `<Major>.<Minor>-dev`，例如 `3.1-dev`、`3.0-dev`、`2.6-dev`。在[所有分支](https://github.com/EstrellaXD/Auto_Bangumi/branches/all?query=-dev)中查找它们。\n\n\n### 分支生命周期\n\n当一个 Minor 开发分支（如 `3.1-dev`）完成功能开发并**首次**合并到 main 时：\n- 发布 Minor 版本（如 `3.1.0`）\n- 创建**下一个** Minor 开发分支（`3.2-dev`）用于下个版本的功能\n  - **上一个**版本的分支（`3.0-dev`）将被归档\n- 当前 Minor 分支（`3.1-dev`）进入维护期——不再添加新功能/重构，只修复 bug\n  - Bug 修复先合并到维护分支，然后合并到 main 进行 `Patch` 发布\n\n对于选择 Git 分支的贡献者：\n- **Bug 修复** — 基于**当前已发布版本**的 Minor 分支，向该分支提交 PR\n- **新功能/重构** — 基于**下一个未发布版本**的 Minor 分支，向该分支提交 PR\n\n> \"当前已发布版本\"是 [[Releases 页面]](https://github.com/EstrellaXD/Auto_Bangumi/releases) 上的最新版本\n\n\n### Git 工作流程概述\n\n> 提交时间线从左到右 --->\n\n![dev-branch](/image/dev/branch.png)\n\n\n## Pull Request\n\n请确保按照上述 Git 分支管理部分选择正确的 PR 目标分支：\n> - **Bug 修复** → 向**当前已发布版本**的 Minor 维护分支提交 PR\n> - **新功能/重构** → 向**下一版本**的 Minor 开发分支提交 PR\n\n<br/>\n\n- 一个 PR 应该对应单一关注点，不要引入无关的更改。\n\n  将不同的关注点拆分为多个 PR，以帮助团队在每次审查中专注于一个问题。\n\n- 在 PR 标题和描述中，简要说明更改内容，包括原因和意图。\n\n  在 PR 描述中链接相关的 issues 或 RFC。\n\n  这有助于团队在代码审查时快速了解上下文。\n\n- 确保勾选\"允许维护者编辑\"。这允许直接进行小的编辑/重构，节省时间。\n\n- 确保本地测试和代码检查通过。这些也会在 PR CI 中检查。\n  - 对于 bug 修复和新功能，团队可能会要求相应的单元测试覆盖。\n\n\n开发团队将尽快审查贡献者的 PR，进行讨论或批准合并。\n\n## 发布流程\n\n目前，发布是在开发团队手动合并特定的\"发布 PR\"后自动触发的。\n\nBug 修复 PR 通常会快速发布，一般在一周内。\n\n新功能发布需要更长时间，时间不太可预测。查看 [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) 看板了解开发进度——当所有计划功能完成时发布版本。\n"
  },
  {
    "path": "docs/en/api/index.md",
    "content": "# REST API Reference\n\nAutoBangumi exposes a REST API at `/api/v1`. All endpoints (except login and setup) require JWT authentication.\n\n**Base URL:** `http://your-host:7892/api/v1`\n\n**Authentication:** Include the JWT token as a cookie or `Authorization: Bearer <token>` header.\n\n**Interactive Docs:** When running in development mode, Swagger UI is available at `http://your-host:7892/docs`.\n\n---\n\n## Authentication\n\n### Login\n\n```\nPOST /auth/login\n```\n\nAuthenticate with username and password.\n\n**Request Body:**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n**Response:** Sets authentication cookie with JWT token.\n\n### Refresh Token\n\n```\nGET /auth/refresh_token\n```\n\nRefresh the current authentication token.\n\n### Logout\n\n```\nGET /auth/logout\n```\n\nClear authentication cookies and log out.\n\n### Update Credentials\n\n```\nPOST /auth/update\n```\n\nUpdate username and/or password.\n\n**Request Body:**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n---\n\n## Passkey / WebAuthn <Badge type=\"tip\" text=\"v3.2+\" />\n\nPasswordless authentication using WebAuthn/FIDO2 Passkeys.\n\n### Register Passkey\n\n```\nPOST /passkey/register/options\n```\n\nGet WebAuthn registration options (challenge, relying party info).\n\n```\nPOST /passkey/register/verify\n```\n\nVerify and save the Passkey registration response from the browser.\n\n### Authenticate with Passkey\n\n```\nPOST /passkey/auth/options\n```\n\nGet WebAuthn authentication challenge options.\n\n```\nPOST /passkey/auth/verify\n```\n\nVerify the Passkey authentication response and issue a JWT token.\n\n### Manage Passkeys\n\n```\nGET /passkey/list\n```\n\nList all registered Passkeys for the current user.\n\n```\nPOST /passkey/delete\n```\n\nDelete a registered Passkey by credential ID.\n\n---\n\n## Configuration\n\n### Get Configuration\n\n```\nGET /config/get\n```\n\nRetrieve the current application configuration.\n\n**Response:** Full configuration object including `program`, `downloader`, `rss_parser`, `bangumi_manager`, `notification`, `proxy`, and `experimental_openai` sections.\n\n### Update Configuration\n\n```\nPATCH /config/update\n```\n\nPartially update the application configuration. Only include fields you want to change.\n\n**Request Body:** Partial configuration object.\n\n---\n\n## Bangumi (Anime Rules)\n\n### List All Bangumi\n\n```\nGET /bangumi/get/all\n```\n\nGet all anime download rules.\n\n### Get Bangumi by ID\n\n```\nGET /bangumi/get/{bangumi_id}\n```\n\nGet a specific anime rule by ID.\n\n### Update Bangumi\n\n```\nPATCH /bangumi/update/{bangumi_id}\n```\n\nUpdate an anime rule's metadata (title, season, episode offset, etc.).\n\n### Delete Bangumi\n\n```\nDELETE /bangumi/delete/{bangumi_id}\n```\n\nDelete a single anime rule and its associated torrents.\n\n```\nDELETE /bangumi/delete/many/\n```\n\nBatch delete multiple anime rules.\n\n**Request Body:**\n```json\n{\n  \"bangumi_ids\": [1, 2, 3]\n}\n```\n\n### Disable / Enable Bangumi\n\n```\nDELETE /bangumi/disable/{bangumi_id}\n```\n\nDisable an anime rule (keeps files, stops downloading).\n\n```\nDELETE /bangumi/disable/many/\n```\n\nBatch disable multiple anime rules.\n\n```\nGET /bangumi/enable/{bangumi_id}\n```\n\nRe-enable a previously disabled anime rule.\n\n### Poster Refresh\n\n```\nGET /bangumi/refresh/poster/all\n```\n\nRefresh poster images for all anime from TMDB.\n\n```\nGET /bangumi/refresh/poster/{bangumi_id}\n```\n\nRefresh the poster image for a specific anime.\n\n### Calendar\n\n```\nGET /bangumi/refresh/calendar\n```\n\nRefresh the anime broadcast calendar data from Bangumi.tv.\n\n### Reset All\n\n```\nGET /bangumi/reset/all\n```\n\nDelete all anime rules. Use with caution.\n\n---\n\n## RSS Feeds\n\n### List All Feeds\n\n```\nGET /rss\n```\n\nGet all configured RSS feeds.\n\n### Add Feed\n\n```\nPOST /rss/add\n```\n\nAdd a new RSS feed subscription.\n\n**Request Body:**\n```json\n{\n  \"url\": \"string\",\n  \"aggregate\": true,\n  \"parser\": \"mikan\"\n}\n```\n\n### Enable / Disable Feeds\n\n```\nPOST /rss/enable/many\n```\n\nEnable multiple RSS feeds.\n\n```\nPATCH /rss/disable/{rss_id}\n```\n\nDisable a single RSS feed.\n\n```\nPOST /rss/disable/many\n```\n\nBatch disable multiple RSS feeds.\n\n### Delete Feeds\n\n```\nDELETE /rss/delete/{rss_id}\n```\n\nDelete a single RSS feed.\n\n```\nPOST /rss/delete/many\n```\n\nBatch delete multiple RSS feeds.\n\n### Update Feed\n\n```\nPATCH /rss/update/{rss_id}\n```\n\nUpdate an RSS feed's configuration.\n\n### Refresh Feeds\n\n```\nGET /rss/refresh/all\n```\n\nManually trigger a refresh of all RSS feeds.\n\n```\nGET /rss/refresh/{rss_id}\n```\n\nRefresh a specific RSS feed.\n\n### Get Torrents from Feed\n\n```\nGET /rss/torrent/{rss_id}\n```\n\nGet the list of torrents parsed from a specific RSS feed.\n\n### Analysis & Subscription\n\n```\nPOST /rss/analysis\n```\n\nAnalyze an RSS URL and extract anime metadata without subscribing.\n\n**Request Body:**\n```json\n{\n  \"url\": \"string\"\n}\n```\n\n```\nPOST /rss/collect\n```\n\nDownload all episodes from an RSS feed (for completed anime).\n\n```\nPOST /rss/subscribe\n```\n\nSubscribe to an RSS feed for automatic ongoing downloads.\n\n---\n\n## Search\n\n### Search Bangumi (Server-Sent Events)\n\n```\nGET /search/bangumi?keyword={keyword}&provider={provider}\n```\n\nSearch for anime torrents. Returns results as a Server-Sent Events (SSE) stream for real-time updates.\n\n**Query Parameters:**\n- `keyword` — Search keyword\n- `provider` — Search provider (e.g., `mikan`, `nyaa`, `dmhy`)\n\n**Response:** SSE stream with parsed search results.\n\n### List Search Providers\n\n```\nGET /search/provider\n```\n\nGet the list of available search providers.\n\n---\n\n## Program Control\n\n### Get Status\n\n```\nGET /status\n```\n\nGet program status including version, running state, and first_run flag.\n\n**Response:**\n```json\n{\n  \"status\": \"running\",\n  \"version\": \"3.2.0\",\n  \"first_run\": false\n}\n```\n\n### Start Program\n\n```\nGET /start\n```\n\nStart the main program (RSS checking, downloading, renaming).\n\n### Restart Program\n\n```\nGET /restart\n```\n\nRestart the main program.\n\n### Stop Program\n\n```\nGET /stop\n```\n\nStop the main program (WebUI remains accessible).\n\n### Shutdown\n\n```\nGET /shutdown\n```\n\nShutdown the entire application (restarts the Docker container).\n\n### Check Downloader\n\n```\nGET /check/downloader\n```\n\nTest connectivity to the configured downloader (qBittorrent).\n\n---\n\n## Downloader Management <Badge type=\"tip\" text=\"v3.2+\" />\n\nManage torrents in the downloader directly from AutoBangumi.\n\n### List Torrents\n\n```\nGET /downloader/torrents\n```\n\nGet all torrents in the Bangumi category.\n\n### Pause Torrents\n\n```\nPOST /downloader/torrents/pause\n```\n\nPause torrents by hash.\n\n**Request Body:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### Resume Torrents\n\n```\nPOST /downloader/torrents/resume\n```\n\nResume paused torrents by hash.\n\n**Request Body:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### Delete Torrents\n\n```\nPOST /downloader/torrents/delete\n```\n\nDelete torrents with optional file deletion.\n\n**Request Body:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"],\n  \"delete_files\": false\n}\n```\n\n---\n\n## Setup Wizard <Badge type=\"tip\" text=\"v3.2+\" />\n\nThese endpoints are only available during first-run setup (before setup is complete). They do **not** require authentication. After setup completes, all endpoints return `403 Forbidden`.\n\n### Check Setup Status\n\n```\nGET /setup/status\n```\n\nCheck if setup wizard is needed (first run).\n\n**Response:**\n```json\n{\n  \"need_setup\": true\n}\n```\n\n### Test Downloader Connection\n\n```\nPOST /setup/test-downloader\n```\n\nTest connection to a downloader with provided credentials.\n\n**Request Body:**\n```json\n{\n  \"type\": \"qbittorrent\",\n  \"host\": \"172.17.0.1:8080\",\n  \"username\": \"admin\",\n  \"password\": \"adminadmin\",\n  \"ssl\": false\n}\n```\n\n### Test RSS Feed\n\n```\nPOST /setup/test-rss\n```\n\nValidate an RSS feed URL is accessible and parseable.\n\n**Request Body:**\n```json\n{\n  \"url\": \"https://mikanime.tv/RSS/MyBangumi?token=xxx\"\n}\n```\n\n### Test Notification\n\n```\nPOST /setup/test-notification\n```\n\nSend a test notification with provided settings.\n\n**Request Body:**\n```json\n{\n  \"type\": \"telegram\",\n  \"token\": \"bot_token\",\n  \"chat_id\": \"chat_id\"\n}\n```\n\n### Complete Setup\n\n```\nPOST /setup/complete\n```\n\nSave all configuration and mark setup as complete. Creates the sentinel file `config/.setup_complete`.\n\n**Request Body:** Full configuration object.\n\n---\n\n## Logs\n\n### Get Logs\n\n```\nGET /log\n```\n\nRetrieve the full application log file.\n\n### Clear Logs\n\n```\nGET /log/clear\n```\n\nClear the log file.\n\n---\n\n## Response Format\n\nAll API responses follow a consistent format:\n\n```json\n{\n  \"msg_en\": \"Success message in English\",\n  \"msg_zh\": \"Success message in Chinese\",\n  \"status\": true\n}\n```\n\nError responses include appropriate HTTP status codes (400, 401, 403, 404, 500) with error messages in both languages.\n"
  },
  {
    "path": "docs/en/changelog/2.6.md",
    "content": "# [2.6] Release Notes\n\n## Upgrade Notes from Older Versions\n\nStarting from version 2.6, AutoBangumi (AB) configuration has moved from environment variables to `config.json`. Note the following before upgrading.\n\n### Environment Variable Migration\n\nOld environment variables are automatically converted to `config.json` on the first startup after upgrading to 2.6. The generated `config.json` is placed in the `/app/config` folder.\nOnce you've mapped the `/app/config` folder, old environment variables no longer affect AB's operation. You can delete `config.json` to regenerate from environment variables.\n\n### Container Volume Mapping\n\nAfter version 2.6, the following folders need to be mapped:\n\n- `/app/config`: Configuration folder containing `config.json`\n- `/app/data`: Data folder containing `bangumi.json`, etc.\n\n### Data Files\n\nDue to major updates, we don't recommend using old data files. AB will automatically generate a new `bangumi.json` in `/app/data`.\n\nDon't worry — QB won't re-download previously downloaded anime.\n\n### Subsequent Configuration Changes\n\nAB can now edit configuration directly in the WebUI. After editing, restart the container for changes to take effect.\n\n## How to Upgrade\n\n### Docker Compose\n\nYou can use your existing docker-compose.yml file to upgrade:\n\n```bash\ndocker compose stop autobangumi\ndocker compose pull autobangumi\n```\n\nThen modify docker-compose.yml to add volume mappings:\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  autobangumi:\n    image: estrellaxd/auto_bangumi:latest\n    container_name: autobangumi\n    restart: unless-stopped\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=Asia/Shanghai\n    volumes:\n      - /path/to/config:/app/config\n      - /path/to/data:/app/data\n    networks:\n      - bridge\n    dns:\n      - 8.8.8.8\n```\n\nThen start AB:\n\n```bash\ndocker compose up -d autobangumi\n```\n\n### Portainer\n\nIn Portainer, modify the volume mappings and click `Recreate` to complete the upgrade.\n\n### What to do if the upgrade causes issues\n\nSince configurations may vary, upgrades might cause the program to fail. Delete all previous data and generated configuration files, then restart the container and reconfigure in the WebUI.\n\n\n## New Features\n\n### Configuration Method Change\n\nAfter v2.6, program configuration has moved from Docker environment variables to `config.json`.\nThe new WebUI also provides a web-based configuration editor. Access the AB URL and find `Settings` in the sidebar to modify configuration. Restart the container after editing.\n\n### Custom Reverse Proxy URL and AB as Proxy Relay\n\nTo handle situations where [Mikan Project](https://mikanani.me) is inaccessible, AB provides three approaches:\n\n1. HTTP and SOCKS Proxy\n\n    This feature existed in older versions. After upgrading to 2.6, just check the proxy configuration in the WebUI to access Mikan Project normally.\n\n    However, qBittorrent still can't access Mikan's RSS and torrent URLs directly, so you need to add a proxy in qBittorrent as well. See #198 for details.\n\n2. Custom Reverse Proxy URL\n\n    Version 2.6 added a `custom_url` option for custom reverse proxy URLs.\n    Set it to your properly configured reverse proxy URL. AB will use this custom URL to access Mikan Project, and QB can download normally.\n\n3. AB as Proxy Relay\n\n    After configuring a proxy in AB, AB can serve as a local proxy relay (currently only for RSS-related functions).\n    Set `custom_url` to `http://abhost:abport` where `abhost` is AB's IP and `abport` is AB's port.\n    AB will push its own address to qBittorrent, which will use AB as a proxy to access Mikan Project.\n\n    Note: If you haven't set up a reverse proxy for AB with Nginx or similar, include `http://` to ensure proper operation.\n\n**Important Notes**\n\nIf AB and QB are in the same container, don't use `127.0.0.1` or `localhost` as they can't communicate this way.\nIf on the same network, use container name addressing, e.g., `http://autobangumi:7892`.\n\nYou can also use the Docker gateway address, e.g., `http://172.17.0.1:7892`.\n\nIf on different hosts, use the host machine's IP address.\n\n### Collection and Folder Renaming\n\nAB can now rename files within collections and folders, moving media files back to the root directory.\nNote that AB still relies on the save path to determine season and episode information, so place collection files according to AB's standard.\n\nAfter version **2.6.4**, AB can rename subtitles within folders (feature still being refined). Collections and subtitles default to `pn` format renaming; adjustment options are not yet available.\n\n**Standard Path**\n\n```\n/downloads/Bangumi/Title/Season 1/xxx\n```\n\n### Push Notifications\n\nAB can now send rename completion notifications via `Telegram` and `ServerChan`.\n\nIn the WebUI, enable push notifications and fill in the required parameters.\n\n- Telegram requires Bot Token and Chat ID. Refer to various tutorials for obtaining these.\n- ServerChan requires a Token. Refer to various tutorials for obtaining this.\n"
  },
  {
    "path": "docs/en/changelog/3.0.md",
    "content": "# [3.0] Release Notes\n\n### New WebUI\n\n- Login functionality — AB now supports username/password authentication. Some operations require login.\n- New poster wall\n- Bangumi management features\n  - Edit anime season info and names. Changes automatically update **download rules** / **downloaded file paths** and trigger renaming.\n  - New link parser — after parsing a link, you can manually adjust download info, select download season, or add automatic download rules.\n  - Delete anime — one-click deletion of anime and its torrent files.\n  - Custom download rules per anime, independent of global rules.\n- New configuration interface for easier application rule configuration\n- Added initialization page for first-time startup guidance\n- Downloader connection checker for qBittorrent connectivity\n- RSS URL validator to check if RSS feeds are valid\n- Added program management buttons for starting/stopping the program and restarting the container from the WebUI\n\n### Parser\n\n- New parser with support for different source types to obtain official titles and poster URLs\n- Supports changing RSS subscription sources without regenerating the database\n\n### Notification Module\n\n- Added `Bark` notification module\n- New notification format — can now push posters, anime names, and updated episode numbers to Telegram\n\n### Data Migration\n\n- Automatic data migration when upgrading from older versions\n- Migrated data also automatically matches posters\n\n## Fixes\n\n- Fixed renaming bugs that could occur with Windows paths\n\n## Changes\n\n- Migrated from `json` to `sqlite` for data storage\n- Migrated from multiprocessing to multithreading\n  - Refactored main program\n  - Improved startup/shutdown time\n- Refactored parser module\n- Refactored renaming module\n  - Temporarily removed `normal` mode\n- Added `ghcr.io` image registry\n"
  },
  {
    "path": "docs/en/changelog/3.1.md",
    "content": "# [3.1] - 2023-08\n\n- Merged backend and frontend repositories, optimized project directory structure\n- Optimized version release workflow\n- Wiki migrated to VitePress at: https://autobangumi.org\n\n## Backend\n\n### Features\n\n- Added `RSS Engine` module — AB can now independently update and manage RSS subscriptions and send torrents to the downloader\n  - Supports multiple aggregated RSS subscription sources, managed via the RSS Engine module\n  - Download deduplication — duplicate subscribed torrents won't be re-downloaded\n  - Added manual refresh API for RSS subscriptions\n  - Added RSS subscription management API\n- Added `Search Engine` module — search torrents by keyword and parse results into collection or subscription tasks\n  - Plugin-based search engine with support for `mikan`, `dmhy`, and `nyaa`\n- Added subtitle group-specific rules for individual group configurations\n- Added IPv6 listening support (set `IPV6=1` in environment variables)\n- Added batch operations API for bulk rule and RSS subscription management\n\n### Changes\n\n- Database structure changed to `sqlmodel` for database management\n- Added version management for seamless software data updates\n- Unified API format\n- Added API response language options\n- Added database mock tests\n- Code optimizations\n\n### Bugfixes\n\n- Fixed various minor issues\n- Introduced some major issues\n\n## Frontend\n\n### Features\n\n- Added `i18n` support — currently supports `zh-CN` and `en-US`\n- Added PWA support\n- Added RSS management page\n- Added search top bar\n\n### Changes\n\n- Adjusted various UI details\n"
  },
  {
    "path": "docs/en/changelog/3.2.md",
    "content": "# [3.2] - 2025-01\n\n## Backend\n\n### Features\n\n- Added WebAuthn Passkey passwordless login support\n  - Register, authenticate, and manage Passkey credentials\n  - Multi-device credential backup detection (iCloud Keychain, etc.)\n  - Clone attack protection (sign_count verification)\n  - Authentication strategy pattern unifying password and Passkey login interfaces\n  - Usernameless login support via discoverable credentials (resident keys)\n- Added season/episode offset auto-detection\n  - Analyzes TMDB episode air dates to detect \"virtual seasons\" (e.g., Frieren S1 split into two parts)\n  - Auto-identifies different parts when broadcast gap exceeds 6 months\n  - Calculates episode offset (e.g., RSS shows S2E1 → TMDB S1E29)\n  - Background scan thread automatically detects offset issues in existing subscriptions\n  - New API endpoints: `POST /bangumi/detect-offset`, `PATCH /bangumi/dismiss-review/{id}`\n- Added bangumi archive functionality\n  - Manual archive/unarchive support\n  - Auto-archive completed series\n  - New API endpoints: `PATCH /bangumi/archive/{id}`, `PATCH /bangumi/unarchive/{id}`, `GET /bangumi/refresh/metadata`\n- Added search provider configuration API\n  - `GET /search/provider/config` - Get search provider config\n  - `PUT /search/provider/config` - Update search provider config\n- Added RSS connection status tracking\n  - Records `connection_status` (healthy/error), `last_checked_at`, and `last_error` after each refresh\n- Added first-run setup wizard\n  - 7-step guided configuration: account, downloader, RSS source, media path, notifications\n  - Downloader connection test, RSS source validation\n  - Optional steps can be skipped and configured later in settings\n  - Sentinel file mechanism (`config/.setup_complete`) prevents re-triggering\n  - Unauthenticated setup API (only available on first run, returns 403 after completion)\n- Added calendar view with Bangumi.tv broadcast schedule integration\n- Added downloader API and management interface\n- Full async migration\n  - Database layer async support (aiosqlite) for non-blocking I/O in Passkey operations\n  - `UserDatabase` supports both sync/async modes for backward compatibility\n  - `Database` context manager supports both `with` (sync) and `async with` (async)\n  - RSS engine, downloader, checker, and parser fully converted to async\n  - Network requests migrated from `requests` to `httpx` (AsyncClient)\n- Backend migrated to `uv` package manager (pyproject.toml + uv.lock)\n- Server startup uses background tasks to avoid blocking (fixes #891, #929)\n- Database migration auto-fills NULL values with model defaults\n- Database adds `needs_review` and `needs_review_reason` fields for offset detection\n\n### Performance\n\n- Shared HTTP client connection pool, reuses TCP/SSL connections\n- RSS refresh now concurrent (`asyncio.gather`), ~10x faster with multiple sources\n- Torrent file download now concurrent, ~5x faster for multiple torrents\n- Rename module concurrent file list fetching, ~20x faster\n- Notification sending now concurrent, removed 2-second hardcoded delay\n- Added TMDB and Mikan parser result caching to avoid duplicate API calls\n- Database indexes added for `Torrent.url`, `Torrent.rss_id`, `Bangumi.title_raw`, `Bangumi.deleted`, `RSSItem.url`\n- RSS batch enable/disable uses single transaction instead of per-item commits\n- Pre-compiled regex patterns for torrent name parsing and filter matching\n- `SeasonCollector` created outside loops, reuses single authentication\n- RSS parsing deduplication changed from O(n²) list lookup to O(1) set lookup\n- `Episode`/`SeasonInfo` dataclasses use `__slots__` for reduced memory footprint\n\n### Changes\n\n- Upgraded WebAuthn dependency to py_webauthn 2.7.0\n- `_get_webauthn_from_request` prioritizes browser Origin header, fixing verification issues in cross-port development environments\n- `auth_user` and `update_user_info` converted to async functions\n- `TitleParser.tmdb_parser` converted to async function\n- `RSSEngine` methods fully async (`pull_rss`, `refresh_rss`, `download_bangumi`, `add_rss`)\n- `Checker.check_downloader` converted to async function\n- `ProgramStatus` migrated from threading to asyncio (Event, Lock)\n\n### Bugfixes\n\n- Fixed downloader connection check with max retry limit\n- Fixed transient network errors when adding torrents with retry logic\n- Fixed multiple issues in search and subscription flow\n- Improved torrent fetch reliability and error handling\n- Fixed `aaguid` type error (now `str` in py_webauthn 2.7.0, no longer `bytes`)\n- Fixed missing `credential_backup_eligible` field (replaced with `credential_device_type`)\n- Fixed `verify_authentication_response` receiving invalid `credential_id` parameter causing TypeError\n- Fixed program startup blocking the server (fixes #891, #929, #886, #917, #946)\n- Fixed search interface export not matching component expectations\n- Fixed poster endpoint path check incorrectly intercepting all requests (fixes #933, #934)\n- Fixed OpenAI parser security issue\n- Fixed database tests using async sessions with sync code mismatch\n- Fixed config field conflicts when upgrading from 3.1.x to 3.2 causing settings loss (fixes #956)\n  - `program.sleep_time` / `program.times` auto-migrated to `rss_time` / `rename_time`\n  - Removed deprecated `rss_parser` fields (`type`, `custom_url`, `token`, `enable_tmdb`)\n  - Fixed `ENV_TO_ATTR` environment variable mapping pointing to non-existent model fields\n  - Fixed `DEFAULT_SETTINGS` inconsistency with current config model\n- Fixed version upgrade migration logic errors (all upgrades calling 3.0→3.1 migration)\n  - Added version-aware migration dispatch based on source version\n  - Added `from_31_to_32()` migration function for database schema changes\n\n## Frontend\n\n### Features\n\n- Complete UI design system redesign\n  - Unified design tokens (colors, fonts, spacing, shadows, animations)\n  - Light/dark theme toggle support\n  - Comprehensive accessibility support (ARIA, keyboard navigation, focus management)\n  - Responsive layout for mobile devices\n- Added first-run setup wizard page\n  - Multi-step wizard component (progress bar + step navigation)\n  - Route guard auto-detection and redirect to setup page\n  - Downloader/RSS/notification connection test feedback\n  - Chinese and English i18n support\n- Added Passkey management panel (settings page)\n  - WebAuthn browser support detection\n  - Automatic device name identification\n  - Passkey list display and deletion\n- Added Passkey fingerprint login button on login page (supports usernameless login)\n- Added calendar view page\n- Added downloader management page\n- Added Bangumi card hover overlay (showing title and tags)\n- Added `resolvePosterUrl` utility function for unified external URL and local path handling (fixes #934)\n- Redesigned search panel with modal and filter system\n- Redesigned login panel with modern glassmorphism style\n- Added log level filter in log view\n- Redesigned LLM settings panel (fixes #938)\n- Redesigned settings, downloader, player, and log page styles\n- Added search provider settings panel\n  - View, add, edit, delete search sources in UI\n  - Default sources (mikan, nyaa, dmhy) cannot be deleted\n  - URL template validation ensures `%s` placeholder\n- Added iOS-style notification badge system\n  - Yellow badge + purple border for subscriptions needing review\n  - Combined display support (e.g., `! | 2` for warning + multiple rules)\n  - Yellow glow animation on cards needing attention\n- Edit modal warning banner with one-click auto-detect and dismiss\n- Rule selection modal highlights rules with warnings\n- Calendar page bangumi grouping: same anime with multiple rules merged, click to select specific rule\n- Bangumi list page collapsible \"Archived\" section\n- Bangumi list page skeleton loading animation\n- Rule editor episode offset field with \"Auto Detect\" button\n- RSS management page connection status labels: green \"Connected\" when healthy, red \"Error\" with tooltip for details\n- New mobile-first responsive design\n  - Three-tier breakpoint system: mobile (<640px), tablet (640-1023px), desktop (≥1024px)\n  - Mobile bottom navigation bar (with icons and text labels)\n  - Tablet mini sidebar (56px icon navigation)\n  - Mobile popups automatically switch to bottom sheets\n  - Pull-to-refresh support\n  - Horizontal swipe container support\n  - Mobile card list replacing data tables (RSS page)\n  - CSS Grid responsive layout (Bangumi card grid)\n  - Form labels stack vertically on mobile, full-width inputs\n  - Touch targets minimum 44px, meeting accessibility standards\n  - Safe area support (notched devices)\n  - `100dvh` dynamic viewport height (fixes mobile browser address bar issue)\n  - `viewport-fit=cover` for full-screen devices\n\n### New Components\n\n- `ab-bottom-sheet` — Touch-driven bottom sheet component (drag to close, max height limit)\n- `ab-adaptive-modal` — Adaptive modal (bottom sheet on mobile / centered dialog on desktop)\n- `ab-pull-refresh` — Pull-to-refresh wrapper component\n- `ab-swipe-container` — Horizontal swipe container (CSS scroll-snap)\n- `ab-data-list` — Mobile-friendly card list (replacing NDataTable)\n- `ab-mobile-nav` — Enhanced bottom navigation bar (icon + label + active indicator)\n- `useSafeArea` — Safe area composable\n\n### Performance\n\n- Downloader store uses `shallowRef` instead of `ref` to avoid deep reactive proxy on large arrays\n- Table column definitions moved to `computed` to avoid rebuilding on each render\n- RSS table columns separated from data, column config not rebuilt on data changes\n- Calendar page removed duplicate `getAll()` calls\n- `ab-select` `watchEffect` changed to `watch`, eliminates invalid emit on mount\n- `useClipboard` hoisted to store top level, avoids creating new instance on each `copy()`\n- `setInterval` replaced with `useIntervalFn` for automatic lifecycle management\n\n### Changes\n\n- Refactored search logic, removed rxjs dependency\n- Search store export refactored to match component expectations\n- Upgraded frontend dependencies\n- Breakpoint system expanded from single 1024px to 640px + 1024px two-tier\n- `useBreakpointQuery` added `isTablet`, `isMobileOrTablet`, `isTabletOrPC`\n- `media-query.vue` added `#tablet` slot (falls back to `#mobile`)\n- UnoCSS added `sm: 640px` breakpoint\n- `ab-input` mobile full-width + increased touch target styling\n- Layout uses `dvh` units instead of `vh`, supports safe-area-inset\n- Fixed calendar page unknown column width\n- Unified action bar button sizes in downloader page\n\n## CI/Infrastructure\n\n- CI added build test on PR open (dev branch PRs to main auto-trigger build)\n- CI upgraded `actions/upload-artifact` and `actions/download-artifact` to v4\n- Docker build removed `linux/arm/v7` platform (uv image doesn't support it)\n- Added CLAUDE.md development guide\n"
  },
  {
    "path": "docs/en/config/downloader.md",
    "content": "# Downloader Settings\n\n## WebUI Configuration\n\n![downloader](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **Downloader Type** is the downloader type. Currently only qBittorrent is supported.\n- **Host** is the downloader address. [See below](#downloader-address)\n- **Download path** is the mapped download path for the downloader. [See below](#download-path-issues)\n- **SSL** enables SSL for the downloader connection.\n\n## Common Issues\n\n### Downloader Address\n\n::: warning Note\nDo not use 127.0.0.1 or localhost as the downloader address.\n:::\n\nSince AB runs in Docker with **Bridge** mode in the official tutorial, using 127.0.0.1 or localhost will resolve to AB itself, not the downloader.\n- If your qBittorrent also runs in Docker, we recommend using the Docker **gateway address: 172.17.0.1**.\n- If your qBittorrent runs on the host machine, use the host machine's IP address.\n\nIf you run AB in **Host** mode, you can use 127.0.0.1 instead of the Docker gateway address.\n\n::: warning Note\nMacvlan isolates container networks. Without additional bridge configuration, containers cannot access other containers or the host itself.\n:::\n\n### Download Path Issues\n\nThe path configured in AB is only used to generate the corresponding anime file path. AB itself does not directly manage files at that path.\n\n**What should I put for the download path?**\n\nThis parameter just needs to match your **downloader's** configuration:\n- Docker: If qB uses `/downloads`, then set `/downloads/Bangumi`. You can change `Bangumi` to anything.\n- Linux/macOS: If it's `/home/usr/downloads` or `/User/UserName/Downloads`, just append `/Bangumi` at the end.\n- Windows: Change `D:\\Media\\` to `D:\\Media\\Bangumi`\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `downloader`\n\n| Parameter | Description          | Type    | WebUI Option          | Default              |\n|-----------|---------------------|---------|----------------------|---------------------|\n| type      | Downloader type     | String  | Downloader type      | qbittorrent         |\n| host      | Downloader address  | String  | Downloader address   | 172.17.0.1:8080     |\n| username  | Downloader username | String  | Downloader username  | admin               |\n| password  | Downloader password | String  | Downloader password  | adminadmin          |\n| path      | Download path       | String  | Download path        | /downloads/Bangumi  |\n| ssl       | Enable SSL          | Boolean | Enable SSL           | false               |\n"
  },
  {
    "path": "docs/en/config/experimental.md",
    "content": "# Experimental Features\n\n::: warning\nExperimental features are still in testing. Enabling them may cause unexpected issues and they may be removed in future versions. Use with caution!\n:::\n\n## OpenAI ChatGPT\n\nUse OpenAI ChatGPT for better structured title parsing. For example:\n\n```\ninput: \"【喵萌奶茶屋】★04月新番★[夏日重现/Summer Time Rendering][11][1080p][繁日双语][招募翻译]\"\noutput: '{\"group\": \"喵萌奶茶屋\", \"title_en\": \"Summer Time Rendering\", \"resolution\": \"1080p\", \"episode\": 11, \"season\": 1, \"title_zh\": \"夏日重现\", \"sub\": \"\", \"title_jp\": \"\", \"season_raw\": \"\", \"source\": \"\"}'\n```\n\n![experimental OpenAI](/image/config/experimental-openai.png){width=500}{class=ab-shadow-card}\n\n- **Enable OpenAI** enables OpenAI and uses ChatGPT for title parsing.\n- **OpenAI API Type** defaults to OpenAI.\n- **OpenAI API Key** is your OpenAI account API key.\n- **OpenAI API Base URL** is the OpenAI endpoint. Defaults to the official OpenAI URL; you can change it to a compatible third-party endpoint.\n- **OpenAI Model** is the ChatGPT model parameter. Currently provides `gpt-3.5-turbo`, which is affordable and produces excellent results with the right prompts.\n\n## Microsoft Azure OpenAI\n\n\n![experimental Microsoft Azure OpenAI](/image/config/experimental-azure-openai.png){width=500}{class=ab-shadow-card}\n\nIn addition to standard OpenAI, [version 3.1.8](https://github.com/EstrellaXD/Auto_Bangumi/releases/tag/3.1.8) added Microsoft Azure OpenAI support. Usage is similar to standard OpenAI with some shared parameters, but note the following:\n\n- **Enable OpenAI** enables OpenAI and uses ChatGPT for title parsing.\n- **OpenAI API Type** — Select `azure` to show Azure-specific options.\n- **OpenAI API Key** is your Microsoft Azure OpenAI API key.\n- **OpenAI API Base URL** corresponds to the Microsoft Azure OpenAI Entrypoint. **Must be filled in manually**.\n- **Azure OpenAI Version** is the API version. Defaults to `2023-05-15`. See [supported versions](https://learn.microsoft.com/en-us/azure/ai-services/openai/reference#completions).\n- **Azure OpenAI Deployment ID** is your deployment ID, usually the same as the model name. Note that Azure OpenAI doesn't support symbols other than `_-`, so `gpt-3.5-turbo` becomes `gpt-35-turbo` in Azure. **Must be filled in manually**.\n\nReference documentation:\n\n- [Quickstart: Get started using GPT-35-Turbo and GPT-4 with Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line&pivots=programming-language-python)\n- [Learn how to work with the GPT-35-Turbo and GPT-4 models](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions)\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `experimental_openai`\n\n| Parameter     | Description              | Type    | WebUI Option               | Default                    |\n|---------------|--------------------------|---------|---------------------------|---------------------------|\n| enable        | Enable OpenAI parser     | Boolean | Enable OpenAI             | false                     |\n| api_type      | OpenAI API type          | String  | API type (`openai`/`azure`) | openai                  |\n| api_key       | OpenAI API key           | String  | OpenAI API Key            |                           |\n| api_base      | API Base URL (Azure entrypoint) | String | OpenAI API Base URL  | https://api.openai.com/v1 |\n| model         | OpenAI model             | String  | OpenAI Model              | gpt-3.5-turbo             |\n| api_version   | Azure OpenAI API version | String  | Azure API Version         | 2023-05-15                |\n| deployment_id | Azure Deployment ID      | String  | Azure Deployment ID       |                           |\n"
  },
  {
    "path": "docs/en/config/manager.md",
    "content": "# Bangumi Manager Settings\n\n## WebUI Configuration\n\n![proxy](/image/config/manager.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **Enable** enables the bangumi manager. If disabled, the settings below will not take effect.\n- **Rename method** is the renaming method. Currently supported:\n  - `pn` — `Torrent Title S0XE0X.mp4` format\n  - `advance` — `Official Title S0XE0X.mp4` format\n  - `none` — No renaming\n- **Eps complete** enables episode completion for the current season. If enabled, missing episodes will be downloaded.\n- **Add group tag** adds subtitle group tags to download rules.\n- **Delete bad torrent** deletes errored torrents.\n- [About file paths][1]\n- [About renaming][2]\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `bangumi_manager`\n\n| Parameter          | Description                    | Type    | WebUI Option      | Default |\n|--------------------|------------------------------ |---------|-------------------|---------|\n| enable             | Enable bangumi manager        | Boolean | Enable manager    | true    |\n| eps_complete       | Enable episode completion     | Boolean | Episode completion | false  |\n| rename_method      | Rename method                 | String  | Rename method     | pn      |\n| group_tag          | Add subtitle group tag        | Boolean | Group tag         | false   |\n| remove_bad_torrent | Delete bad torrents           | Boolean | Remove bad torrent | false  |\n\n\n[1]: https://www.autobangumi.org/faq/#download-path\n[2]: https://www.autobangumi.org/faq/#file-renaming\n"
  },
  {
    "path": "docs/en/config/notifier.md",
    "content": "# Notification Settings\n\n## WebUI Configuration\n\n![notification](/image/config/notifier.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **Enable** enables notifications. If disabled, the settings below will not take effect.\n- **Type** is the notification type. Currently supported:\n  - Telegram\n  - Wecom\n  - Bark\n  - ServerChan\n- **Chat ID** only needs to be filled in when using `telegram` notifications. [How to get Telegram Bot Chat ID][1]\n- **Wecom**: Fill in the custom push URL in the Chat ID field, and add [Rich Text Message][2] type on the server side. [Wecom Configuration Guide][3]\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `notification`\n\n| Parameter | Description       | Type    | WebUI Option     | Default  |\n|-----------|------------------|---------|-----------------|----------|\n| enable    | Enable notifications | Boolean | Notifications   | false    |\n| type      | Notification type | String  | Notification type | telegram |\n| token     | Notification token | String | Notification token |         |\n| chat_id   | Notification Chat ID | String | Notification Chat ID |       |\n\n\n[1]: https://core.telegram.org/bots#6-botfather\n[2]: https://github.com/umbors/wecomchan-alifun\n[3]: https://github.com/easychen/wecomchan\n"
  },
  {
    "path": "docs/en/config/parser.md",
    "content": "# Parser Settings\n\nAB's parser is used to parse aggregated RSS links. When new entries appear in the RSS feed, AB will parse the titles and generate automatic download rules.\n\n::: tip\nSince v3.1, parser settings have moved to individual RSS settings. To configure the **parser type**, see [Setting up parser for RSS][add_rss].\n:::\n\n## Parser Settings in WebUI\n\n![parser](/image/config/parser.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **Enable**: Whether to enable the RSS parser.\n- **Language** is the RSS parser language. Currently supports `zh`, `jp`, and `en`.\n- **Exclude** is the global RSS parser filter. You can enter strings or regular expressions, and AB will filter out matching entries during RSS parsing.\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `rss_parser`\n\n| Parameter | Description           | Type    | WebUI Option         | Default        |\n|-----------|-----------------------|---------|---------------------|----------------|\n| enable    | Enable RSS parser     | Boolean | Enable RSS parser   | true           |\n| filter    | RSS parser filter     | Array   | Filter              | [720,\\d+-\\d+] |\n| language  | RSS parser language   | String  | RSS parser language | zh             |\n\n\n[rss_token]: rss\n[add_rss]: /feature/rss#parser-settings\n[reproxy]: proxy#reverse-proxy\n"
  },
  {
    "path": "docs/en/config/program.md",
    "content": "# Program Settings\n\n## WebUI Configuration\n\n![program](/image/config/program.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- Interval Time parameters are in seconds. Convert to seconds if you need to set minutes.\n- RSS is the RSS check interval, which affects how often automatic download rules are generated.\n- Rename is the rename check interval. Modify this if you need to change how often renaming is checked.\n- WebUI Port is the port number. Note that if you're using Docker, you need to remap the port in Docker after changing it.\n\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `program`\n\n| Parameter   | Description          | Type            | WebUI Option         | Default |\n|-------------|---------------------|-----------------|---------------------|---------|\n| rss_time    | RSS check interval  | Integer (seconds) | RSS check interval  | 7200    |\n| rename_time | Rename check interval | Integer (seconds) | Rename check interval | 60    |\n| webui_port  | WebUI port          | Integer         | WebUI port          | 7892    |\n"
  },
  {
    "path": "docs/en/config/proxy.md",
    "content": "# Proxy and Reverse Proxy\n\n## Proxy\n\n![proxy](/image/config/proxy.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\nAB supports HTTP and SOCKS5 proxies to help resolve network issues.\n\n- **Enable**: Whether to enable the proxy.\n- **Type** is the proxy type.\n- **Host** is the proxy address.\n- **Port** is the proxy port.\n\n::: tip\nIn **SOCKS5** mode, username and password are required.\n:::\n\n## `config.json` Configuration Options\n\nThe corresponding options in the configuration file are:\n\nConfiguration section: `proxy`\n\n| Parameter | Description    | Type    | WebUI Option   | Default |\n|-----------|---------------|---------|---------------|---------|\n| enable    | Enable proxy  | Boolean | Proxy         | false   |\n| type      | Proxy type    | String  | Proxy type    | http    |\n| host      | Proxy address | String  | Proxy address |         |\n| port      | Proxy port    | Integer | Proxy port    |         |\n| username  | Proxy username | String | Proxy username |        |\n| password  | Proxy password | String | Proxy password |        |\n\n## Reverse Proxy\n\n- Use the Mikan Project alternative domain `mikanime.tv` to replace `mikanani.me` in your RSS subscription URL.\n- Use a Cloudflare Worker as a reverse proxy and replace all `mikanani.me` domains in the RSS feed.\n\n## Cloudflare Workers\n\nBased on the approach used to bypass blocks on other services, you can set up a reverse proxy using Cloudflare Workers. How to register a domain and bind it to Cloudflare is beyond the scope of this guide. Add the following code in Workers to use your own domain to access Mikan Project and download torrents from RSS links:\n\n```js\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // Only perform replacement if content type is RSS\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n"
  },
  {
    "path": "docs/en/config/rss.md",
    "content": "# RSS Feed Setup\n\nAutoBangumi can automatically parse aggregated anime RSS feeds and generate download rules based on subtitle groups and anime names, enabling fully automatic anime tracking.\nThe following uses [Mikan Project][mikan-site] as an example to explain how to get an RSS subscription URL.\n\nNote that the main Mikan Project site may be blocked in some regions. If you cannot access it without a proxy, use the following alternative domain:\n\n[Mikan Project (Alternative)][mikan-cn-site]\n\n## Get Subscription URL\n\nThis project is based on parsing RSS URLs provided by Mikan Project. To enable automatic anime tracking, you need to register and obtain a Mikan Project RSS URL:\n\n![image](/image/rss/rss-token.png){data-zoomable}\n\nThe RSS URL will look like:\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# or\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n## Mikan Project Subscription Tips\n\nSince AutoBangumi parses all RSS entries it receives, keep the following in mind when subscribing:\n\n![image](/image/rss/advanced-subscription.png){data-zoomable}\n\n- Enable advanced settings in your profile settings.\n- Subscribe to only one subtitle group per anime. Click the anime poster on Mikan Project to open the submenu and select a single subtitle group.\n- If a subtitle group offers both Simplified and Traditional Chinese subtitles, Mikan Project usually provides a way to choose. Select one subtitle type.\n- If no subtitle type selection is available, you can set up a `filter` in AutoBangumi to filter them, or manually filter in qBittorrent after the rule is generated.\n- OVA and movie subscriptions are currently not supported for parsing.\n\n\n[mikan-site]: https://mikanani.me/\n[mikan-cn-site]: https://mikanime.tv/\n"
  },
  {
    "path": "docs/en/deploy/docker-cli.md",
    "content": "# Deploy with Docker CLI\n\n## Note on New Versions\n\nSince AutoBangumi 2.6, you can configure everything directly in the WebUI. You can start the container first and then configure it in the WebUI. Environment variable configuration from older versions will be automatically migrated. Environment variables still work but only take effect on the first startup.\n\n## Create Data and Configuration Directories\n\nTo ensure AB's data and configuration persist across updates, we recommend using Docker volumes or bind mounts.\n\n```shell\n# Using bind mount\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\nChoose either bind mount or Docker volume:\n```shell\n# Using Docker volume\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## Deploy AutoBangumi with Docker CLI\n\nCopy and run the following command.\n\nMake sure your working directory is AutoBangumi.\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Shanghai \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\nIf using Docker volumes, replace the bind paths accordingly:\n```shell\n  -v AutoBangumi_config:/app/config \\\n  -v AutoBangumi_data:/app/data \\\n```\n\nThe AB WebUI will start automatically, but the main program will be paused. Access `http://abhost:7892` to configure it.\n\nAB will automatically write environment variables to `config.json` and start running.\n\nWe recommend using _[Portainer](https://www.portainer.io)_ or similar Docker management UIs for advanced deployment.\n"
  },
  {
    "path": "docs/en/deploy/docker-compose.md",
    "content": "# Deploy with Docker Compose\n\nA one-click deployment method for **AutoBangumi** using a `docker-compose.yml` file.\n\n## Install Docker Compose\n\nDocker Compose usually comes bundled with Docker. Check with:\n\n```bash\ndocker compose -v\n```\n\nIf not installed, install it with:\n\n```bash\n$ sudo apt-get update\n$ sudo apt-get install docker-compose-plugin\n```\n\n## Deploy **AutoBangumi**\n\n### Create AutoBangumi and Data Directories\n\n```bash\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\n### Option 1: Custom Docker Compose Configuration\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\nCopy the above content into a `docker-compose.yml` file.\n\n### Option 2: Download Docker Compose Configuration File\n\nIf you don't want to create the `docker-compose.yml` file manually, the project provides pre-made configurations:\n\n- Install **AutoBangumi** only:\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/AutoBangumi/docker-compose.yml\n  ```\n- Install **qBittorrent** and **AutoBangumi**:\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml\n  ```\n\nChoose your installation method and run the command to download the `docker-compose.yml` file. You can customize parameters with a text editor if needed.\n\n### Define Environment Variables\n\nIf you're using the downloaded AB+QB Docker Compose file, you need to define the following environment variables:\n\n```shell\nexport \\\nQB_PORT=<YOUR_PORT>\n```\n\n- `QB_PORT`: Enter your existing qBittorrent port or your desired custom port, e.g., `8080`\n\n### Start Docker Compose\n\n```bash\ndocker compose up -d\n```\n"
  },
  {
    "path": "docs/en/deploy/dsm.md",
    "content": "# Synology NAS (DSM 7.2) Deployment (QNAP Similar)\n\nDSM 7.2 supports Docker Compose, so we recommend using Docker Compose for one-click deployment.\n\n## Create Configuration and Data Directories\n\nCreate an `AutoBangumi` folder under `/volume1/docker/`, then create `config` and `data` subfolders inside it.\n\n## Install Container Manager (Docker) Package\n\nOpen Package Center and install the Container Manager (Docker) package.\n\n![install-docker](/image/dsm/install-docker.png){data-zoomable}\n\n## Install AB via Docker Compose\n\nClick **Project**, then click **Create**, and select **Docker Compose**.\n\n![new-compose](/image/dsm/new-compose.png){data-zoomable}\n\nCopy and paste the following content into **Docker Compose**:\n```yaml\nversion: \"3.4\"\n\nservices:\n  ab:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: \"auto_bangumi\"\n    restart: unless-stopped\n    ports:\n      - \"7892:7892\"\n    volumes:\n      - \"./config:/app/config\"\n      - \"./data:/app/data\"\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - AB_METHOD=Advance\n      - PGID=1000\n      - PUID=1000\n      - UMASK=022\n```\n\nClick **Next**, then click **Done**.\n\n![create](/image/dsm/create.png){data-zoomable}\n\nAfter creation, access `http://<NAS IP>:7892` to enter AB and configure it.\n\n## Install AB and qBittorrent via Docker Compose\n\nWhen you have both a proxy and IPv6, configuring IPv6 in Docker on Synology NAS can be complex. We recommend installing both AB and qBittorrent on the host network to reduce complexity.\n\nThe following configuration assumes you have a Clash proxy deployed in Docker that is accessible via a local IP at a specified port.\n\nFollowing the previous section, adjust and paste the following content into **Docker Compose**:\n\n```yaml\n  qbittorrent:\n    container_name: qbittorrent\n    image: linuxserver/qbittorrent\n    hostname: qbittorrent\n    environment:\n      - PGID=1000  # Modify as needed\n      - PUID=1000  # Modify as needed\n      - WEBUI_PORT=8989\n      - TZ=Asia/Shanghai\n    volumes:\n      - ./qb_config:/config\n      - your_anime_path:/downloads # Change this to your anime storage directory. Set download path in AB as /downloads\n    networks:\n      - host\n    restart: unless-stopped\n\n  auto_bangumi:\n    container_name: AutoBangumi\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=1000  # Modify as needed\n      - PUID=1000  # Modify as needed\n      - UMASK=022\n      - AB_DOWNLOADER_HOST=127.0.0.1:8989  # Modify port as needed\n    volumes:\n      - /volume1/docker/ab/config:/app/config\n      - /volume1/docker/ab/data:/app/data\n    network_mode: host\n    environment:\n      - AB_METHOD=Advance\n    dns:\n      - 8.8.8.8\n    restart: unless-stopped\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    depends_on:\n      - qbittorrent\n\n```\n\n## Additional Notes\n\nThe PGID and PUID values need to be determined for your system. For newer Synology NAS devices, they are typically: `PUID=1026, PGID=100`. When modifying the qBittorrent port, make sure to update it in all locations.\n\nFor proxy setup, refer to: [Proxy Settings](../config/proxy)\n\nOn lower-performance machines, the default configuration may heavily use the CPU, causing AB to fail connecting to qB and the qB WebUI to become inaccessible.\n\nFor devices like the 220+, recommended qBittorrent settings to reduce CPU usage:\n\n- Settings -> Connections -> Connection Limits\n  - Global maximum number of connections: 300\n  - Maximum number of connections per torrent: 60\n  - Global upload slots limit: 15\n  - Upload slots per torrent: 4\n- BitTorrent\n  - Maximum active checking torrents: 1\n  - Torrent Queueing\n    - Maximum active downloads: 3\n    - Maximum active uploads: 5\n    - Maximum active torrents: 10\n- RSS\n  - RSS Reader\n    - Maximum number of articles per feed: 50\n"
  },
  {
    "path": "docs/en/deploy/local.md",
    "content": "# Local Deployment\n\n::: warning\nLocal deployment may cause unexpected issues. We strongly recommend using Docker instead.\n\nThis documentation may have update delays. If you have questions, please raise them in [Issues](https://github.com/EstrellaXD/Auto_Bangumi/issues).\n:::\n\n## Download the Latest Release\n\n```bash\nVERSION=$(curl -s \"https://api.github.com/repos/EstrellaXD/Auto_Bangumi/releases/latest\" | grep '\"tag_name\":' | sed -E 's/.*\"([^\"]+)\".*/\\1/')\ncurl -L -O \"https://github.com/EstrellaXD/Auto_Bangumi/releases/download/$VERSION/app-v$VERSION.zip\"\n```\n\n## Extract the Archive\n\nOn Unix/WSL systems, use the following command. On Windows, extract manually.\n\n```bash\nunzip app-v$VERSION.zip -d AutoBangumi\ncd AutoBangumi\n```\n\n\n## Create Virtual Environment and Install Dependencies\n\nEnsure you have Python 3.10+ and pip installed locally.\n\n```bash\ncd src\npython3 -m venv env\npython3 pip install -r requirements.txt\n```\n\n## Create Configuration and Data Directories\n\n```bash\nmkdir config\nmkdir data\n```\n\n## Run AutoBangumi\n\n```bash\npython3 main.py\n```\n\n\n## Windows Auto-Start on Boot\n\nYou can use `nssm` for auto-start on boot. Example with `nssm`:\n\n```powershell\nnssm install AutoBangumi (Get-Command python).Source\nnssm set AutoBangumi AppParameters (Get-Item .\\main.py).FullName\nnssm set AutoBangumi AppDirectory (Get-Item ..).FullName\nnssm set AutoBangumi Start SERVICE_DELAYED_AUTO_START\n```\n"
  },
  {
    "path": "docs/en/deploy/quick-start.md",
    "content": "# Quick Start\n\nWe recommend deploying AutoBangumi in Docker.\nBefore deployment, make sure you have [Docker Engine][docker-engine] or [Docker Desktop][docker-desktop] installed.\n\n## Create Data and Configuration Directories\n\nTo ensure AB's data and configuration persist across updates, we recommend using bind mounts or Docker volumes.\n\n```shell\n# Using bind mount\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\nChoose either bind mount or Docker volume:\n\n```shell\n# Using Docker volume\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## Deploy AutoBangumi with Docker\n\nMake sure you are in the AutoBangumi directory when running these commands.\n\n### Option 1: Deploy with Docker CLI\n\nCopy and run the following command:\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Shanghai \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\n### Option 2: Deploy with Docker Compose\n\nCopy the following content into a `docker-compose.yml` file:\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    network_mode: bridge\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\nRun the following command to start the container:\n\n```shell\ndocker compose up -d\n```\n\n## Install qBittorrent\n\nIf you haven't installed qBittorrent, please install it first:\n\n- [Install qBittorrent in Docker][qbittorrent-docker]\n- [Install qBittorrent on Windows/macOS][qbittorrent-desktop]\n- [Install qBittorrent-nox on Linux][qbittorrent-nox]\n\n## Get an Aggregated RSS Link (Using Mikan Project as an Example)\n\nVisit [Mikan Project][mikan-project], register an account and log in, then click the **RSS** button in the bottom right corner and copy the link.\n\n![mikan-rss](/image/rss/rss-token.png){data-zoomable}\n\nThe RSS URL will look like:\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# or\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\nFor detailed steps, see [Mikan RSS Setup][config-rss].\n\n\n## Configure AutoBangumi\n\nAfter installing AB, the WebUI will start automatically, but the main program will be paused. You can access `http://abhost:7892` to configure it.\n\n1. Open the webpage. The default username is `admin` and the default password is `adminadmin`. Change these immediately after first login.\n2. Enter your downloader's address, port, username, and password.\n\n![ab-webui](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n3. Click **Apply** to save the configuration. AB will restart, and when the dot in the upper right corner turns green, it indicates AB is running normally.\n\n4. Click the **+** button in the upper right corner, check **Aggregated RSS**, select the parser type, and enter your Mikan RSS URL.\n\n![ab-rss](/image/config/add-rss.png){width=500}{class=ab-shadow-card}\n\nWait for AB to parse the aggregated RSS. Once parsing is complete, it will automatically add anime and manage downloads.\n\n\n\n[docker-engine]: https://docs.docker.com/engine/install/\n[docker-desktop]: https://www.docker.com/products/docker-desktop\n[config-rss]: ../config/rss\n[mikan-project]: https://mikanani.me/\n[qbittorrent-docker]: https://hub.docker.com/r/superng6/qbittorrent\n[qbittorrent-desktop]: https://www.qbittorrent.org/download\n[qbittorrent-nox]: https://www.qbittorrent.org/download-nox\n"
  },
  {
    "path": "docs/en/dev/database.md",
    "content": "# Database Developer Guide\n\nThis guide covers the database architecture, models, and operations in AutoBangumi.\n\n## Overview\n\nAutoBangumi uses **SQLite** as its database with **SQLModel** (Pydantic + SQLAlchemy hybrid) for ORM. The database file is located at `data/data.db`.\n\n### Architecture\n\n```\nmodule/database/\n├── engine.py       # SQLAlchemy engine configuration\n├── combine.py      # Database class, migrations, session management\n├── bangumi.py      # Bangumi (anime subscription) operations\n├── rss.py          # RSS feed operations\n├── torrent.py      # Torrent tracking operations\n└── user.py         # User authentication operations\n```\n\n## Core Components\n\n### Database Class\n\nThe `Database` class in `combine.py` is the main entry point. It inherits from SQLModel's `Session` and provides access to all sub-databases:\n\n```python\nfrom module.database import Database\n\nwith Database() as db:\n    # Access sub-databases\n    bangumis = db.bangumi.search_all()\n    rss_items = db.rss.search_active()\n    torrents = db.torrent.search_all()\n```\n\n### Sub-Database Classes\n\n| Class | Model | Purpose |\n|-------|-------|---------|\n| `BangumiDatabase` | `Bangumi` | Anime subscription rules |\n| `RSSDatabase` | `RSSItem` | RSS feed sources |\n| `TorrentDatabase` | `Torrent` | Downloaded torrent tracking |\n| `UserDatabase` | `User` | Authentication |\n\n## Models\n\n### Bangumi Model\n\nCore model for anime subscriptions:\n\n```python\nclass Bangumi(SQLModel, table=True):\n    id: int                          # Primary key\n    official_title: str              # Display name (e.g., \"Mushoku Tensei\")\n    title_raw: str                   # Raw title for torrent matching (indexed)\n    season: int = 1                  # Season number\n    episode_offset: int = 0          # Episode numbering adjustment\n    season_offset: int = 0           # Season numbering adjustment\n    rss_link: str                    # Comma-separated RSS feed URLs\n    filter: str                      # Exclusion filter (e.g., \"720,\\\\d+-\\\\d+\")\n    poster_link: str                 # TMDB poster URL\n    save_path: str                   # Download destination path\n    rule_name: str                   # qBittorrent RSS rule name\n    added: bool = False              # Whether rule is added to downloader\n    deleted: bool = False            # Soft delete flag (indexed)\n    archived: bool = False           # For completed series (indexed)\n    needs_review: bool = False       # Offset mismatch detected\n    needs_review_reason: str         # Reason for review\n    suggested_season_offset: int     # Suggested season offset\n    suggested_episode_offset: int    # Suggested episode offset\n    air_weekday: int                 # Airing day (0=Sunday, 6=Saturday)\n```\n\n### RSSItem Model\n\nRSS feed subscriptions:\n\n```python\nclass RSSItem(SQLModel, table=True):\n    id: int                          # Primary key\n    name: str                        # Display name\n    url: str                         # Feed URL (unique, indexed)\n    aggregate: bool = True           # Whether to parse torrents\n    parser: str = \"mikan\"            # Parser type: mikan, dmhy, nyaa\n    enabled: bool = True             # Active flag\n    connection_status: str           # \"healthy\" or \"error\"\n    last_checked_at: str             # ISO timestamp\n    last_error: str                  # Last error message\n```\n\n### Torrent Model\n\nTracks downloaded torrents:\n\n```python\nclass Torrent(SQLModel, table=True):\n    id: int                          # Primary key\n    name: str                        # Torrent name (indexed)\n    url: str                         # Torrent/magnet URL (unique, indexed)\n    rss_id: int                      # Source RSS feed ID\n    bangumi_id: int                  # Linked Bangumi ID (nullable)\n    qb_hash: str                     # qBittorrent info hash (indexed)\n    downloaded: bool = False         # Download completed\n```\n\n## Common Operations\n\n### BangumiDatabase\n\n```python\nwith Database() as db:\n    # Create\n    db.bangumi.add(bangumi)              # Single insert\n    db.bangumi.add_all(bangumi_list)     # Batch insert (deduplicates)\n\n    # Read\n    db.bangumi.search_all()              # All records (cached, 5min TTL)\n    db.bangumi.search_id(123)            # By ID\n    db.bangumi.match_torrent(\"torrent name\")  # Find by title_raw match\n    db.bangumi.not_complete()            # Incomplete series\n    db.bangumi.get_needs_review()        # Flagged for review\n\n    # Update\n    db.bangumi.update(bangumi)           # Update single record\n    db.bangumi.update_all(bangumi_list)  # Batch update\n\n    # Delete\n    db.bangumi.delete_one(123)           # Hard delete\n    db.bangumi.disable_rule(123)         # Soft delete (deleted=True)\n```\n\n### RSSDatabase\n\n```python\nwith Database() as db:\n    # Create\n    db.rss.add(rss_item)                 # Single insert\n    db.rss.add_all(rss_items)            # Batch insert (deduplicates)\n\n    # Read\n    db.rss.search_all()                  # All feeds\n    db.rss.search_active()               # Enabled feeds only\n    db.rss.search_aggregate()            # Enabled + aggregate=True\n\n    # Update\n    db.rss.update(id, rss_update)        # Partial update\n    db.rss.enable(id)                    # Enable feed\n    db.rss.disable(id)                   # Disable feed\n    db.rss.enable_batch([1, 2, 3])       # Batch enable\n    db.rss.disable_batch([1, 2, 3])      # Batch disable\n```\n\n### TorrentDatabase\n\n```python\nwith Database() as db:\n    # Create\n    db.torrent.add(torrent)              # Single insert\n    db.torrent.add_all(torrents)         # Batch insert\n\n    # Read\n    db.torrent.search_all()              # All torrents\n    db.torrent.search_by_qb_hash(hash)   # By qBittorrent hash\n    db.torrent.search_by_url(url)        # By URL\n    db.torrent.check_new(torrents)       # Filter out existing\n\n    # Update\n    db.torrent.update_qb_hash(id, hash)  # Set qb_hash\n```\n\n## Caching\n\n### Bangumi Cache\n\n`search_all()` results are cached at the module level with a 5-minute TTL:\n\n```python\n# Module-level cache in bangumi.py\n_bangumi_cache: list[Bangumi] | None = None\n_bangumi_cache_time: float = 0\n_BANGUMI_CACHE_TTL: float = 300.0  # 5 minutes\n\n# Cache invalidation\ndef _invalidate_bangumi_cache():\n    global _bangumi_cache, _bangumi_cache_time\n    _bangumi_cache = None\n    _bangumi_cache_time = 0\n```\n\n**Important:** The cache is automatically invalidated on:\n- `add()`, `add_all()`\n- `update()`, `update_all()`\n- `delete_one()`, `delete_all()`\n- `archive_one()`, `unarchive_one()`\n- Any RSS link update operations\n\n### Session Expunge\n\nCached objects are **expunged** from the session to prevent `DetachedInstanceError`:\n\n```python\nfor b in bangumis:\n    self.session.expunge(b)  # Detach from session\n```\n\n## Migration System\n\n### Schema Versioning\n\nMigrations are tracked via a `schema_version` table:\n\n```python\nCURRENT_SCHEMA_VERSION = 7\n\n# Each migration: (version, description, [SQL statements])\nMIGRATIONS = [\n    (1, \"add air_weekday column\", [...]),\n    (2, \"add connection status columns\", [...]),\n    (3, \"create passkey table\", [...]),\n    (4, \"add archived column\", [...]),\n    (5, \"rename offset to episode_offset\", [...]),\n    (6, \"add qb_hash column\", [...]),\n    (7, \"add suggested offset columns\", [...]),\n]\n```\n\n### Adding a New Migration\n\n1. Increment `CURRENT_SCHEMA_VERSION` in `combine.py`\n2. Add migration tuple to `MIGRATIONS` list:\n\n```python\nMIGRATIONS = [\n    # ... existing migrations ...\n    (\n        8,\n        \"add my_new_column to bangumi\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN my_new_column TEXT DEFAULT NULL\",\n        ],\n    ),\n]\n```\n\n3. Add idempotency check in `run_migrations()`:\n\n```python\nif \"bangumi\" in tables and version == 8:\n    columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n    if \"my_new_column\" in columns:\n        needs_run = False\n```\n\n4. Update the corresponding Pydantic model in `module/models/`\n\n### Default Value Backfill\n\nAfter migrations, `_fill_null_with_defaults()` automatically fills NULL values based on model defaults:\n\n```python\n# If model defines:\nclass Bangumi(SQLModel, table=True):\n    my_field: bool = False\n\n# Then existing rows with NULL will be updated to False\n```\n\n## Performance Patterns\n\n### Batch Queries\n\n`add_all()` uses a single query to check for duplicates instead of N queries:\n\n```python\n# Efficient: single SELECT\nkeys_to_check = [(d.title_raw, d.group_name) for d in datas]\nconditions = [\n    and_(Bangumi.title_raw == tr, Bangumi.group_name == gn)\n    for tr, gn in keys_to_check\n]\nstatement = select(Bangumi.title_raw, Bangumi.group_name).where(or_(*conditions))\n```\n\n### Regex Matching\n\n`match_list()` compiles a single regex pattern for all title matches:\n\n```python\n# Compile once, match many\nsorted_titles = sorted(title_index.keys(), key=len, reverse=True)\npattern = \"|\".join(re.escape(title) for title in sorted_titles)\ntitle_regex = re.compile(pattern)\n\n# O(1) lookup per torrent instead of O(n)\nfor torrent in torrent_list:\n    match = title_regex.search(torrent.name)\n```\n\n### Indexed Columns\n\nThe following columns have indexes for fast lookups:\n\n| Table | Column | Index Type |\n|-------|--------|------------|\n| `bangumi` | `title_raw` | Regular |\n| `bangumi` | `deleted` | Regular |\n| `bangumi` | `archived` | Regular |\n| `rssitem` | `url` | Unique |\n| `torrent` | `name` | Regular |\n| `torrent` | `url` | Unique |\n| `torrent` | `qb_hash` | Regular |\n\n## Testing\n\n### Test Database Setup\n\nTests use an in-memory SQLite database:\n\n```python\n# conftest.py\n@pytest.fixture\ndef db_engine():\n    engine = create_engine(\"sqlite:///:memory:\")\n    SQLModel.metadata.create_all(engine)\n    yield engine\n    engine.dispose()\n\n@pytest.fixture\ndef db_session(db_engine):\n    with Session(db_engine) as session:\n        yield session\n```\n\n### Factory Functions\n\nUse factory functions for creating test data:\n\n```python\nfrom test.factories import make_bangumi, make_torrent, make_rss_item\n\ndef test_bangumi_search():\n    bangumi = make_bangumi(title_raw=\"Test Title\", season=2)\n    # ... test logic\n```\n\n## Design Notes\n\n### No Foreign Keys\n\nSQLite foreign key enforcement is disabled by default. Relationships (like `Torrent.bangumi_id`) are managed in application logic rather than database constraints.\n\n### Soft Deletes\n\nThe `Bangumi.deleted` flag enables soft deletes. Queries should filter by `deleted=False` for user-facing data:\n\n```python\nstatement = select(Bangumi).where(Bangumi.deleted == false())\n```\n\n### Torrent Tagging\n\nTorrents are tagged in qBittorrent with `ab:{bangumi_id}` for offset lookup during rename operations. This enables fast bangumi identification without database queries.\n\n## Common Issues\n\n### DetachedInstanceError\n\nIf you access cached objects from a different session:\n\n```python\n# Wrong: accessing cached object in new session\nbangumis = db.bangumi.search_all()  # Cached\nwith Database() as new_db:\n    new_db.session.add(bangumis[0])  # Error!\n\n# Right: objects are expunged, work independently\nbangumis = db.bangumi.search_all()\nbangumis[0].title_raw = \"New Title\"  # OK, but won't persist\n```\n\n### Cache Staleness\n\nIf manual SQL updates bypass the ORM, invalidate the cache:\n\n```python\nfrom module.database.bangumi import _invalidate_bangumi_cache\n\nwith engine.connect() as conn:\n    conn.execute(text(\"UPDATE bangumi SET ...\"))\n    conn.commit()\n\n_invalidate_bangumi_cache()  # Important!\n```\n"
  },
  {
    "path": "docs/en/dev/index.md",
    "content": "# Contributing Guide\n\nWe welcome contributors to help make AutoBangumi better at solving issues encountered by users.\n\nThis guide will walk you through how to contribute code to AutoBangumi. Please take a few minutes to read through before submitting a Pull Request.\n\nThis article covers:\n\n- [Project Roadmap](#project-roadmap)\n  - [Request for Comments (RFC)](#request-for-comments-rfc)\n- [Git Branch Management](#git-branch-management)\n  - [Version Numbering](#version-numbering)\n  - [Branch Development, Trunk Release](#branch-development-trunk-release)\n  - [Branch Lifecycle](#branch-lifecycle)\n  - [Git Workflow Overview](#git-workflow-overview)\n- [Pull Request](#pull-request)\n- [Release Process](#release-process)\n\n\n## Project Roadmap\n\nThe AutoBangumi development team uses [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) boards to manage planned development, ongoing fixes, and their progress.\n\nThis helps you understand:\n- What the development team is working on\n- What aligns with your intended contribution, so you can participate directly\n- What's already in progress, to avoid duplicate work\n\nIn [Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen), beyond the usual `[Feature Request]`, `[BUG]`, and small improvements, you'll find **`[RFC]`** items.\n\n### Request for Comments (RFC)\n\n> Find existing [AutoBangumi RFCs](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+label%3ARFC) via the `RFC` label in issues.\n\nFor small improvements or bug fixes, feel free to adjust the code and submit a Pull Request. Just read the [Branch Management](#git-branch-management) section to base your work on the correct branch, and the [Pull Request](#pull-request) section to understand how PRs are merged.\n\n<br/>\n\nFor **larger** feature refactors with broad scope, please first write an RFC proposal via [Issue: Feature Proposal](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=RFC&projects=&template=rfc.yml&title=%5BRFC%5D%3A+) to briefly describe your approach and seek developer discussion and consensus.\n\nSome proposals may conflict with decisions the development team has already made, and this step helps avoid wasted effort.\n\n> If you only want to discuss whether to add or improve a feature (not \"how to implement it\"), use -> [Issue: Feature Request](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)\n\n\n<br/>\n\nAn [RFC Proposal](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+is%3Aopen+label%3ARFC) is **\"a document for developers to review technical design/approach before concrete development of a feature/refactor\"**.\n\nThe purpose is to ensure collaborating developers clearly know \"what to do\" and \"how it will be done\", with all developers able to participate in open discussion.\n\nThis helps evaluate impacts (overlooked considerations, backward compatibility, conflicts with existing features).\n\nTherefore, proposals focus on describing the **approach, design, and steps** for solving the problem.\n\n\n## Git Branch Management\n\n### Version Numbering\n\nGit branches in the AutoBangumi project are closely related to release version rules.\n\nAutoBangumi follows [Semantic Versioning (SemVer)](https://semver.org/) with a `<Major>.<Minor>.<Patch>` format:\n\n- **Major**: Major version update, likely with incompatible configuration/API changes\n- **Minor**: Backward-compatible new functionality\n- **Patch**: Backward-compatible bug fixes / minor improvements\n\n### Branch Development, Trunk Release\n\nAutoBangumi uses a \"branch development, trunk release\" model.\n\n[**`main`**](https://github.com/EstrellaXD/Auto_Bangumi/commits/main) is the stable **trunk branch**, used only for releases, not for direct development.\n\nEach Minor version has a corresponding **development branch** for new features and post-release maintenance.\n\nDevelopment branches are named `<Major>.<Minor>-dev`, e.g., `3.1-dev`, `3.0-dev`, `2.6-dev`. Find them in [All Branches](https://github.com/EstrellaXD/Auto_Bangumi/branches/all?query=-dev).\n\n\n### Branch Lifecycle\n\nWhen a Minor development branch (e.g., `3.1-dev`) completes feature development and **first** merges into main:\n- Release the Minor version (e.g., `3.1.0`)\n- Create the **next** Minor development branch (`3.2-dev`) for next version features\n  - The **previous** version's branch (`3.0-dev`) is archived\n- This Minor branch (`3.1-dev`) enters maintenance — no new features/refactors, only bug fixes\n  - Bug fixes are merged to the maintenance branch, then to main for `Patch` releases\n\nFor contributors choosing Git branches:\n- **Bug fixes** — base on the **current released version's** Minor branch, PR to that branch\n- **New features/refactors** — base on the **next unreleased version's** Minor branch, PR to that branch\n\n> \"Current released version\" is the latest version on the [[Releases page]](https://github.com/EstrellaXD/Auto_Bangumi/releases)\n\n\n### Git Workflow Overview\n\n> Commit timeline goes from left to right --->\n\n![dev-branch](/image/dev/branch.png)\n\n\n## Pull Request\n\nEnsure you've selected the correct PR target branch per the Git Branch Management section above:\n> - **Bug fixes** → PR to the **current released version's** Minor maintenance branch\n> - **New features/refactors** → PR to the **next version's** Minor development branch\n\n<br/>\n\n- A PR should correspond to a single concern and not introduce unrelated changes.\n\n  Split different concerns into multiple PRs to help the team focus on one issue per review.\n\n- In the PR title and description, briefly explain the changes including reasons and intent.\n\n  Link related issues or RFCs in the PR description.\n\n  This helps the team understand context quickly during code review.\n\n- Ensure \"Allow edits from maintainers\" is checked. This allows direct minor edits/refactors and saves time.\n\n- Ensure local tests and linting pass. These are also checked in PR CI.\n  - For bug fixes and new features, the team may request corresponding unit test coverage.\n\n\nThe development team will review contributor PRs and discuss or approve merging as soon as possible.\n\n## Release Process\n\nReleases are currently triggered automatically after the development team manually merges a specific \"release PR\".\n\nBug fix PRs are typically released quickly, usually within a week.\n\nNew feature releases take longer and are less predictable. Check the [GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen) board for development progress — a version is released when all planned features are complete.\n"
  },
  {
    "path": "docs/en/faq/index.md",
    "content": "# Frequently Asked Questions\n\n## WebUI\n\n### WebUI Address\n\nThe default port is 7892. For server deployments, access `http://serverhost:7892`. For local deployments, access `http://localhost:7892`. If you changed the port, remember to also update the Docker port mapping.\n\n### Default Username and Password\n\n- Default username: `admin`, default password: `adminadmin`.\n- Please change your password after first login.\n\n### Changing or Resetting Password\n\n- Change password: After logging in, click `···` in the upper right, click `Profile`, and modify your username and password.\n- There is currently no simple password reset method. If you forget your password, delete the `data/data.db` file and restart.\n\n### Why don't my configuration changes take effect?\n\n- After changing configuration, click the **Apply** button, then click **Restart** in the `···` menu to restart the main process.\n- If Debug mode is enabled, click **Shutdown** in the `···` menu to restart the container.\n\n### How to check if the program is running normally\n\nThe new WebUI has a small dot in the upper right corner. Green means running normally, red means an error occurred and the program is paused.\n\n### Poster wall not showing images\n\n- If your version is 3.0:\n    AB uses `mikanani.me` addresses as poster image sources by default. If images aren't showing, your network cannot access these images.\n- If your version is 3.1 or later:\n  - If posters show an error icon, the images are missing. Click the refresh poster button in the upper right menu to fetch TMDB posters.\n  - If posters fail to load, clear your browser cache.\n  - When using `mikanime.tv` as the RSS address, client-side proxies may prevent poster loading. Add a `direct` rule for it.\n\n## How Does v3.0 Manage Bangumi\n\nAfter upgrading to v3.0, AB can manage anime torrents and download rules in the WebUI. It relies on the torrent download path and rule name.\nIf you manually change torrent download paths in QB, you may encounter issues like notifications missing posters or failed torrent deletion.\nPlease manage anime and torrents within AB as much as possible.\n\n## Downloads and Keyword Filtering\n\n### Download Path\n\n**What should I put for the download path?**\n- This parameter just needs to match your qBittorrent configuration:\n  - Docker: If qB uses `/downloads`, then set `/downloads/Bangumi`. You can change `Bangumi` to anything.\n  - Linux/macOS: If it's `/home/usr/downloads` or `/User/UserName/Downloads`, just append `/Bangumi` at the end.\n  - Windows: Change `D:\\Media\\` to `D:\\Media\\Bangumi`\n\n### Downloads not starting automatically\n\nCheck AutoBangumi's logs for any torrent-related entries.\n- If none exist, check if your subscription is correct.\n\n### Downloads not saved in the correct directory\n\n- Check if the [download path](#download-path) is correct.\n- Check qBittorrent's PGID and PUID configuration for folder creation permissions. Try manually downloading any torrent to a specified directory — if errors occur or the directory isn't created, it's a permissions issue.\n- Check qBittorrent's default settings: Saving Management should be set to Manual (Saving Management >> Default Torrent Management Mode >> Manual).\n\n### Downloading many unsubscribed anime\n\n- Check if your Mikan subscription includes all subtitle groups for a single anime. Subscribe to only one group per anime, and enable advanced subscriptions.\n  - Advanced subscriptions can be enabled in Mikan Project's user settings.\n- Regex filtering may be insufficient — see the next section for expanding regex.\n- If neither applies, report with logs at [Issues][ISSUE].\n\n### How to write filter keywords\n\nFilter keywords in AB are regular expressions, added only when rules are created. To expand rules after creation, use the WebUI (v3.0+) to configure each anime individually.\n- Filter keywords are regex — separate unwanted keywords with `|`.\n- The default `720|\\d+-\\d+` rule filters out all collections and 720P anime. Add filters before deploying AB; subsequent environment variable changes only affect new rules.\n- Common regex keywords (separated by `|`):\n  - `720` — filters 720, 720P, 720p, etc.\n  - `\\d+-\\d+` — filters collections like [1-12]\n  - `[Bb]aha` — filters Baha releases\n  - `[Bb]ilibili`, `[Bb]-Global` — filters Bilibili releases\n  - `繁`, `CHT` — filters Traditional Chinese subtitles\n- To match specific keywords, add in QB's include field: `XXXXX+1080P\\+` where `1080P\\+` matches 1080P+ releases.\n\n### First deployment downloaded unwanted anime\n\n1. Delete extra automatic download rules and files in QB.\n2. Check subscriptions and filter rules.\n3. Visit the resetRule API in your browser: `http://localhost:7892/api/v1/resetRule` to reset rules.\n4. Restart AB.\n\n### AB identifies fewer RSS entries than subscribed\n\nIn newer versions, AB's filter also filters all RSS entries by default. Don't add all filters at once. For fine-grained control, configure each anime individually in the WebUI.\n\n### Filter keywords not working\n\n- Check if the **global filter** parameter is set correctly.\n- Check QB's RSS auto-download rules — you can see matched RSS on the right side, adjust download rules, and click save to identify which keyword is causing issues.\n\n## Episode Completion\n\n### Episode completion not working\n\nCheck if the **Episode completion** parameter is correctly configured.\n\n## File Renaming\n\n### Parse error `Cannot parse XXX`\n\n- AB does not currently support parsing collections.\n- If it's not a collection, report the issue on GitHub Issues.\n\n### `Rename failed` or renaming errors\n\n- Check file paths. Standard storage path should be `/title/Season/Episode.mp4`. Non-standard paths cause naming errors — check your qBittorrent configuration.\n- Check if the `download path` is filled in correctly. Incorrect paths prevent proper renaming.\n- For other issues, report on GitHub Issues.\n\n### No automatic renaming\n\n- Check if the torrent category in QB is `Bangumi`.\n- AB only renames downloaded files.\n\n### How to rename non-AB anime with AB\n\n- Simply change the torrent's category to `Bangumi`.\n- Note: The torrent must be stored in a `Title/Season X/` folder to trigger renaming.\n\n### How to rename collections\n\n1. Change the collection's category to `Bangumi`.\n2. Change the collection's storage path to `Title/Season X/`.\n3. Wait for the collection to finish downloading, and renaming will complete.\n\n## Docker\n\n### How to auto-update\n\nRun a `watchtower` daemon in Docker to automatically update your containers.\n\n[watchtower](https://containrrr.dev/watchtower) official documentation\n\n### Updating with Docker Compose\n\nIf your AB is deployed with Docker Compose, use `docker compose pull` to update.\nAfter pulling the new image, use `docker compose up -d` to restart.\n\nYou can also add `pull_policy: always` to your `docker-compose.yml` to pull the latest image on every start.\n\n### What to do if an upgrade causes issues\n\nSince configurations may vary, upgrades might cause the program to fail. In this case, delete all previous data and generated configuration files, then restart the container.\nThen reconfigure in the WebUI.\nIf upgrading from an older version, first refer to the [upgrade guide](/changelog/2.6).\n\nIf you encounter issues not covered above, report them at [Issues][ISSUE] using the bug template.\n\n\n[ISSUE]: https://github.com/EstrellaXD/Auto_Bangumi/issues\n"
  },
  {
    "path": "docs/en/faq/network.md",
    "content": "# Network Issues\n\n## Cannot Connect to Mikan Project\n\nSince the main Mikan Project site (`https://mikanani.me`) may be blocked in some regions, AB may fail to connect. Use the following solutions:\n\n- [Use Mikan Project alternative domain](#mikan-project-alternative-domain)\n- [Use a proxy](#configuring-a-proxy)\n- [Use a Cloudflare Worker reverse proxy](#cloudflare-workers-reverse-proxy)\n\n### Mikan Project Alternative Domain\n\nMikan Project has a new domain `https://mikanime.tv`. Use this domain with AB without enabling a proxy.\n\nIf you see:\n```\nDNS/Connect ERROR\n```\n\n- Check your network connection. If it's fine, check DNS resolution.\n- Add `dns=8.8.8.8` to AB. If using Host network mode, this can be ignored.\n\nIf you're using a proxy, this error typically won't occur with correct configuration.\n\n### Configuring a Proxy\n\n::: tip\nIn AB 3.1+, AB handles RSS updates and notifications itself, so you only need to configure the proxy in AB.\n:::\n\nAB has built-in proxy configuration. To configure a proxy, follow the instructions in [Proxy Settings](../config/proxy) to set up HTTP or SOCKS proxy correctly. This resolves access issues.\n\n**For versions before 3.1, qBittorrent proxy configuration is also needed**\n\nConfigure the proxy in QB as shown below (same approach for SOCKS):\n\n<img width=\"483\" alt=\"image\" src=\"https://user-images.githubusercontent.com/33726646/233681562-cca3957a-a5de-40e2-8fb3-4cc7f57cc139.png\">\n\n\n### Cloudflare Workers Reverse Proxy\n\nYou can also use a reverse proxy approach via Cloudflare Workers. Setting up a domain and binding it to Cloudflare is beyond the scope of this guide.\nAdd the following code in Workers to use your own domain to access Mikan Project and download torrents from RSS links:\n\n```javascript\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // Only perform replacement if content type is RSS\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n\nAfter completing the configuration, replace `https://mikanani.me` with your domain when **adding RSS**.\n\n## Cannot Connect to qBittorrent\n\nFirst, check if the **downloader address** parameter in AB is correct.\n- If AB and QB are on the same Docker network, try using the container name for addressing, e.g., `http://qbittorrent:8080`.\n- If AB and QB are on the same Docker server, try using the Docker gateway address, e.g., `http://172.17.0.1:8080`.\n- If AB's network mode is not `host`, do not use `127.0.0.1` to access QB.\n\nIf containers in Docker cannot access each other, set up a network link between QB and AB in QB's network connection settings. If qBittorrent uses HTTPS, add the `https://` prefix to the **downloader address**.\n"
  },
  {
    "path": "docs/en/faq/troubleshooting.md",
    "content": "---\ntitle: Troubleshooting\n---\n\n## General Troubleshooting Flow\n\n1. If AB fails to start, check if the startup command is correct. If incorrect and you don't know how to fix it, try redeploying AB.\n2. After deploying AB, check the logs first. If you see output like the following, AB is running normally and connected to QB:\n      ```\n      [2022-07-09 21:55:19,164] INFO:\t                _        ____                                    _\n      [2022-07-09 21:55:19,165] INFO:\t     /\\        | |      |  _ \\                                  (_)\n      [2022-07-09 21:55:19,166] INFO:\t    /  \\  _   _| |_ ___ | |_) | __ _ _ __   __ _ _   _ _ __ ___  _\n      [2022-07-09 21:55:19,167] INFO:\t   / /\\ \\| | | | __/ _ \\|  _ < / _` | '_ \\ / _` | | | | '_ ` _ \\| |\n      [2022-07-09 21:55:19,167] INFO:\t  / ____ \\ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |\n      [2022-07-09 21:55:19,168] INFO:\t /_/    \\_\\__,_|\\__\\___/|____/ \\__,_|_| |_|\\__, |\\__,_|_| |_| |_|_|\n      [2022-07-09 21:55:19,169] INFO:\t                                            __/ |\n      [2022-07-09 21:55:19,169] INFO:\t                                           |___/\n      [2022-07-09 21:55:19,170] INFO:\tVersion 3.0.1  Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan\n      [2022-07-09 21:55:19,171] INFO:\tGitHub: https://github.com/EstrellaXD/Auto_Bangumi/\n      [2022-07-09 21:55:19,172] INFO:\tStarting AutoBangumi...\n      [2022-07-09 21:55:20,717] INFO:\tAdd RSS Feed successfully.\n      [2022-07-09 21:55:21,761] INFO:\tStart collecting RSS info.\n      [2022-07-09 21:55:23,431] INFO:\tFinished\n      [2022-07-09 21:55:23,432] INFO:\tRunning....\n      ```\n   1. If you see this log, AB cannot connect to qBittorrent. Check if qBittorrent is running. If it is, go to the [Network Issues](/faq/network) section.\n        ```\n        [2022-07-09 22:01:24,534] WARNING:  Cannot connect to qBittorrent, wait 5min and retry\n        ```\n   2. If you see this log, AB cannot connect to Mikan RSS. Go to the [Network Issues](/faq/network) section.\n        ```\n        [2022-07-09 21:55:21,761] INFO:\t    Start collecting RSS info.\n        [2022-07-09 22:01:24,534] WARNING:  Connected Failed, please check DNS/Connection\n        ```\n3. At this point, QB should have download tasks.\n   1. If downloads show path issues, check QB's \"Saving Management\" → \"Default Torrent Management Mode\" is set to \"Manual\".\n   2. If all downloads show exclamation marks or no category folders are created in the download path, check QB's permissions.\n4. If none of the above resolves the issue, try redeploying a fresh qBittorrent.\n5. If still unsuccessful, report with logs at [Issues](https://www.github.com/EstrellaXD/Auto_Bangumi/issues).\n"
  },
  {
    "path": "docs/en/feature/bangumi.md",
    "content": "# Bangumi Management\n\nClick an anime poster on the homepage to manage individual anime entries.\n\n![Bangumi List](/image/feature/bangumi-list.png)\n\nIf an anime has multiple download rules (e.g., different subtitle groups), a rule selection popup will appear:\n\n![Rule Selection](/image/feature/rule-select.png)\n\nAfter selecting a rule, the edit modal opens:\n\n![Edit Bangumi](/image/feature/bangumi-edit.png)\n\n## Notification Badges\n\nSince v3.2, bangumi cards display iOS-style notification badges to indicate status:\n\n- **Yellow badge with `!`**: Subscription needs review (e.g., offset issues detected)\n- **Number badge**: Multiple rules exist for this anime\n- **Combined badge** (e.g., `! | 2`): Has warning and multiple rules\n\nCards with warnings also display a yellow glow animation to draw attention.\n\n## Episode Offset Auto-Detection\n\nSome anime have complex season structures that cause mismatches between RSS episode numbers and TMDB data. For example:\n- \"Frieren: Beyond Journey's End\" Season 1 was broadcast in two parts with a 6-month gap\n- RSS may show \"S2E01\" while TMDB considers it \"S1E29\"\n\nAB v3.2 can automatically detect these issues:\n\n1. Click the **Auto Detect** button in the edit modal\n2. AB analyzes TMDB episode air dates to identify \"virtual seasons\"\n3. If a mismatch is found, AB suggests the correct offset values\n4. Click **Apply** to save the offset\n\nThe background scan thread also periodically checks existing subscriptions for offset issues and marks them for review.\n\n## Archive / Unarchive Anime\n\nSince v3.2, you can archive completed or inactive anime to keep your list organized.\n\n### Manual Archive\n\n1. Click on an anime poster\n2. In the edit modal, click the **Archive** button\n3. The anime moves to the \"Archived\" section at the bottom of the list\n\n### Automatic Archive\n\nAB can automatically archive anime when:\n- The series status on TMDB shows as \"Ended\" or \"Canceled\"\n- Use **Config** → refresh metadata to trigger auto-archive\n\n### Viewing Archived Anime\n\nArchived anime appear in a collapsible \"Archived\" section at the bottom of the bangumi list. Click to expand and view archived items.\n\n### Unarchive\n\nTo restore an archived anime:\n1. Expand the \"Archived\" section\n2. Click on the anime poster\n3. Click the **Unarchive** button\n\n## Disable / Delete Anime\n\nSince AB continuously parses **aggregated RSS** feeds, for download rules from aggregated RSS that you no longer need:\n- Disable anime: The anime won't be downloaded or re-parsed\n- Remove the subscription from the aggregated RSS\n\nIf you delete the anime entry, it will be recreated on the next parse cycle.\n\n## Advanced Settings\n\nClick **Advanced Settings** in the edit modal to access additional options:\n\n![Advanced Settings](/image/feature/bangumi-edit-advanced.png)\n\n- **Season Offset**: Adjust the season number offset\n- **Episode Offset**: Adjust the episode number offset\n- **Filter**: Custom regex filter for torrent matching\n"
  },
  {
    "path": "docs/en/feature/calendar.md",
    "content": "# Calendar View\n\nSince v3.2, AB includes a calendar view that shows your subscribed anime organized by broadcast day.\n\n![Calendar](/image/feature/calendar.png)\n\n## Features\n\n### Weekly Schedule\n\nThe calendar displays anime organized by their broadcast weekday (Monday through Sunday), plus an \"Unknown\" column for anime without broadcast schedule data.\n\n### Bangumi.tv Integration\n\nAB fetches broadcast schedule data from Bangumi.tv to accurately display when each anime airs.\n\nClick the **Refresh schedule** button to update the broadcast data.\n\n### Grouped Display\n\nSince v3.2, anime with multiple download rules are grouped together:\n\n- Same anime appears once, even with multiple subtitle group rules\n- Click on a grouped anime to see all available rules\n- Select a specific rule to edit\n\nThis keeps the calendar clean while still providing access to all your rules.\n\n## Navigation\n\nClick on any anime poster in the calendar to:\n- View anime details\n- Edit download rules\n- Access archive/disable options\n\n## Tips\n\n::: tip\nIf an anime appears in the \"Unknown\" column, it may not have broadcast data on Bangumi.tv, or the anime title couldn't be matched.\n:::\n"
  },
  {
    "path": "docs/en/feature/rename.md",
    "content": "# File Renaming\n\nAB currently provides three renaming methods: `pn`, `advance`, and `none`.\n\n### pn\n\nShort for `pure name`. This method uses the torrent download name for renaming.\n\nExample:\n```\n[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six S01E01.mkv\n```\n\n### advance\n\nAdvanced renaming. This method uses the parent folder name for renaming.\n\n```\n/downloads/Bangumi/86 - Eighty Six(2023)/Season 1/[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six(2023) S01E01.mkv\n```\n\n### none\n\nNo renaming. Files are left as-is.\n\n## Collection Renaming\n\nAB supports renaming collections. Collection renaming requires:\n- Episodes are in the collection's first-level directory\n- Episode numbers can be parsed from file names\n\nAB can also rename subtitle files in the first-level directory.\n\nAfter renaming, episodes and directories are placed in the `Season` folder.\n\nRenamed collections are moved and categorized under `BangumiCollection`.\n\n## Episode Offset\n\nSince v3.2, AB supports episode offset for renaming. This is useful when:\n- RSS shows different episode numbers than expected (e.g., S2E01 should be S1E29)\n- Anime has \"virtual seasons\" due to broadcast gaps\n\nWhen an offset is configured for a bangumi, AB automatically applies it during renaming:\n\n```\nOriginal: S02E01.mkv\nWith offset (season: -1, episode: +28): S01E29.mkv\n```\n\nTo configure offset:\n1. Click on the anime poster\n2. Open Advanced Settings\n3. Set Season Offset and/or Episode Offset values\n4. Or use \"Auto Detect\" to let AB suggest the correct offset\n\nSee [Bangumi Management](./bangumi.md#episode-offset-auto-detection) for more details on auto-detection.\n"
  },
  {
    "path": "docs/en/feature/rss.md",
    "content": "---\ntitle: RSS Management\n---\n\n# RSS Management\n\n## RSS Manager Page\n\nThe RSS Manager page displays all your RSS subscriptions with their connection status.\n\n![RSS Manager](/image/feature/rss-manager.png)\n\n### Connection Status\n\nSince v3.2, AB tracks the connection status of each RSS source:\n\n| Status | Description |\n|--------|-------------|\n| **Connected** (green) | RSS source is reachable and returning valid data |\n| **Error** (red) | RSS source failed to respond or returned invalid data |\n\nWhen a source shows an error, hover over the status label to see the error details in a tooltip.\n\nAB automatically updates the connection status on each RSS refresh cycle.\n\n## Adding Collections\n\nAB provides two manual download methods:\n**Collect** and **Subscribe**.\n- **Collect** downloads all episodes at once, suitable for completed anime.\n- **Subscribe** adds an automatic download rule with the corresponding RSS link, suitable for ongoing anime.\n\n### Parsing RSS Links\n\nAB supports parsing collection RSS links from all resource sites. Find the collection RSS for your desired anime on the corresponding site, click the **+** button in the upper right corner of AB, and paste the RSS link in the popup window.\n\n### Adding Downloads\n\nIf parsing succeeds, a window will appear showing the parsed anime information. Click **Collect** or **Subscribe** to add it to the download queue.\n\n### Common Issues\n\nIf a parsing error occurs, it may be due to an incorrect RSS link or an unsupported subtitle group naming format.\n\n## Managing Bangumi\n\nSince v3.0, AB provides manual anime management in the WebUI, allowing you to manually adjust incorrectly parsed anime information.\n\n### Editing Anime Information\n\nIn the anime list, click the anime poster to enter the anime information page.\nAfter modifying the information, click **Apply**.\nAB will readjust the directory and automatically rename files based on your changes.\n\n\n### Deleting Anime\n\nSince v3.0, you can manually delete anime. Click the anime poster, enter the information page, and click **Delete**.\n\n::: warning\nAfter deleting anime, if the RSS subscription hasn't been cancelled, AB will still re-parse it. To disable the download rule, use [Disable Anime](#disabling-anime).\n:::\n\n### Disabling Anime\n\nSince v3.0, you can manually disable anime. Click the anime poster, enter the information page, and click **Disable**.\n\nOnce disabled, the anime poster will be grayed out and sorted to the end. To re-enable the download rule, click **Enable**.\n"
  },
  {
    "path": "docs/en/feature/search.md",
    "content": "# Torrent Search\n\nSince v3.1, AB includes a search feature for quickly finding anime.\n\n## Using the Search Feature\n\n::: warning\nThe search feature relies on the main program's parser. The current version does not support parsing collections. A `warning` when parsing collections is normal behavior.\n:::\n\nThe search bar is located in the AB top bar. Click to open the search panel.\n\n![Search Panel](/image/feature/search-panel.png)\n\nSelect the source site, enter keywords, and AB will automatically parse and display search results. To add an anime, click the add button on the right side of the card.\n\n::: tip\nWhen the source is **Mikan**, AB uses the `mikan` parser by default. For other sources, the TMDB parser is used.\n:::\n\n## Managing Search Sources\n\nSince v3.2, you can manage search sources directly in the Settings page without editing JSON files.\n\n### Search Provider Settings Panel\n\nNavigate to **Config** → **Search Provider** to access the settings panel.\n\n![Search Provider Settings](/image/feature/search-provider.png)\n\nFrom here you can:\n- **View** all configured search sources\n- **Add** new search sources with the \"Add Provider\" button\n- **Edit** existing source URLs\n- **Delete** custom sources (default sources mikan, nyaa, dmhy cannot be deleted)\n\n### URL Template Format\n\nWhen adding a custom source, the URL must contain `%s` as a placeholder for the search keyword.\n\nExample:\n```\nhttps://example.com/rss/search?q=%s\n```\n\nThe `%s` will be replaced with the user's search query.\n\n### Default Sources\n\nThe following sources are built-in and cannot be deleted:\n\n| Source | URL Template |\n|--------|--------------|\n| mikan | `https://mikanani.me/RSS/Search?searchstr=%s` |\n| nyaa | `https://nyaa.si/?page=rss&q=%s&c=0_0&f=0` |\n| dmhy | `http://dmhy.org/topics/rss/rss.xml?keyword=%s` |\n\n### Adding Sources via Config File\n\nYou can also manually add sources by editing `config/search_provider.json`:\n\n```json\n{\n  \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n  \"nyaa\": \"https://nyaa.si/?page=rss&q=%s&c=0_0&f=0\",\n  \"dmhy\": \"http://dmhy.org/topics/rss/rss.xml?keyword=%s\",\n  \"bangumi.moe\": \"https://bangumi.moe/rss/search/%s\"\n}\n```\n"
  },
  {
    "path": "docs/en/home/index.md",
    "content": "---\ntitle: About\n---\n\n<p align=\"center\">\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"/image/icons/dark-icon.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"/image/icons/light-icon.svg\">\n  <img src=\"/image/icons/light-icon.svg\" width=50%>\n</picture>\n</p>\n\n\n## About AutoBangumi\n\n\n<p align=\"center\">\n  <img\n    title=\"AutoBangumi WebUI\"\n    alt=\"AutoBangumi WebUI\"\n    src=\"/image/preview/window.png\"\n    width=85%\n    data-zoomable\n  >\n</p>\n\n**`AutoBangumi`** is a fully automated anime downloading and organizing tool based on RSS feeds. Simply subscribe to anime on [Mikan Project][mikan] or similar sites, and it will automatically track and download new episodes.\n\nThe organized file names and directory structure are directly compatible with [Plex][plex], [Jellyfin][jellyfin], and other media library software without requiring additional metadata scraping.\n\n## Features\n\n- Simple one-time configuration for continuous use\n- Hands-free RSS parser that extracts anime information and automatically generates download rules\n- Anime file organization:\n\n  ```\n  Bangumi\n  ├── bangumi_A_title\n  │   ├── Season 1\n  │   │   ├── A S01E01.mp4\n  │   │   ├── A S01E02.mp4\n  │   │   ├── A S01E03.mp4\n  │   │   └── A S01E04.mp4\n  │   └── Season 2\n  │       ├── A S02E01.mp4\n  │       ├── A S02E02.mp4\n  │       ├── A S02E03.mp4\n  │       └── A S02E04.mp4\n  ├── bangumi_B_title\n  │   └─── Season 1\n  ```\n\n- Fully automatic renaming — over 99% of anime files can be directly scraped by media library software after renaming\n\n  ```\n  [Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4\n  >>\n  Kakkou no Iinazuke S01E07.mp4\n  ```\n\n- Custom renaming based on parent folder names for all child files\n- Mid-season catch-up to fill in all missed episodes of the current season\n- Highly customizable options that can be fine-tuned for different media library software\n- Zero maintenance, completely transparent operation\n- Built-in TMDB parser for generating complete TMDB-formatted files and anime metadata\n- Reverse proxy support for Mikan RSS feeds\n\n## Community\n\n- Update notifications: [Telegram Channel](https://t.me/autobangumi_update)\n- Bug reports: [Telegram](https://t.me/+yNisOnDGaX5jMTM9)\n\n## Acknowledgments\n\nThanks to [Sean](https://github.com/findix) for extensive help with the project.\n\n## Contributing\n\nIssues and Pull Requests are welcome!\n\n<a href=\"https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi\" />\n</a>\n\n## Disclaimer\n\nSince AutoBangumi obtains anime through unofficial copyright channels:\n\n- **Do not** use AutoBangumi for commercial purposes.\n- **Do not** create video content featuring AutoBangumi for distribution on domestic video platforms (copyright stakeholders).\n- **Do not** use AutoBangumi for any activity that violates laws or regulations.\n\nAutoBangumi is for educational and personal use only.\n\n## License\n\n[MIT License](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE)\n\n[mikan]: https://mikanani.me\n[plex]: https://plex.tv\n[jellyfin]: https://jellyfin.org\n"
  },
  {
    "path": "docs/en/home/pipline.md",
    "content": "# How AutoBangumi Works\n\nAutoBangumi (AB for short) is essentially an RSS parser. It parses RSS feeds from anime torrent sites, extracts metadata from torrent titles, generates download rules, and sends them to qBittorrent for downloading. After downloading, it organizes files into a standard media library directory structure.\n\n## Pipeline Overview\n\n1. **RSS Parsing** — AB periodically fetches and parses your subscribed RSS feeds\n2. **Title Analysis** — Torrent titles are parsed to extract anime name, episode number, season, subtitle group, and resolution\n3. **Rule Generation** — Download rules are created in qBittorrent based on the parsed information\n4. **Download Management** — qBittorrent handles the actual downloading of torrents\n5. **File Organization** — Downloaded files are renamed and moved into a standardized directory structure\n6. **Media Library Ready** — The organized files can be directly recognized by Plex, Jellyfin, and other media servers\n"
  },
  {
    "path": "docs/en/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\ntitle: AutoBangumi\ntitleTemplate: 全自动追番，解放双手！\n\nhero:\n  name: AutoBangumi\n  text: 全自动追番，解放双手！\n  tagline: 全自动 RSS 订阅解析、下载管理和文件整理\n  actions:\n    - theme: brand\n      text: 快速开始\n      link: /deploy/quick-start\n    - theme: alt\n      text: 关于\n      link: /home/\n    - theme: alt\n      text: 更新日志\n      link: /changelog/3.2\n\nfeatures:\n  - icon:\n      src: /image/icons/rss.png\n    title: RSS 订阅解析\n    details: 自动识别并解析番剧 RSS 订阅源。无需手动输入，只需订阅即可自动完成解析、下载和整理。\n  - icon:\n      src: /image/icons/qbittorrent-logo.svg\n    title: qBittorrent 下载器\n    details: 使用 qBittorrent 下载番剧资源。在 AutoBangumi 中即可管理现有番剧、下载往期番剧以及删除条目。\n  - icon:\n      src: /image/icons/tmdb-icon.png\n    title: TMDB 元数据匹配\n    details: 通过 TMDB 匹配番剧信息以获取准确的元数据，确保即使在多个字幕组之间也能正确解析。\n  - icon:\n      src: /image/icons/plex-icon.png\n    title: Plex / Jellyfin / Infuse ...\n    details: 根据匹配结果自动整理文件名和目录结构，确保媒体库软件能够高成功率地刮削元数据。\n---\n\n\n<div class=\"container\">\n<div class=\"vp-doc\">\n\n## 鸣谢\n\n### 致谢\n感谢\n- [Mikan Project](https://mikanani.me) 提供了如此优秀的番剧资源。\n- [VitePress](https://vitepress.dev) 提供了优秀的文档框架。\n- [qBittorrent](https://www.qbittorrent.org) 提供了优秀的下载器。\n- [Plex](https://www.plex.tv) / [Jellyfin](https://jellyfin.org) 提供了优秀的自托管媒体库。\n- [Infuse](https://firecore.com/infuse) 提供了优雅的视频播放器。\n- [弹弹 Play](https://www.dandanplay.com) 提供了优秀的弹幕播放器。\n- 每一个番剧制作组 / 字幕组 / 爱好者。\n\n### 贡献者\n\n[\n  ![](https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi){class=contributors-avatar}\n](https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors)\n\n## 免责声明\n\n由于 AutoBangumi 通过非官方版权渠道获取番剧：\n\n- **请勿**将 AutoBangumi 用于商业用途。\n- **请勿**制作包含 AutoBangumi 的视频内容并在国内视频平台（版权相关方）上发布。\n- **请勿**将 AutoBangumi 用于任何违反法律法规的活动。\n\n</div>\n</div>\n\n<style scoped>\n.container {\n  display: flex;\n  position: relative;\n  margin: 0 auto;\n  padding: 0 24px;\n  max-width: 1280px;\n}\n\n@media (min-width: 640px) {\n  .container {\n    padding-inline: 48px;\n  }\n}\n\n@media (min-width: 960px) {\n  .container {\n    padding-inline: 64px;\n  }\n}\n\n\n.contributors-avatar {\n  width: 600px;\n}\n</style>\n"
  },
  {
    "path": "docs/faq/index.md",
    "content": "# 常见问题\n\n## WebUI\n\n### WebUI 地址\n\n默认端口为 7892。服务器部署时访问 `http://serverhost:7892`，本地部署时访问 `http://localhost:7892`。如果修改了端口，请记得同时修改 Docker 端口映射。\n\n### 默认用户名和密码\n\n- 默认用户名：`admin`，默认密码：`adminadmin`。\n- 请在首次登录后修改密码。\n\n### 修改或重置密码\n\n- 修改密码：登录后，点击右上角的 `···`，点击 `个人资料`，即可修改用户名和密码。\n- 目前没有简便的密码重置方法。如果忘记密码，请删除 `data/data.db` 文件后重启。\n\n### 为什么配置修改不生效？\n\n- 修改配置后，点击 **应用** 按钮，然后在 `···` 菜单中点击 **重启** 来重启主程序。\n- 如果启用了调试模式，请在 `···` 菜单中点击 **关闭** 来重启容器。\n\n### 如何检查程序是否正常运行\n\n新版 WebUI 右上角有一个小圆点。绿色表示程序正常运行，红色表示发生错误且程序已暂停。\n\n### 海报墙不显示图片\n\n- 如果您的版本是 3.0：\n    AB 默认使用 `mikanani.me` 地址作为海报图片来源。如果图片不显示，说明您的网络无法访问这些图片。\n- 如果您的版本是 3.1 或更高版本：\n  - 如果海报显示错误图标，说明图片缺失。请点击右上角菜单中的刷新海报按钮来获取 TMDB 海报。\n  - 如果海报加载失败，请清除浏览器缓存。\n  - 使用 `mikanime.tv` 作为 RSS 地址时，客户端代理可能会阻止海报加载。请为其添加 `direct` 规则。\n\n## v3.0 如何管理番剧\n\n升级到 v3.0 后，AB 可以在 WebUI 中管理番剧种子和下载规则。这依赖于种子下载路径和规则名称。\n如果您在 QB 中手动修改了种子下载路径，可能会遇到通知缺少海报或种子删除失败等问题。\n请尽量在 AB 内管理番剧和种子。\n\n## 下载与关键词过滤\n\n### 下载路径\n\n**下载路径应该填什么？**\n- 这个参数只需要与您的 qBittorrent 配置匹配即可：\n  - Docker：如果 qB 使用 `/downloads`，则设置为 `/downloads/Bangumi`。您可以将 `Bangumi` 改为任何名称。\n  - Linux/macOS：如果是 `/home/usr/downloads` 或 `/User/UserName/Downloads`，只需在末尾添加 `/Bangumi`。\n  - Windows：将 `D:\\Media\\` 改为 `D:\\Media\\Bangumi`\n\n### 下载没有自动开始\n\n检查 AutoBangumi 的日志中是否有种子相关的条目。\n- 如果没有，请检查您的订阅是否正确。\n\n### 下载没有保存到正确的目录\n\n- 检查[下载路径](#下载路径)是否正确。\n- 检查 qBittorrent 的 PGID 和 PUID 配置是否有创建文件夹的权限。尝试手动下载任意种子到指定目录——如果出错或目录未创建，则是权限问题。\n- 检查 qBittorrent 的默认设置：保存管理应设置为手动（保存管理 >> 默认 Torrent 管理模式 >> 手动）。\n\n### 下载了很多未订阅的番剧\n\n- 检查您的 Mikan 订阅是否包含了同一番剧的所有字幕组。每个番剧只订阅一个字幕组，并启用高级订阅。\n  - 高级订阅可以在 Mikan Project 的用户设置中启用。\n- 正则表达式过滤可能不够——请参阅下一节来扩展正则表达式。\n- 如果以上都不适用，请携带日志在 [Issues][ISSUE] 报告。\n\n### 如何编写过滤关键词\n\nAB 中的过滤关键词是正则表达式，仅在创建规则时添加。要在创建后扩展规则，请使用 WebUI（v3.0+）单独配置每个番剧。\n- 过滤关键词是正则表达式——用 `|` 分隔不需要的关键词。\n- 默认的 `720|\\d+-\\d+` 规则会过滤掉所有合集和 720P 番剧。在部署 AB 前添加过滤器；之后的环境变量修改只影响新规则。\n- 常用正则表达式关键词（用 `|` 分隔）：\n  - `720` — 过滤 720、720P、720p 等。\n  - `\\d+-\\d+` — 过滤 [1-12] 等合集\n  - `[Bb]aha` — 过滤 Baha 发布\n  - `[Bb]ilibili`、`[Bb]-Global` — 过滤 Bilibili 发布\n  - `繁`、`CHT` — 过滤繁体中文字幕\n- 要匹配特定关键词，请在 QB 的包含字段中添加：`XXXXX+1080P\\+`，其中 `1080P\\+` 匹配 1080P+ 发布。\n\n### 首次部署时下载了不想要的番剧\n\n1. 删除 QB 中多余的自动下载规则和文件。\n2. 检查订阅和过滤规则。\n3. 在浏览器中访问 resetRule API：`http://localhost:7892/api/v1/resetRule` 来重置规则。\n4. 重启 AB。\n\n### AB 识别的 RSS 条目比订阅的少\n\n在较新版本中，AB 的过滤器默认也会过滤所有 RSS 条目。不要一次添加所有过滤器。要进行细粒度控制，请在 WebUI 中单独配置每个番剧。\n\n### 过滤关键词不生效\n\n- 检查 **全局过滤器** 参数是否设置正确。\n- 检查 QB 的 RSS 自动下载规则——您可以在右侧看到匹配的 RSS，调整下载规则并点击保存来确定是哪个关键词导致问题。\n\n## 剧集补全\n\n### 剧集补全不生效\n\n检查 **剧集补全** 参数是否配置正确。\n\n## 文件重命名\n\n### 解析错误 `Cannot parse XXX`\n\n- AB 目前不支持解析合集。\n- 如果不是合集，请在 GitHub Issues 上报告问题。\n\n### `Rename failed` 或重命名错误\n\n- 检查文件路径。标准存储路径应为 `/title/Season/Episode.mp4`。非标准路径会导致命名错误——请检查您的 qBittorrent 配置。\n- 检查 `下载路径` 是否填写正确。路径不正确会导致无法正确重命名。\n- 其他问题请在 GitHub Issues 上报告。\n\n### 没有自动重命名\n\n- 检查 QB 中的种子分类是否为 `Bangumi`。\n- AB 只会重命名已下载的文件。\n\n### 如何使用 AB 重命名非 AB 下载的番剧\n\n- 只需将种子的分类改为 `Bangumi`。\n- 注意：种子必须存储在 `Title/Season X/` 文件夹中才能触发重命名。\n\n### 如何重命名合集\n\n1. 将合集的分类改为 `Bangumi`。\n2. 将合集的存储路径改为 `Title/Season X/`。\n3. 等待合集下载完成，重命名将自动完成。\n\n## Docker\n\n### 如何自动更新\n\n在 Docker 中运行 `watchtower` 守护进程来自动更新您的容器。\n\n[watchtower](https://containrrr.dev/watchtower) 官方文档\n\n### 使用 Docker Compose 更新\n\n如果您的 AB 是使用 Docker Compose 部署的，请使用 `docker compose pull` 来更新。\n拉取新镜像后，使用 `docker compose up -d` 来重启。\n\n您也可以在 `docker-compose.yml` 中添加 `pull_policy: always` 来在每次启动时拉取最新镜像。\n\n### 升级导致问题怎么办\n\n由于配置可能各不相同，升级可能会导致程序失败。在这种情况下，删除所有之前的数据和生成的配置文件，然后重启容器。\n然后在 WebUI 中重新配置。\n如果从旧版本升级，请先参阅[升级指南](/changelog/2.6)。\n\n如果您遇到上述未涵盖的问题，请使用 bug 模板在 [Issues][ISSUE] 报告。\n\n\n[ISSUE]: https://github.com/EstrellaXD/Auto_Bangumi/issues\n"
  },
  {
    "path": "docs/faq/network.md",
    "content": "# 网络问题\n\n## 无法连接到 Mikan Project\n\n由于 Mikan Project 主站（`https://mikanani.me`）在某些地区可能被屏蔽，AB 可能无法连接。请使用以下解决方案：\n\n- [使用 Mikan Project 备用域名](#mikan-project-备用域名)\n- [使用代理](#配置代理)\n- [使用 Cloudflare Worker 反向代理](#cloudflare-workers-反向代理)\n\n### Mikan Project 备用域名\n\nMikan Project 有一个新域名 `https://mikanime.tv`。使用此域名配合 AB，无需启用代理。\n\n如果您看到：\n```\nDNS/Connect ERROR\n```\n\n- 请检查您的网络连接。如果网络正常，请检查 DNS 解析。\n- 在 AB 中添加 `dns=8.8.8.8`。如果使用 Host 网络模式，可以忽略此项。\n\n如果您使用代理，正确配置后通常不会出现此错误。\n\n### 配置代理\n\n::: tip\n在 AB 3.1+ 中，AB 自己处理 RSS 更新和通知，因此您只需要在 AB 中配置代理。\n:::\n\nAB 有内置的代理配置。要配置代理，请按照[代理设置](../config/proxy)中的说明正确设置 HTTP 或 SOCKS 代理。这可以解决访问问题。\n\n**对于 3.1 之前的版本，还需要配置 qBittorrent 代理**\n\n如下图所示在 QB 中配置代理（SOCKS 方法相同）：\n\n<img width=\"483\" alt=\"image\" src=\"https://user-images.githubusercontent.com/33726646/233681562-cca3957a-a5de-40e2-8fb3-4cc7f57cc139.png\">\n\n\n### Cloudflare Workers 反向代理\n\n您也可以通过 Cloudflare Workers 使用反向代理方式。设置域名并绑定到 Cloudflare 超出了本指南的范围。\n在 Workers 中添加以下代码，即可使用您自己的域名访问 Mikan Project 并从 RSS 链接下载种子：\n\n```javascript\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // Only perform replacement if content type is RSS\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // Add CORS headers\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n\n完成配置后，**添加 RSS** 时将 `https://mikanani.me` 替换为您的域名。\n\n## 无法连接到 qBittorrent\n\n首先，检查 AB 中的 **下载器地址** 参数是否正确。\n- 如果 AB 和 QB 在同一个 Docker 网络中，请尝试使用容器名称进行寻址，例如 `http://qbittorrent:8080`。\n- 如果 AB 和 QB 在同一台 Docker 服务器上，请尝试使用 Docker 网关地址，例如 `http://172.17.0.1:8080`。\n- 如果 AB 的网络模式不是 `host`，请不要使用 `127.0.0.1` 访问 QB。\n\n如果 Docker 中的容器无法相互访问，请在 QB 的网络连接设置中设置 QB 和 AB 之间的网络链接。如果 qBittorrent 使用 HTTPS，请在 **下载器地址** 前添加 `https://` 前缀。\n"
  },
  {
    "path": "docs/faq/troubleshooting.md",
    "content": "---\ntitle: 故障排除\n---\n\n## 一般故障排除流程\n\n1. 如果 AB 无法启动，请检查启动命令是否正确。如果不正确且您不知道如何修复，请尝试重新部署 AB。\n2. 部署 AB 后，首先检查日志。如果您看到类似以下的输出，说明 AB 正常运行并已连接到 QB：\n      ```\n      [2022-07-09 21:55:19,164] INFO:\t                _        ____                                    _\n      [2022-07-09 21:55:19,165] INFO:\t     /\\        | |      |  _ \\                                  (_)\n      [2022-07-09 21:55:19,166] INFO:\t    /  \\  _   _| |_ ___ | |_) | __ _ _ __   __ _ _   _ _ __ ___  _\n      [2022-07-09 21:55:19,167] INFO:\t   / /\\ \\| | | | __/ _ \\|  _ < / _` | '_ \\ / _` | | | | '_ ` _ \\| |\n      [2022-07-09 21:55:19,167] INFO:\t  / ____ \\ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |\n      [2022-07-09 21:55:19,168] INFO:\t /_/    \\_\\__,_|\\__\\___/|____/ \\__,_|_| |_|\\__, |\\__,_|_| |_| |_|_|\n      [2022-07-09 21:55:19,169] INFO:\t                                            __/ |\n      [2022-07-09 21:55:19,169] INFO:\t                                           |___/\n      [2022-07-09 21:55:19,170] INFO:\tVersion 3.0.1  Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan\n      [2022-07-09 21:55:19,171] INFO:\tGitHub: https://github.com/EstrellaXD/Auto_Bangumi/\n      [2022-07-09 21:55:19,172] INFO:\tStarting AutoBangumi...\n      [2022-07-09 21:55:20,717] INFO:\tAdd RSS Feed successfully.\n      [2022-07-09 21:55:21,761] INFO:\tStart collecting RSS info.\n      [2022-07-09 21:55:23,431] INFO:\tFinished\n      [2022-07-09 21:55:23,432] INFO:\tRunning....\n      ```\n   1. 如果您看到此日志，说明 AB 无法连接到 qBittorrent。请检查 qBittorrent 是否正在运行。如果正在运行，请前往[网络问题](/faq/network)部分。\n        ```\n        [2022-07-09 22:01:24,534] WARNING:  Cannot connect to qBittorrent, wait 5min and retry\n        ```\n   2. 如果您看到此日志，说明 AB 无法连接到 Mikan RSS。请前往[网络问题](/faq/network)部分。\n        ```\n        [2022-07-09 21:55:21,761] INFO:\t    Start collecting RSS info.\n        [2022-07-09 22:01:24,534] WARNING:  Connected Failed, please check DNS/Connection\n        ```\n3. 此时，QB 应该有下载任务了。\n   1. 如果下载显示路径问题，请检查 QB 的\"保存管理\"→\"默认 Torrent 管理模式\"是否设置为\"手动\"。\n   2. 如果所有下载都显示感叹号或下载路径中没有创建分类文件夹，请检查 QB 的权限。\n4. 如果以上都无法解决问题，请尝试重新部署一个全新的 qBittorrent。\n5. 如果仍然不成功，请携带日志在 [Issues](https://www.github.com/EstrellaXD/Auto_Bangumi/issues) 报告。\n"
  },
  {
    "path": "docs/feature/bangumi.md",
    "content": "# 番剧管理\n\n点击首页的番剧海报可管理单个番剧条目。\n\n![Bangumi List](/image/feature/bangumi-list.png)\n\n如果一部番剧有多个下载规则（例如不同字幕组），将出现规则选择弹窗：\n\n![Rule Selection](/image/feature/rule-select.png)\n\n选择规则后，编辑弹窗打开：\n\n![Edit Bangumi](/image/feature/bangumi-edit.png)\n\n## 通知徽章\n\n从 v3.2 开始，番剧卡片显示 iOS 风格的通知徽章来指示状态：\n\n- **黄色徽章带 `!`**：订阅需要审核（例如检测到偏移问题）\n- **数字徽章**：该番剧存在多个规则\n- **组合徽章**（例如 `! | 2`）：有警告且有多个规则\n\n带有警告的卡片还会显示黄色发光动画以引起注意。\n\n## 剧集偏移自动检测\n\n某些番剧具有复杂的季度结构，导致 RSS 剧集编号与 TMDB 数据不匹配。例如：\n- \"葬送的芙莉莲\" 第一季分两部分播出，中间隔了 6 个月\n- RSS 可能显示 \"S2E01\"，而 TMDB 认为是 \"S1E29\"\n\nAB v3.2 可以自动检测这些问题：\n\n1. 点击编辑弹窗中的 **自动检测** 按钮\n2. AB 分析 TMDB 剧集播出日期以识别\"虚拟季度\"\n3. 如果发现不匹配，AB 会建议正确的偏移值\n4. 点击 **应用** 保存偏移\n\n后台扫描线程还会定期检查现有订阅的偏移问题，并标记需要审核的项目。\n\n## 归档 / 取消归档番剧\n\n从 v3.2 开始，您可以归档已完结或不活跃的番剧，以保持列表整洁。\n\n### 手动归档\n\n1. 点击番剧海报\n2. 在编辑弹窗中，点击 **归档** 按钮\n3. 番剧移至列表底部的\"已归档\"区域\n\n### 自动归档\n\nAB 可以在以下情况自动归档番剧：\n- TMDB 上的系列状态显示为\"已完结\"或\"已取消\"\n- 使用 **配置** → 刷新元数据触发自动归档\n\n### 查看已归档番剧\n\n已归档番剧出现在番剧列表底部的可折叠\"已归档\"区域。点击展开查看已归档项目。\n\n### 取消归档\n\n要恢复已归档番剧：\n1. 展开\"已归档\"区域\n2. 点击番剧海报\n3. 点击 **取消归档** 按钮\n\n## 禁用 / 删除番剧\n\n由于 AB 会持续解析**聚合 RSS** 订阅，对于来自聚合 RSS 中不再需要的下载规则：\n- 禁用番剧：番剧不会被下载或重新解析\n- 从聚合 RSS 中移除订阅\n\n如果您删除番剧条目，它将在下次解析周期被重新创建。\n\n## 高级设置\n\n点击编辑弹窗中的 **高级设置** 访问更多选项：\n\n![Advanced Settings](/image/feature/bangumi-edit-advanced.png)\n\n- **季度偏移**：调整季度编号偏移\n- **剧集偏移**：调整剧集编号偏移\n- **过滤**：用于种子匹配的自定义正则表达式过滤器\n"
  },
  {
    "path": "docs/feature/calendar.md",
    "content": "# 日历视图\n\n从 v3.2 开始，AB 包含日历视图，按播出日期显示您订阅的番剧。\n\n![Calendar](/image/feature/calendar.png)\n\n## 功能\n\n### 每周时间表\n\n日历按播出日期（周一至周日）组织显示番剧，另有\"未知\"列显示没有播出时间数据的番剧。\n\n### Bangumi.tv 集成\n\nAB 从 Bangumi.tv 获取播出时间数据，以准确显示每部番剧的播出时间。\n\n点击 **刷新时间表** 按钮更新播出数据。\n\n### 分组显示\n\n从 v3.2 开始，具有多个下载规则的番剧会被分组显示：\n\n- 同一番剧只显示一次，即使有多个字幕组规则\n- 点击分组番剧可查看所有可用规则\n- 选择特定规则进行编辑\n\n这样可以保持日历整洁，同时仍能访问所有规则。\n\n## 导航\n\n点击日历中的任意番剧海报可：\n- 查看番剧详情\n- 编辑下载规则\n- 访问归档/禁用选项\n\n## 提示\n\n::: tip\n如果番剧出现在\"未知\"列，可能是 Bangumi.tv 上没有播出数据，或番剧标题无法匹配。\n:::\n"
  },
  {
    "path": "docs/feature/rename.md",
    "content": "# 文件重命名\n\nAB 目前提供三种重命名方式：`pn`、`advance` 和 `none`。\n\n### pn\n\n`pure name` 的缩写。此方法使用种子下载名称进行重命名。\n\n示例：\n```\n[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six S01E01.mkv\n```\n\n### advance\n\n高级重命名。此方法使用父文件夹名称进行重命名。\n\n```\n/downloads/Bangumi/86 - Eighty Six(2023)/Season 1/[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six(2023) S01E01.mkv\n```\n\n### none\n\n不重命名。文件保持原样。\n\n## 收藏重命名\n\nAB 支持收藏重命名。收藏重命名需要：\n- 剧集位于收藏的一级目录中\n- 可以从文件名解析出剧集编号\n\nAB 还可以重命名一级目录中的字幕文件。\n\n重命名后，剧集和目录将放置在 `Season` 文件夹中。\n\n重命名后的收藏将被移动并归类到 `BangumiCollection` 下。\n\n## 剧集偏移\n\n从 v3.2 开始，AB 支持重命名时的剧集偏移。在以下情况下很有用：\n- RSS 显示的剧集编号与预期不同（例如 S2E01 应该是 S1E29）\n- 番剧因播出间隔而产生\"虚拟季度\"\n\n当为番剧配置偏移后，AB 会在重命名时自动应用：\n\n```\n原始：S02E01.mkv\n应用偏移（季度：-1，剧集：+28）：S01E29.mkv\n```\n\n配置偏移：\n1. 点击番剧海报\n2. 打开高级设置\n3. 设置季度偏移和/或剧集偏移值\n4. 或使用\"自动检测\"让 AB 建议正确的偏移\n\n有关自动检测的更多详情，请参阅[番剧管理](./bangumi.md#episode-offset-auto-detection)。\n"
  },
  {
    "path": "docs/feature/rss.md",
    "content": "---\ntitle: RSS 管理\n---\n\n# RSS 管理\n\n## RSS 管理页面\n\nRSS 管理页面显示您所有的 RSS 订阅及其连接状态。\n\n![RSS Manager](/image/feature/rss-manager.png)\n\n### 连接状态\n\n从 v3.2 开始，AB 会跟踪每个 RSS 源的连接状态：\n\n| 状态 | 说明 |\n|------|------|\n| **已连接**（绿色） | RSS 源可访问且返回有效数据 |\n| **错误**（红色） | RSS 源无法响应或返回无效数据 |\n\n当源显示错误时，将鼠标悬停在状态标签上可在提示框中查看错误详情。\n\nAB 会在每次 RSS 刷新周期自动更新连接状态。\n\n## 添加收藏\n\nAB 提供两种手动下载方式：\n**收藏** 和 **订阅**。\n- **收藏** 一次性下载所有剧集，适用于已完结番剧。\n- **订阅** 添加带有对应 RSS 链接的自动下载规则，适用于连载番剧。\n\n### 解析 RSS 链接\n\nAB 支持解析所有资源站的收藏 RSS 链接。在对应网站找到您想要的番剧收藏 RSS，点击 AB 右上角的 **+** 按钮，在弹出窗口中粘贴 RSS 链接。\n\n### 添加下载\n\n如果解析成功，将出现一个窗口显示解析后的番剧信息。点击 **收藏** 或 **订阅** 将其添加到下载队列。\n\n### 常见问题\n\n如果出现解析错误，可能是由于 RSS 链接不正确或字幕组命名格式不支持。\n\n## 管理番剧\n\n从 v3.0 开始，AB 在 WebUI 中提供手动番剧管理功能，允许您手动调整解析错误的番剧信息。\n\n### 编辑番剧信息\n\n在番剧列表中，点击番剧海报进入番剧信息页面。\n修改信息后，点击 **应用**。\nAB 将根据您的更改重新调整目录并自动重命名文件。\n\n\n### 删除番剧\n\n从 v3.0 开始，您可以手动删除番剧。点击番剧海报，进入信息页面，点击 **删除**。\n\n::: warning\n删除番剧后，如果 RSS 订阅未取消，AB 仍会重新解析。要禁用下载规则，请使用[禁用番剧](#禁用番剧)。\n:::\n\n### 禁用番剧\n\n从 v3.0 开始，您可以手动禁用番剧。点击番剧海报，进入信息页面，点击 **禁用**。\n\n禁用后，番剧海报将变灰并排序到末尾。要重新启用下载规则，点击 **启用**。\n"
  },
  {
    "path": "docs/feature/search.md",
    "content": "# 种子搜索\n\n从 v3.1 开始，AB 包含搜索功能，可快速查找番剧。\n\n## 使用搜索功能\n\n::: warning\n搜索功能依赖主程序的解析器。当前版本不支持解析收藏。解析收藏时出现 `warning` 是正常现象。\n:::\n\n搜索栏位于 AB 顶栏。点击打开搜索面板。\n\n![Search Panel](/image/feature/search-panel.png)\n\n选择资源站，输入关键词，AB 将自动解析并显示搜索结果。要添加番剧，点击卡片右侧的添加按钮。\n\n::: tip\n当资源站为 **Mikan** 时，AB 默认使用 `mikan` 解析器。其他资源站使用 TMDB 解析器。\n:::\n\n## 管理搜索源\n\n从 v3.2 开始，您可以直接在设置页面管理搜索源，无需编辑 JSON 文件。\n\n### 搜索源设置面板\n\n导航至 **配置** → **搜索源** 访问设置面板。\n\n![Search Provider Settings](/image/feature/search-provider.png)\n\n在这里您可以：\n- **查看** 所有已配置的搜索源\n- **添加** 新搜索源，使用\"添加源\"按钮\n- **编辑** 现有源的 URL\n- **删除** 自定义源（默认源 mikan、nyaa、dmhy 不能删除）\n\n### URL 模板格式\n\n添加自定义源时，URL 必须包含 `%s` 作为搜索关键词的占位符。\n\n示例：\n```\nhttps://example.com/rss/search?q=%s\n```\n\n`%s` 将被替换为用户的搜索查询。\n\n### 默认源\n\n以下源为内置源，不能删除：\n\n| 源 | URL 模板 |\n|----|----------|\n| mikan | `https://mikanani.me/RSS/Search?searchstr=%s` |\n| nyaa | `https://nyaa.si/?page=rss&q=%s&c=0_0&f=0` |\n| dmhy | `http://dmhy.org/topics/rss/rss.xml?keyword=%s` |\n\n### 通过配置文件添加源\n\n您也可以通过编辑 `config/search_provider.json` 手动添加源：\n\n```json\n{\n  \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n  \"nyaa\": \"https://nyaa.si/?page=rss&q=%s&c=0_0&f=0\",\n  \"dmhy\": \"http://dmhy.org/topics/rss/rss.xml?keyword=%s\",\n  \"bangumi.moe\": \"https://bangumi.moe/rss/search/%s\"\n}\n```\n"
  },
  {
    "path": "docs/home/index.md",
    "content": "---\ntitle: 关于\n---\n\n<p align=\"center\">\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"/image/icons/dark-icon.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"/image/icons/light-icon.svg\">\n  <img src=\"/image/icons/light-icon.svg\" width=50%>\n</picture>\n</p>\n\n\n## 关于 AutoBangumi\n\n\n<p align=\"center\">\n  <img\n    title=\"AutoBangumi WebUI\"\n    alt=\"AutoBangumi WebUI\"\n    src=\"/image/preview/window.png\"\n    width=85%\n    data-zoomable\n  >\n</p>\n\n**`AutoBangumi`** 是一款基于 RSS 订阅的全自动番剧下载和整理工具。只需在 [Mikan Project][mikan] 或类似网站订阅番剧，即可自动追踪和下载最新剧集。\n\n整理后的文件名和目录结构可直接兼容 [Plex][plex]、[Jellyfin][jellyfin] 等媒体库软件，无需额外的元数据刮削。\n\n## 功能特性\n\n- 简单的一次性配置，持续使用\n- 无需操心的 RSS 解析器，可提取番剧信息并自动生成下载规则\n- 番剧文件整理：\n\n  ```\n  Bangumi\n  ├── bangumi_A_title\n  │   ├── Season 1\n  │   │   ├── A S01E01.mp4\n  │   │   ├── A S01E02.mp4\n  │   │   ├── A S01E03.mp4\n  │   │   └── A S01E04.mp4\n  │   └── Season 2\n  │       ├── A S02E01.mp4\n  │       ├── A S02E02.mp4\n  │       ├── A S02E03.mp4\n  │       └── A S02E04.mp4\n  ├── bangumi_B_title\n  │   └─── Season 1\n  ```\n\n- 全自动重命名——超过 99% 的番剧文件在重命名后可被媒体库软件直接刮削\n\n  ```\n  [Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4\n  >>\n  Kakkou no Iinazuke S01E07.mp4\n  ```\n\n- 基于父文件夹名称自定义重命名所有子文件\n- 季中追番补全当季所有缺失剧集\n- 高度自定义选项，可针对不同媒体库软件进行微调\n- 零维护，完全透明运行\n- 内置 TMDB 解析器，可生成完整的 TMDB 格式文件和番剧元数据\n- 支持 Mikan RSS 订阅的反向代理\n\n## 社区\n\n- 更新通知：[Telegram 频道](https://t.me/autobangumi_update)\n- Bug 反馈：[Telegram](https://t.me/+yNisOnDGaX5jMTM9)\n\n## 致谢\n\n感谢 [Sean](https://github.com/findix) 对项目的大力帮助。\n\n## 参与贡献\n\n欢迎提交 Issues 和 Pull Requests！\n\n<a href=\"https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi\" />\n</a>\n\n## 免责声明\n\n由于 AutoBangumi 通过非官方版权渠道获取番剧：\n\n- **请勿**将 AutoBangumi 用于商业用途。\n- **请勿**制作包含 AutoBangumi 的视频内容并在国内视频平台（版权相关方）上发布。\n- **请勿**将 AutoBangumi 用于任何违反法律法规的活动。\n\nAutoBangumi 仅供学习和个人使用。\n\n## 许可证\n\n[MIT License](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE)\n\n[mikan]: https://mikanani.me\n[plex]: https://plex.tv\n[jellyfin]: https://jellyfin.org\n"
  },
  {
    "path": "docs/home/pipline.md",
    "content": "# AutoBangumi 工作原理\n\nAutoBangumi（简称 AB）本质上是一个 RSS 解析器。它从番剧种子站点解析 RSS 订阅源，从种子标题中提取元数据，生成下载规则并发送给 qBittorrent 进行下载。下载完成后，将文件整理成标准的媒体库目录结构。\n\n## 流程概览\n\n1. **RSS 解析** — AB 定期获取并解析您订阅的 RSS 源\n2. **标题分析** — 解析种子标题以提取番剧名称、集数、季度、字幕组和分辨率\n3. **规则生成** — 根据解析的信息在 qBittorrent 中创建下载规则\n4. **下载管理** — qBittorrent 负责实际的种子下载\n5. **文件整理** — 下载的文件被重命名并移动到标准化的目录结构中\n6. **媒体库就绪** — 整理后的文件可直接被 Plex、Jellyfin 等媒体服务器识别\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\ntitle: AutoBangumi\ntitleTemplate: 全自动追番，解放双手！\n\nhero:\n  name: AutoBangumi\n  text: 全自动追番，解放双手！\n  tagline: 全自动 RSS 订阅解析、下载管理和文件整理\n  actions:\n    - theme: brand\n      text: 快速开始\n      link: /deploy/quick-start\n    - theme: alt\n      text: 关于\n      link: /home/\n    - theme: alt\n      text: 更新日志\n      link: /changelog/3.2\n\nfeatures:\n  - icon:\n      src: /image/icons/rss.png\n    title: RSS 订阅解析\n    details: 自动识别并解析番剧 RSS 订阅源。无需手动输入，只需订阅即可自动完成解析、下载和整理。\n  - icon:\n      src: /image/icons/qbittorrent-logo.svg\n    title: qBittorrent 下载器\n    details: 使用 qBittorrent 下载番剧资源。在 AutoBangumi 中即可管理现有番剧、下载往期番剧以及删除条目。\n  - icon:\n      src: /image/icons/tmdb-icon.png\n    title: TMDB 元数据匹配\n    details: 通过 TMDB 匹配番剧信息以获取准确的元数据，确保即使在多个字幕组之间也能正确解析。\n  - icon:\n      src: /image/icons/plex-icon.png\n    title: Plex / Jellyfin / Infuse ...\n    details: 根据匹配结果自动整理文件名和目录结构，确保媒体库软件能够高成功率地刮削元数据。\n---\n\n\n<div class=\"container\">\n<div class=\"vp-doc\">\n\n## 鸣谢\n\n### 致谢\n感谢\n- [Mikan Project](https://mikanani.me) 提供了如此优秀的番剧资源。\n- [VitePress](https://vitepress.dev) 提供了优秀的文档框架。\n- [qBittorrent](https://www.qbittorrent.org) 提供了优秀的下载器。\n- [Plex](https://www.plex.tv) / [Jellyfin](https://jellyfin.org) 提供了优秀的自托管媒体库。\n- [Infuse](https://firecore.com/infuse) 提供了优雅的视频播放器。\n- [弹弹 Play](https://www.dandanplay.com) 提供了优秀的弹幕播放器。\n- 每一个番剧制作组 / 字幕组 / 爱好者。\n\n### 贡献者\n\n[\n  ![](https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi){class=contributors-avatar}\n](https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors)\n\n## 免责声明\n\n由于 AutoBangumi 通过非官方版权渠道获取番剧：\n\n- **请勿**将 AutoBangumi 用于商业用途。\n- **请勿**制作包含 AutoBangumi 的视频内容并在国内视频平台（版权相关方）上发布。\n- **请勿**将 AutoBangumi 用于任何违反法律法规的活动。\n\n</div>\n</div>\n\n<style scoped>\n.container {\n  display: flex;\n  position: relative;\n  margin: 0 auto;\n  padding: 0 24px;\n  max-width: 1280px;\n}\n\n@media (min-width: 640px) {\n  .container {\n    padding-inline: 48px;\n  }\n}\n\n@media (min-width: 960px) {\n  .container {\n    padding-inline: 64px;\n  }\n}\n\n\n.contributors-avatar {\n  width: 600px;\n}\n</style>\n"
  },
  {
    "path": "docs/ja/api/index.md",
    "content": "# REST APIリファレンス\n\nAutoBangumiは`/api/v1`でREST APIを公開しています。すべてのエンドポイント（ログインとセットアップを除く）はJWT認証が必要です。\n\n**ベースURL:** `http://your-host:7892/api/v1`\n\n**認証:** JWTトークンをCookieまたは`Authorization: Bearer <token>`ヘッダーとして含めてください。\n\n**インタラクティブドキュメント:** 開発モードで実行している場合、Swagger UIは`http://your-host:7892/docs`で利用可能です。\n\n---\n\n## 認証\n\n### ログイン\n\n```\nPOST /auth/login\n```\n\nユーザー名とパスワードで認証します。\n\n**リクエストボディ:**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n**レスポンス:** JWTトークン付きの認証Cookieを設定します。\n\n### トークンのリフレッシュ\n\n```\nGET /auth/refresh_token\n```\n\n現在の認証トークンをリフレッシュします。\n\n### ログアウト\n\n```\nGET /auth/logout\n```\n\n認証Cookieをクリアしてログアウトします。\n\n### 認証情報の更新\n\n```\nPOST /auth/update\n```\n\nユーザー名および/またはパスワードを更新します。\n\n**リクエストボディ:**\n```json\n{\n  \"username\": \"string\",\n  \"password\": \"string\"\n}\n```\n\n---\n\n## Passkey / WebAuthn <Badge type=\"tip\" text=\"v3.2+\" />\n\nWebAuthn/FIDO2 Passkeysを使用したパスワードレス認証。\n\n### Passkeyの登録\n\n```\nPOST /passkey/register/options\n```\n\nWebAuthn登録オプション（チャレンジ、リライングパーティ情報）を取得します。\n\n```\nPOST /passkey/register/verify\n```\n\nブラウザからのPasskey登録レスポンスを検証して保存します。\n\n### Passkeyで認証\n\n```\nPOST /passkey/auth/options\n```\n\nWebAuthn認証チャレンジオプションを取得します。\n\n```\nPOST /passkey/auth/verify\n```\n\nPasskey認証レスポンスを検証し、JWTトークンを発行します。\n\n### Passkeyの管理\n\n```\nGET /passkey/list\n```\n\n現在のユーザーの登録済みPasskeyをすべてリストします。\n\n```\nPOST /passkey/delete\n```\n\nクレデンシャルIDで登録済みPasskeyを削除します。\n\n---\n\n## 設定\n\n### 設定の取得\n\n```\nGET /config/get\n```\n\n現在のアプリケーション設定を取得します。\n\n**レスポンス:** `program`、`downloader`、`rss_parser`、`bangumi_manager`、`notification`、`proxy`、`experimental_openai`セクションを含む完全な設定オブジェクト。\n\n### 設定の更新\n\n```\nPATCH /config/update\n```\n\nアプリケーション設定を部分的に更新します。変更したいフィールドのみを含めてください。\n\n**リクエストボディ:** 部分的な設定オブジェクト。\n\n---\n\n## 番組（アニメルール）\n\n### すべての番組をリスト\n\n```\nGET /bangumi/get/all\n```\n\nすべてのアニメダウンロードルールを取得します。\n\n### IDで番組を取得\n\n```\nGET /bangumi/get/{bangumi_id}\n```\n\nIDで特定のアニメルールを取得します。\n\n### 番組の更新\n\n```\nPATCH /bangumi/update/{bangumi_id}\n```\n\nアニメルールのメタデータ（タイトル、シーズン、エピソードオフセットなど）を更新します。\n\n### 番組の削除\n\n```\nDELETE /bangumi/delete/{bangumi_id}\n```\n\n単一のアニメルールと関連するトレントを削除します。\n\n```\nDELETE /bangumi/delete/many/\n```\n\n複数のアニメルールを一括削除します。\n\n**リクエストボディ:**\n```json\n{\n  \"bangumi_ids\": [1, 2, 3]\n}\n```\n\n### 番組の無効化 / 有効化\n\n```\nDELETE /bangumi/disable/{bangumi_id}\n```\n\nアニメルールを無効化します（ファイルは保持、ダウンロード停止）。\n\n```\nDELETE /bangumi/disable/many/\n```\n\n複数のアニメルールを一括無効化します。\n\n```\nGET /bangumi/enable/{bangumi_id}\n```\n\n以前に無効化されたアニメルールを再有効化します。\n\n### ポスターのリフレッシュ\n\n```\nGET /bangumi/refresh/poster/all\n```\n\nすべてのアニメのポスター画像をTMDBからリフレッシュします。\n\n```\nGET /bangumi/refresh/poster/{bangumi_id}\n```\n\n特定のアニメのポスター画像をリフレッシュします。\n\n### カレンダー\n\n```\nGET /bangumi/refresh/calendar\n```\n\nBangumi.tvからアニメ放送カレンダーデータをリフレッシュします。\n\n### すべてリセット\n\n```\nGET /bangumi/reset/all\n```\n\nすべてのアニメルールを削除します。注意して使用してください。\n\n---\n\n## RSSフィード\n\n### すべてのフィードをリスト\n\n```\nGET /rss\n```\n\n設定されたすべてのRSSフィードを取得します。\n\n### フィードの追加\n\n```\nPOST /rss/add\n```\n\n新しいRSSフィード購読を追加します。\n\n**リクエストボディ:**\n```json\n{\n  \"url\": \"string\",\n  \"aggregate\": true,\n  \"parser\": \"mikan\"\n}\n```\n\n### フィードの有効化 / 無効化\n\n```\nPOST /rss/enable/many\n```\n\n複数のRSSフィードを有効化します。\n\n```\nPATCH /rss/disable/{rss_id}\n```\n\n単一のRSSフィードを無効化します。\n\n```\nPOST /rss/disable/many\n```\n\n複数のRSSフィードを一括無効化します。\n\n### フィードの削除\n\n```\nDELETE /rss/delete/{rss_id}\n```\n\n単一のRSSフィードを削除します。\n\n```\nPOST /rss/delete/many\n```\n\n複数のRSSフィードを一括削除します。\n\n### フィードの更新\n\n```\nPATCH /rss/update/{rss_id}\n```\n\nRSSフィードの設定を更新します。\n\n### フィードのリフレッシュ\n\n```\nGET /rss/refresh/all\n```\n\nすべてのRSSフィードのリフレッシュを手動でトリガーします。\n\n```\nGET /rss/refresh/{rss_id}\n```\n\n特定のRSSフィードをリフレッシュします。\n\n### フィードからトレントを取得\n\n```\nGET /rss/torrent/{rss_id}\n```\n\n特定のRSSフィードから解析されたトレントのリストを取得します。\n\n### 分析と購読\n\n```\nPOST /rss/analysis\n```\n\nRSS URLを分析し、購読せずにアニメのメタデータを抽出します。\n\n**リクエストボディ:**\n```json\n{\n  \"url\": \"string\"\n}\n```\n\n```\nPOST /rss/collect\n```\n\nRSSフィードからすべてのエピソードをダウンロードします（完結したアニメ用）。\n\n```\nPOST /rss/subscribe\n```\n\n自動継続ダウンロード用にRSSフィードを購読します。\n\n---\n\n## 検索\n\n### 番組検索（Server-Sent Events）\n\n```\nGET /search/bangumi?keyword={keyword}&provider={provider}\n```\n\nアニメのトレントを検索します。リアルタイム更新のためにServer-Sent Events（SSE）ストリームとして結果を返します。\n\n**クエリパラメータ:**\n- `keyword` — 検索キーワード\n- `provider` — 検索プロバイダー（例：`mikan`、`nyaa`、`dmhy`）\n\n**レスポンス:** 解析された検索結果を含むSSEストリーム。\n\n### 検索プロバイダーのリスト\n\n```\nGET /search/provider\n```\n\n利用可能な検索プロバイダーのリストを取得します。\n\n---\n\n## プログラム制御\n\n### ステータスの取得\n\n```\nGET /status\n```\n\nバージョン、実行状態、first_runフラグを含むプログラムステータスを取得します。\n\n**レスポンス:**\n```json\n{\n  \"status\": \"running\",\n  \"version\": \"3.2.0\",\n  \"first_run\": false\n}\n```\n\n### プログラムの開始\n\n```\nGET /start\n```\n\nメインプログラム（RSSチェック、ダウンロード、リネーム）を開始します。\n\n### プログラムの再起動\n\n```\nGET /restart\n```\n\nメインプログラムを再起動します。\n\n### プログラムの停止\n\n```\nGET /stop\n```\n\nメインプログラムを停止します（WebUIはアクセス可能なまま）。\n\n### シャットダウン\n\n```\nGET /shutdown\n```\n\nアプリケーション全体をシャットダウンします（Dockerコンテナを再起動します）。\n\n### ダウンローダーのチェック\n\n```\nGET /check/downloader\n```\n\n設定されたダウンローダー（qBittorrent）への接続をテストします。\n\n---\n\n## ダウンローダー管理 <Badge type=\"tip\" text=\"v3.2+\" />\n\nAutoBangumiから直接ダウンローダー内のトレントを管理します。\n\n### トレントのリスト\n\n```\nGET /downloader/torrents\n```\n\nBangumiカテゴリ内のすべてのトレントを取得します。\n\n### トレントの一時停止\n\n```\nPOST /downloader/torrents/pause\n```\n\nハッシュでトレントを一時停止します。\n\n**リクエストボディ:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### トレントの再開\n\n```\nPOST /downloader/torrents/resume\n```\n\nハッシュで一時停止したトレントを再開します。\n\n**リクエストボディ:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"]\n}\n```\n\n### トレントの削除\n\n```\nPOST /downloader/torrents/delete\n```\n\nオプションでファイル削除を伴うトレントを削除します。\n\n**リクエストボディ:**\n```json\n{\n  \"hashes\": [\"hash1\", \"hash2\"],\n  \"delete_files\": false\n}\n```\n\n---\n\n## セットアップウィザード <Badge type=\"tip\" text=\"v3.2+\" />\n\nこれらのエンドポイントは、初回実行セットアップ中（セットアップ完了前）にのみ利用可能です。認証は**不要**です。セットアップ完了後、すべてのエンドポイントは`403 Forbidden`を返します。\n\n### セットアップステータスの確認\n\n```\nGET /setup/status\n```\n\nセットアップウィザードが必要かどうか（初回実行）を確認します。\n\n**レスポンス:**\n```json\n{\n  \"need_setup\": true\n}\n```\n\n### ダウンローダー接続のテスト\n\n```\nPOST /setup/test-downloader\n```\n\n提供された認証情報でダウンローダーへの接続をテストします。\n\n**リクエストボディ:**\n```json\n{\n  \"type\": \"qbittorrent\",\n  \"host\": \"172.17.0.1:8080\",\n  \"username\": \"admin\",\n  \"password\": \"adminadmin\",\n  \"ssl\": false\n}\n```\n\n### RSSフィードのテスト\n\n```\nPOST /setup/test-rss\n```\n\nRSS URLがアクセス可能で解析可能か検証します。\n\n**リクエストボディ:**\n```json\n{\n  \"url\": \"https://mikanime.tv/RSS/MyBangumi?token=xxx\"\n}\n```\n\n### 通知のテスト\n\n```\nPOST /setup/test-notification\n```\n\n提供された設定でテスト通知を送信します。\n\n**リクエストボディ:**\n```json\n{\n  \"type\": \"telegram\",\n  \"token\": \"bot_token\",\n  \"chat_id\": \"chat_id\"\n}\n```\n\n### セットアップの完了\n\n```\nPOST /setup/complete\n```\n\nすべての設定を保存し、セットアップを完了としてマークします。センチネルファイル`config/.setup_complete`を作成します。\n\n**リクエストボディ:** 完全な設定オブジェクト。\n\n---\n\n## ログ\n\n### ログの取得\n\n```\nGET /log\n```\n\n完全なアプリケーションログファイルを取得します。\n\n### ログのクリア\n\n```\nGET /log/clear\n```\n\nログファイルをクリアします。\n\n---\n\n## レスポンス形式\n\nすべてのAPIレスポンスは一貫した形式に従います：\n\n```json\n{\n  \"msg_en\": \"Success message in English\",\n  \"msg_zh\": \"Success message in Chinese\",\n  \"status\": true\n}\n```\n\nエラーレスポンスには、両方の言語でのエラーメッセージとともに適切なHTTPステータスコード（400、401、403、404、500）が含まれます。\n"
  },
  {
    "path": "docs/ja/changelog/2.6.md",
    "content": "# [2.6] リリースノート\n\n## 古いバージョンからのアップグレードノート\n\nバージョン2.6以降、AutoBangumi（AB）の設定は環境変数から`config.json`に移動しました。アップグレード前に以下の点に注意してください。\n\n### 環境変数の移行\n\n古い環境変数は、2.6へのアップグレード後の初回起動時に自動的に`config.json`に変換されます。生成された`config.json`は`/app/config`フォルダに配置されます。\n`/app/config`フォルダをマッピングすると、古い環境変数はABの動作に影響しなくなります。`config.json`を削除すると、環境変数から再生成できます。\n\n### コンテナボリュームマッピング\n\nバージョン2.6以降、以下のフォルダをマッピングする必要があります：\n\n- `/app/config`：`config.json`を含む設定フォルダ\n- `/app/data`：`bangumi.json`などを含むデータフォルダ\n\n### データファイル\n\n大きな更新があるため、古いデータファイルの使用は推奨しません。ABは自動的に`/app/data`に新しい`bangumi.json`を生成します。\n\n心配しないでください — QBは以前にダウンロードしたアニメを再ダウンロードしません。\n\n### 以降の設定変更\n\nABはWebUIで直接設定を編集できるようになりました。編集後、変更を有効にするにはコンテナを再起動してください。\n\n## アップグレード方法\n\n### Docker Compose\n\n既存のdocker-compose.ymlファイルを使用してアップグレードできます：\n\n```bash\ndocker compose stop autobangumi\ndocker compose pull autobangumi\n```\n\n次に、docker-compose.ymlを変更してボリュームマッピングを追加します：\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  autobangumi:\n    image: estrellaxd/auto_bangumi:latest\n    container_name: autobangumi\n    restart: unless-stopped\n    environment:\n      - PUID=1000\n      - PGID=1000\n      - TZ=Asia/Shanghai\n    volumes:\n      - /path/to/config:/app/config\n      - /path/to/data:/app/data\n    networks:\n      - bridge\n    dns:\n      - 8.8.8.8\n```\n\nその後ABを起動します：\n\n```bash\ndocker compose up -d autobangumi\n```\n\n### Portainer\n\nPortainerで、ボリュームマッピングを変更し、`Recreate`をクリックしてアップグレードを完了します。\n\n### アップグレードで問題が発生した場合\n\n設定は様々であるため、アップグレードによりプログラムが失敗する場合があります。以前のすべてのデータと生成された設定ファイルを削除し、コンテナを再起動してWebUIで再設定してください。\n\n\n## 新機能\n\n### 設定方法の変更\n\nv2.6以降、プログラム設定はDocker環境変数から`config.json`に移動しました。\n新しいWebUIはWebベースの設定エディターも提供します。AB URLにアクセスし、サイドバーで`設定`を見つけて設定を変更してください。編集後はコンテナを再起動してください。\n\n### カスタムリバースプロキシURLとABのプロキシリレー\n\n[Mikan Project](https://mikanani.me)にアクセスできない状況に対処するため、ABは3つのアプローチを提供します：\n\n1. HTTPとSOCKSプロキシ\n\n    この機能は古いバージョンに存在しました。2.6にアップグレードした後、WebUIでプロキシ設定を確認するだけでMikan Projectに正常にアクセスできます。\n\n    ただし、qBittorrentはまだMikanのRSSとトレントURLに直接アクセスできないため、qBittorrentにもプロキシを追加する必要があります。詳細は#198を参照してください。\n\n2. カスタムリバースプロキシURL\n\n    バージョン2.6ではカスタムリバースプロキシURL用の`custom_url`オプションが追加されました。\n    適切に設定されたリバースプロキシURLに設定してください。ABはこのカスタムURLを使用してMikan Projectにアクセスし、QBは正常にダウンロードできます。\n\n3. ABのプロキシリレー\n\n    ABでプロキシを設定した後、ABはローカルプロキシリレーとして機能できます（現在はRSS関連機能のみ）。\n    `custom_url`を`http://abhost:abport`に設定します。ここで`abhost`はABのIP、`abport`はABのポートです。\n    ABは自分のアドレスをqBittorrentにプッシュし、qBittorrentはABをプロキシとして使用してMikan Projectにアクセスします。\n\n    注意：NginxなどでABのリバースプロキシを設定していない場合は、適切な動作を確保するために`http://`を含めてください。\n\n**重要な注意事項**\n\nABとQBが同じコンテナにある場合、`127.0.0.1`や`localhost`を使用しないでください。この方法では通信できません。\n同じネットワーク上にある場合は、コンテナ名アドレッシングを使用してください。例：`http://autobangumi:7892`。\n\nDockerゲートウェイアドレスも使用できます。例：`http://172.17.0.1:7892`。\n\n異なるホストにある場合は、ホストマシンのIPアドレスを使用してください。\n\n### コレクションとフォルダのリネーム\n\nABはコレクションとフォルダ内のファイルをリネームし、メディアファイルをルートディレクトリに戻すことができるようになりました。\nABは保存パスに依存してシーズンとエピソード情報を決定するため、ABの標準に従ってコレクションファイルを配置してください。\n\nバージョン**2.6.4**以降、ABはフォルダ内の字幕をリネームできます（機能はまだ改善中）。コレクションと字幕はデフォルトで`pn`形式リネームです。調整オプションはまだ利用できません。\n\n**標準パス**\n\n```\n/downloads/Bangumi/Title/Season 1/xxx\n```\n\n### プッシュ通知\n\nABは`Telegram`と`ServerChan`を介してリネーム完了通知を送信できるようになりました。\n\nWebUIで、プッシュ通知を有効にし、必要なパラメータを入力してください。\n\n- Telegramには Bot TokenとChat IDが必要です。取得方法については様々なチュートリアルを参照してください。\n- ServerChanにはTokenが必要です。取得方法については様々なチュートリアルを参照してください。\n"
  },
  {
    "path": "docs/ja/changelog/3.0.md",
    "content": "# [3.0] リリースノート\n\n### 新しいWebUI\n\n- ログイン機能 — ABがユーザー名/パスワード認証をサポート。一部の操作にはログインが必要。\n- 新しいポスターウォール\n- 番組管理機能\n  - アニメシーズン情報と名前を編集。変更は自動的に**ダウンロードルール** / **ダウンロード済みファイルパス**を更新し、リネームをトリガー。\n  - 新しいリンクパーサー — リンクを解析した後、ダウンロード情報を手動で調整、ダウンロードシーズンを選択、または自動ダウンロードルールを追加可能。\n  - アニメ削除 — アニメとそのトレントファイルをワンクリックで削除。\n  - アニメごとのカスタムダウンロードルール、グローバルルールから独立。\n- より簡単なアプリケーションルール設定のための新しい設定インターフェース\n- 初回起動ガイダンス用の初期化ページを追加\n- qBittorrent接続用のダウンローダー接続チェッカー\n- RSSフィードが有効かどうかをチェックするRSS URLバリデーター\n- WebUIからプログラムの開始/停止とコンテナの再起動のためのプログラム管理ボタンを追加\n\n### パーサー\n\n- 公式タイトルとポスターURLを取得するための異なるソースタイプをサポートする新しいパーサー\n- データベースを再生成せずにRSS購読ソースの変更をサポート\n\n### 通知モジュール\n\n- `Bark`通知モジュールを追加\n- 新しい通知形式 — ポスター、アニメ名、更新されたエピソード番号をTelegramにプッシュ可能に\n\n### データ移行\n\n- 古いバージョンからアップグレード時の自動データ移行\n- 移行されたデータも自動的にポスターをマッチング\n\n## 修正\n\n- Windowsパスで発生する可能性のあるリネームバグを修正\n\n## 変更\n\n- データストレージを`json`から`sqlite`に移行\n- マルチプロセッシングからマルチスレッドに移行\n  - メインプログラムのリファクタリング\n  - 起動/シャットダウン時間の改善\n- パーサーモジュールのリファクタリング\n- リネームモジュールのリファクタリング\n  - 一時的に`normal`モードを削除\n- `ghcr.io`イメージレジストリを追加\n"
  },
  {
    "path": "docs/ja/changelog/3.1.md",
    "content": "# [3.1] - 2023-08\n\n- バックエンドとフロントエンドリポジトリを統合、プロジェクトディレクトリ構造を最適化\n- バージョンリリースワークフローを最適化\n- WikiをVitePressに移行：https://autobangumi.org\n\n## バックエンド\n\n### 機能\n\n- `RSS Engine`モジュールを追加 — ABが独立してRSS購読を更新・管理し、トレントをダウンローダーに送信可能に\n  - RSS Engineモジュールで管理される複数の集約RSS購読ソースをサポート\n  - ダウンロード重複排除 — 重複して購読されたトレントは再ダウンロードされない\n  - RSS購読の手動リフレッシュAPIを追加\n  - RSS購読管理APIを追加\n- `Search Engine`モジュールを追加 — キーワードでトレントを検索し、結果をコレクションまたは購読タスクとして解析\n  - `mikan`、`dmhy`、`nyaa`をサポートするプラグインベースの検索エンジン\n- 個別グループ設定用の字幕グループ固有ルールを追加\n- IPv6リスニングサポートを追加（環境変数で`IPV6=1`を設定）\n- ルールとRSS購読の一括管理用バッチ操作APIを追加\n\n### 変更\n\n- データベース構造を`sqlmodel`に変更してデータベース管理\n- シームレスなソフトウェアデータ更新のためにバージョン管理を追加\n- API形式を統一\n- APIレスポンス言語オプションを追加\n- データベースモックテストを追加\n- コード最適化\n\n### バグ修正\n\n- 様々な小さな問題を修正\n- いくつかの大きな問題を導入\n\n## フロントエンド\n\n### 機能\n\n- `i18n`サポートを追加 — 現在`zh-CN`と`en-US`をサポート\n- PWAサポートを追加\n- RSS管理ページを追加\n- 検索トップバーを追加\n\n### 変更\n\n- 様々なUI詳細を調整\n"
  },
  {
    "path": "docs/ja/changelog/3.2.md",
    "content": "# [3.2] - 2025-01\n\n## バックエンド\n\n### 機能\n\n- WebAuthn Passkeyパスワードレスログインサポートを追加\n  - Passkeyクレデンシャルの登録、認証、管理\n  - マルチデバイスクレデンシャルバックアップ検出（iCloud Keychainなど）\n  - クローン攻撃保護（sign_count検証）\n  - パスワードとPasskeyログインインターフェースを統合する認証戦略パターン\n  - 検出可能なクレデンシャル（レジデントキー）によるユーザー名なしログインサポート\n- シーズン/エピソードオフセットの自動検出を追加\n  - TMDBエピソード放送日を分析して「仮想シーズン」を検出（例：フリーレンS1が2つのパートに分割）\n  - 放送ギャップが6ヶ月を超える場合に異なるパートを自動識別\n  - エピソードオフセットを計算（例：RSSがS2E1を表示 → TMDB S1E29）\n  - バックグラウンドスキャンスレッドが既存の購読のオフセット問題を自動検出\n  - 新しいAPIエンドポイント：`POST /bangumi/detect-offset`、`PATCH /bangumi/dismiss-review/{id}`\n- 番組アーカイブ機能を追加\n  - 手動アーカイブ/アーカイブ解除サポート\n  - 完結シリーズの自動アーカイブ\n  - 新しいAPIエンドポイント：`PATCH /bangumi/archive/{id}`、`PATCH /bangumi/unarchive/{id}`、`GET /bangumi/refresh/metadata`\n- 検索プロバイダー設定APIを追加\n  - `GET /search/provider/config` - 検索プロバイダー設定を取得\n  - `PUT /search/provider/config` - 検索プロバイダー設定を更新\n- RSS接続ステータス追跡を追加\n  - 各リフレッシュ後に`connection_status`（healthy/error）、`last_checked_at`、`last_error`を記録\n- 初回実行セットアップウィザードを追加\n  - 7ステップのガイド付き設定：アカウント、ダウンローダー、RSSソース、メディアパス、通知\n  - ダウンローダー接続テスト、RSSソース検証\n  - オプションのステップはスキップして後で設定で構成可能\n  - センチネルファイルメカニズム（`config/.setup_complete`）で再トリガーを防止\n  - 認証不要のセットアップAPI（初回実行時のみ利用可能、完了後は403を返す）\n- Bangumi.tv放送スケジュール連携付きカレンダービューを追加\n- ダウンローダーAPIと管理インターフェースを追加\n- 完全な非同期移行\n  - データベースレイヤーの非同期サポート（aiosqlite）でPasskey操作のノンブロッキングI/O\n  - `UserDatabase`は後方互換性のためsync/asyncの両モードをサポート\n  - `Database`コンテキストマネージャーは`with`（sync）と`async with`（async）の両方をサポート\n  - RSSエンジン、ダウンローダー、チェッカー、パーサーを完全に非同期に変換\n  - ネットワークリクエストを`requests`から`httpx`（AsyncClient）に移行\n- バックエンドを`uv`パッケージマネージャーに移行（pyproject.toml + uv.lock）\n- サーバー起動がバックグラウンドタスクを使用してブロッキングを回避（#891、#929を修正）\n- データベースマイグレーションがNULL値をモデルデフォルトで自動埋め\n- データベースにオフセット検出用の`needs_review`と`needs_review_reason`フィールドを追加\n\n### パフォーマンス\n\n- 共有HTTPクライアント接続プール、TCP/SSL接続を再利用\n- RSSリフレッシュが並行（`asyncio.gather`）、複数ソースで約10倍高速化\n- トレントファイルダウンロードが並行、複数トレントで約5倍高速化\n- リネームモジュールのファイルリスト取得が並行、約20倍高速化\n- 通知送信が並行、2秒のハードコードされた遅延を削除\n- TMDBとMikanパーサー結果のキャッシュを追加して重複API呼び出しを回避\n- `Torrent.url`、`Torrent.rss_id`、`Bangumi.title_raw`、`Bangumi.deleted`、`RSSItem.url`にデータベースインデックスを追加\n- RSSの一括有効化/無効化がアイテムごとのコミットではなく単一トランザクションを使用\n- トレント名解析とフィルターマッチング用にプリコンパイルされた正規表現パターン\n- `SeasonCollector`がループ外で作成され、単一の認証を再利用\n- RSS解析の重複排除がO(n²)のリストルックアップからO(1)のセットルックアップに変更\n- `Episode`/`SeasonInfo`データクラスがメモリフットプリント削減のため`__slots__`を使用\n\n### 変更\n\n- WebAuthn依存関係をpy_webauthn 2.7.0にアップグレード\n- `_get_webauthn_from_request`がブラウザのOriginヘッダーを優先、クロスポート開発環境での検証問題を修正\n- `auth_user`と`update_user_info`を非同期関数に変換\n- `TitleParser.tmdb_parser`を非同期関数に変換\n- `RSSEngine`メソッドを完全に非同期化（`pull_rss`、`refresh_rss`、`download_bangumi`、`add_rss`）\n- `Checker.check_downloader`を非同期関数に変換\n- `ProgramStatus`がthreadingからasyncioに移行（Event、Lock）\n\n### バグ修正\n\n- 最大リトライ制限付きダウンローダー接続チェックを修正\n- トレント追加時の一時的なネットワークエラーをリトライロジックで修正\n- 検索と購読フローの複数の問題を修正\n- トレント取得の信頼性とエラー処理を改善\n- `aaguid`型エラーを修正（py_webauthn 2.7.0では`str`、`bytes`ではない）\n- 欠落していた`credential_backup_eligible`フィールドを修正（`credential_device_type`に置換）\n- `verify_authentication_response`が無効な`credential_id`パラメータを受け取りTypeErrorを引き起こす問題を修正\n- プログラム起動がサーバーをブロックする問題を修正（#891、#929、#886、#917、#946を修正）\n- 検索インターフェースのエクスポートがコンポーネントの期待と一致しない問題を修正\n- ポスターエンドポイントのパスチェックがすべてのリクエストを誤ってインターセプトする問題を修正（#933、#934を修正）\n- OpenAIパーサーのセキュリティ問題を修正\n- 非同期セッションを使用するデータベーステストと同期コードの不一致を修正\n- 3.1.xから3.2へのアップグレード時の設定フィールドの競合による設定喪失を修正（#956を修正）\n  - `program.sleep_time` / `program.times`が`rss_time` / `rename_time`に自動移行\n  - 非推奨の`rss_parser`フィールド（`type`、`custom_url`、`token`、`enable_tmdb`）を削除\n  - `ENV_TO_ATTR`環境変数マッピングが存在しないモデルフィールドを指す問題を修正\n  - `DEFAULT_SETTINGS`と現在の設定モデルの不整合を修正\n- バージョンアップグレードマイグレーションロジックエラーを修正（すべてのアップグレードが3.0→3.1マイグレーションを呼び出していた）\n  - ソースバージョンに基づくバージョン対応マイグレーションディスパッチを追加\n  - データベーススキーマ変更用の`from_31_to_32()`マイグレーション関数を追加\n\n## フロントエンド\n\n### 機能\n\n- 完全なUIデザインシステムの再設計\n  - 統一されたデザイントークン（色、フォント、間隔、シャドウ、アニメーション）\n  - ライト/ダークテーマ切り替えサポート\n  - 包括的なアクセシビリティサポート（ARIA、キーボードナビゲーション、フォーカス管理）\n  - モバイルデバイス用レスポンシブレイアウト\n- 初回実行セットアップウィザードページを追加\n  - マルチステップウィザードコンポーネント（プログレスバー + ステップナビゲーション）\n  - ルートガードの自動検出とセットアップページへのリダイレクト\n  - ダウンローダー/RSS/通知接続テストフィードバック\n  - 中国語と英語のi18nサポート\n- Passkey管理パネルを追加（設定ページ）\n  - WebAuthnブラウザサポート検出\n  - 自動デバイス名識別\n  - Passkeyリスト表示と削除\n- ログインページにPasskey指紋ログインボタンを追加（ユーザー名なしログインをサポート）\n- カレンダービューページを追加\n- ダウンローダー管理ページを追加\n- 番組カードホバーオーバーレイを追加（タイトルとタグを表示）\n- 外部URLとローカルパス処理を統一する`resolvePosterUrl`ユーティリティ関数を追加（#934を修正）\n- モーダルとフィルターシステムで検索パネルを再設計\n- モダンなグラスモーフィズムスタイルでログインパネルを再設計\n- ログビューにログレベルフィルターを追加\n- LLM設定パネルを再設計（#938を修正）\n- 設定、ダウンローダー、プレーヤー、ログページのスタイルを再設計\n- 検索プロバイダー設定パネルを追加\n  - UIで検索ソースの表示、追加、編集、削除\n  - デフォルトソース（mikan、nyaa、dmhy）は削除不可\n  - URLテンプレート検証で`%s`プレースホルダーを確認\n- iOSスタイルの通知バッジシステムを追加\n  - レビューが必要な購読に黄色バッジ + 紫の枠線\n  - 組み合わせ表示サポート（例：警告 + 複数ルールに`! | 2`）\n  - 注意が必要なカードに黄色のグローアニメーション\n- ワンクリック自動検出と却下機能付きの編集モーダル警告バナー\n- ルール選択モーダルで警告のあるルールをハイライト\n- カレンダーページの番組グループ化：複数ルールの同じアニメをマージ、クリックで特定のルールを選択\n- 番組リストページに折りたたみ可能な「アーカイブ済み」セクション\n- 番組リストページにスケルトンローディングアニメーション\n- ルールエディターのエピソードオフセットフィールドに「自動検出」ボタン\n- RSS管理ページの接続ステータスラベル：正常時は緑の「接続済み」、エラー時は赤で詳細ツールチップ\n- 新しいモバイルファーストレスポンシブデザイン\n  - 3層ブレークポイントシステム：モバイル（<640px）、タブレット（640-1023px）、デスクトップ（≥1024px）\n  - モバイル下部ナビゲーションバー（アイコンとテキストラベル付き）\n  - タブレットミニサイドバー（56pxアイコンナビゲーション）\n  - モバイルポップアップが自動的にボトムシートに切り替わる\n  - プルトゥリフレッシュサポート\n  - 水平スワイプコンテナサポート\n  - モバイルカードリストがデータテーブルを置き換え（RSSページ）\n  - CSSグリッドレスポンシブレイアウト（番組カードグリッド）\n  - モバイルでフォームラベルが垂直にスタック、入力がフル幅\n  - タッチターゲット最小44px、アクセシビリティ標準を満たす\n  - セーフエリアサポート（ノッチデバイス）\n  - `100dvh`動的ビューポート高さ（モバイルブラウザアドレスバー問題を修正）\n  - フルスクリーンデバイス用`viewport-fit=cover`\n\n### 新しいコンポーネント\n\n- `ab-bottom-sheet` — タッチ駆動のボトムシートコンポーネント（ドラッグで閉じる、最大高さ制限）\n- `ab-adaptive-modal` — アダプティブモーダル（モバイルでボトムシート / デスクトップで中央ダイアログ）\n- `ab-pull-refresh` — プルトゥリフレッシュラッパーコンポーネント\n- `ab-swipe-container` — 水平スワイプコンテナ（CSSスクロールスナップ）\n- `ab-data-list` — モバイルフレンドリーなカードリスト（NDataTableを置き換え）\n- `ab-mobile-nav` — 強化された下部ナビゲーションバー（アイコン + ラベル + アクティブインジケーター）\n- `useSafeArea` — セーフエリアコンポーザブル\n\n### パフォーマンス\n\n- ダウンローダーストアが大規模配列でのディープリアクティブプロキシを回避するため`ref`の代わりに`shallowRef`を使用\n- テーブル列定義を`computed`に移動して各レンダリングでの再構築を回避\n- RSSテーブル列をデータから分離、データ変更時に列設定を再構築しない\n- カレンダーページの重複した`getAll()`呼び出しを削除\n- `ab-select`の`watchEffect`を`watch`に変更、マウント時の無効なemitを排除\n- `useClipboard`をストアトップレベルに引き上げ、各`copy()`での新しいインスタンス作成を回避\n- `setInterval`を自動ライフサイクル管理のため`useIntervalFn`に置換\n\n### 変更\n\n- 検索ロジックをリファクタリング、rxjs依存関係を削除\n- 検索ストアエクスポートをコンポーネントの期待に一致するようリファクタリング\n- フロントエンド依存関係をアップグレード\n- ブレークポイントシステムを単一の1024pxから640px + 1024pxの2層に拡張\n- `useBreakpointQuery`に`isTablet`、`isMobileOrTablet`、`isTabletOrPC`を追加\n- `media-query.vue`に`#tablet`スロットを追加（`#mobile`にフォールバック）\n- UnoCSSに`sm: 640px`ブレークポイントを追加\n- `ab-input`のモバイルフル幅 + タッチターゲット増加スタイリング\n- レイアウトが`vh`単位の代わりに`dvh`単位を使用、safe-area-insetをサポート\n- カレンダーページの不明な列幅を修正\n- ダウンローダーページのアクションバーボタンサイズを統一\n\n## CI/インフラ\n\n- CIがPRオープン時にビルドテストを追加（devブランチからmainへのPRが自動的にビルドをトリガー）\n- CIが`actions/upload-artifact`と`actions/download-artifact`をv4にアップグレード\n- Dockerビルドから`linux/arm/v7`プラットフォームを削除（uvイメージがサポートしていない）\n- CLAUDE.md開発ガイドを追加\n"
  },
  {
    "path": "docs/ja/config/downloader.md",
    "content": "# ダウンローダー設定\n\n## WebUI設定\n\n![downloader](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **ダウンローダータイプ**はダウンローダーの種類です。現在はqBittorrentのみサポートされています。\n- **ホスト**はダウンローダーのアドレスです。[下記参照](#ダウンローダーアドレス)\n- **ダウンロードパス**はダウンローダーのマッピングされたダウンロードパスです。[下記参照](#ダウンロードパスの問題)\n- **SSL**はダウンローダー接続のSSLを有効にします。\n\n## よくある問題\n\n### ダウンローダーアドレス\n\n::: warning 注意\nダウンローダーアドレスに127.0.0.1またはlocalhostを使用しないでください。\n:::\n\n公式チュートリアルではABは**Bridge**モードのDockerで実行されるため、127.0.0.1またはlocalhostを使用するとダウンローダーではなくAB自体に解決されます。\n- qBittorrentもDockerで実行している場合は、Dockerの**ゲートウェイアドレス：172.17.0.1**の使用を推奨します。\n- qBittorrentがホストマシンで実行されている場合は、ホストマシンのIPアドレスを使用してください。\n\nABを**Host**モードで実行している場合は、Dockerゲートウェイアドレスの代わりに127.0.0.1を使用できます。\n\n::: warning 注意\nMacvlanはコンテナネットワークを分離します。追加のブリッジ設定なしでは、コンテナは他のコンテナやホスト自体にアクセスできません。\n:::\n\n### ダウンロードパスの問題\n\nABで設定されたパスは、対応するアニメファイルパスを生成するためにのみ使用されます。AB自体はそのパスのファイルを直接管理しません。\n\n**ダウンロードパスには何を入力すればよいですか？**\n\nこのパラメータは**ダウンローダー**の設定と一致させるだけです：\n- Docker：qBが`/downloads`を使用している場合は、`/downloads/Bangumi`に設定します。`Bangumi`は任意の名前に変更できます。\n- Linux/macOS：`/home/usr/downloads`または`/User/UserName/Downloads`の場合は、末尾に`/Bangumi`を追加するだけです。\n- Windows：`D:\\Media\\`を`D:\\Media\\Bangumi`に変更します\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`downloader`\n\n| パラメータ | 説明               | タイプ   | WebUIオプション        | デフォルト           |\n|-----------|-------------------|---------|----------------------|---------------------|\n| type      | ダウンローダータイプ | 文字列   | ダウンローダータイプ    | qbittorrent         |\n| host      | ダウンローダーアドレス | 文字列   | ダウンローダーアドレス  | 172.17.0.1:8080     |\n| username  | ダウンローダーユーザー名 | 文字列   | ダウンローダーユーザー名 | admin               |\n| password  | ダウンローダーパスワード | 文字列   | ダウンローダーパスワード | adminadmin          |\n| path      | ダウンロードパス     | 文字列   | ダウンロードパス        | /downloads/Bangumi  |\n| ssl       | SSL有効            | ブール値 | SSL有効               | false               |\n"
  },
  {
    "path": "docs/ja/config/experimental.md",
    "content": "# 実験的機能\n\n::: warning\n実験的機能はまだテスト中です。有効にすると予期しない問題が発生する可能性があり、将来のバージョンで削除される可能性があります。注意して使用してください！\n:::\n\n## OpenAI ChatGPT\n\nより良い構造化タイトル解析のためにOpenAI ChatGPTを使用します。例：\n\n```\ninput: \"【喵萌奶茶屋】★04月新番★[夏日重现/Summer Time Rendering][11][1080p][繁日双语][招募翻译]\"\noutput: '{\"group\": \"喵萌奶茶屋\", \"title_en\": \"Summer Time Rendering\", \"resolution\": \"1080p\", \"episode\": 11, \"season\": 1, \"title_zh\": \"夏日重现\", \"sub\": \"\", \"title_jp\": \"\", \"season_raw\": \"\", \"source\": \"\"}'\n```\n\n![experimental OpenAI](/image/config/experimental-openai.png){width=500}{class=ab-shadow-card}\n\n- **OpenAI有効**はOpenAIを有効にし、タイトル解析にChatGPTを使用します。\n- **OpenAI APIタイプ**はデフォルトでOpenAIです。\n- **OpenAI APIキー**はOpenAIアカウントのAPIキーです。\n- **OpenAI APIベースURL**はOpenAIエンドポイントです。デフォルトでは公式OpenAI URLですが、互換性のあるサードパーティエンドポイントに変更できます。\n- **OpenAIモデル**はChatGPTモデルパラメータです。現在`gpt-3.5-turbo`を提供しており、適切なプロンプトで手頃な価格で優れた結果を生成します。\n\n## Microsoft Azure OpenAI\n\n\n![experimental Microsoft Azure OpenAI](/image/config/experimental-azure-openai.png){width=500}{class=ab-shadow-card}\n\n標準のOpenAIに加えて、[バージョン3.1.8](https://github.com/EstrellaXD/Auto_Bangumi/releases/tag/3.1.8)でMicrosoft Azure OpenAIサポートが追加されました。使用方法は標準のOpenAIと同様で、一部の共有パラメータがありますが、以下の点に注意してください：\n\n- **OpenAI有効**はOpenAIを有効にし、タイトル解析にChatGPTを使用します。\n- **OpenAI APIタイプ** — Azure固有のオプションを表示するには`azure`を選択します。\n- **OpenAI APIキー**はMicrosoft Azure OpenAI APIキーです。\n- **OpenAI APIベースURL**はMicrosoft Azure OpenAIエントリーポイントに対応します。**手動で入力する必要があります**。\n- **Azure OpenAIバージョン**はAPIバージョンです。デフォルトは`2023-05-15`です。[サポートされているバージョン](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/reference#completions)を参照してください。\n- **Azure OpenAIデプロイメントID**はデプロイメントIDで、通常はモデル名と同じです。Azure OpenAIは`_-`以外の記号をサポートしていないため、`gpt-3.5-turbo`はAzureでは`gpt-35-turbo`になることに注意してください。**手動で入力する必要があります**。\n\n参考ドキュメント：\n\n- [クイックスタート：Azure OpenAI ServiceでGPT-35-TurboとGPT-4の使用を開始する](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line&pivots=programming-language-python)\n- [GPT-35-TurboとGPT-4モデルの操作方法を学ぶ](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions)\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`experimental_openai`\n\n| パラメータ     | 説明                       | タイプ    | WebUIオプション              | デフォルト                    |\n|---------------|---------------------------|---------|----------------------------|------------------------------|\n| enable        | OpenAIパーサー有効         | ブール値 | OpenAI有効                  | false                        |\n| api_type      | OpenAI APIタイプ           | 文字列   | APIタイプ (`openai`/`azure`) | openai                       |\n| api_key       | OpenAI APIキー             | 文字列   | OpenAI APIキー              |                              |\n| api_base      | APIベースURL（Azureエントリーポイント） | 文字列 | OpenAI APIベースURL    | https://api.openai.com/v1    |\n| model         | OpenAIモデル               | 文字列   | OpenAIモデル                | gpt-3.5-turbo                |\n| api_version   | Azure OpenAI APIバージョン  | 文字列   | Azure APIバージョン          | 2023-05-15                   |\n| deployment_id | AzureデプロイメントID       | 文字列   | AzureデプロイメントID        |                              |\n"
  },
  {
    "path": "docs/ja/config/manager.md",
    "content": "# 番組マネージャー設定\n\n## WebUI設定\n\n![proxy](/image/config/manager.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **有効**は番組マネージャーを有効にします。無効にすると、以下の設定は効果がありません。\n- **リネーム方法**はリネーム方法です。現在サポートされているもの：\n  - `pn` — `Torrentタイトル S0XE0X.mp4` 形式\n  - `advance` — `公式タイトル S0XE0X.mp4` 形式\n  - `none` — リネームなし\n- **エピソード補完**は現在のシーズンのエピソード補完を有効にします。有効にすると、不足しているエピソードがダウンロードされます。\n- **グループタグ追加**はダウンロードルールに字幕グループタグを追加します。\n- **不良トレント削除**はエラーのあるトレントを削除します。\n- [ファイルパスについて][1]\n- [リネームについて][2]\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`bangumi_manager`\n\n| パラメータ          | 説明                    | タイプ    | WebUIオプション     | デフォルト |\n|--------------------|------------------------|---------|-------------------|-----------|\n| enable             | 番組マネージャー有効    | ブール値  | マネージャー有効    | true      |\n| eps_complete       | エピソード補完有効      | ブール値  | エピソード補完     | false     |\n| rename_method      | リネーム方法           | 文字列    | リネーム方法       | pn        |\n| group_tag          | 字幕グループタグ追加    | ブール値  | グループタグ       | false     |\n| remove_bad_torrent | 不良トレント削除        | ブール値  | 不良トレント削除   | false     |\n\n\n[1]: https://www.autobangumi.org/faq/#download-path\n[2]: https://www.autobangumi.org/faq/#file-renaming\n"
  },
  {
    "path": "docs/ja/config/notifier.md",
    "content": "# 通知設定\n\n## WebUI設定\n\n![notification](/image/config/notifier.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **有効**は通知を有効にします。無効にすると、以下の設定は効果がありません。\n- **タイプ**は通知タイプです。現在サポートされているもの：\n  - Telegram\n  - Wecom\n  - Bark\n  - ServerChan\n- **Chat ID**は`telegram`通知を使用する場合にのみ入力が必要です。[Telegram Bot Chat IDの取得方法][1]\n- **Wecom**：Chat IDフィールドにカスタムプッシュURLを入力し、サーバー側で[リッチテキストメッセージ][2]タイプを追加します。[Wecom設定ガイド][3]\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`notification`\n\n| パラメータ | 説明             | タイプ    | WebUIオプション    | デフォルト |\n|-----------|-----------------|---------|------------------|----------|\n| enable    | 通知有効         | ブール値 | 通知              | false    |\n| type      | 通知タイプ       | 文字列   | 通知タイプ        | telegram |\n| token     | 通知トークン     | 文字列   | 通知トークン       |          |\n| chat_id   | 通知Chat ID     | 文字列   | 通知Chat ID       |          |\n\n\n[1]: https://core.telegram.org/bots#6-botfather\n[2]: https://github.com/umbors/wecomchan-alifun\n[3]: https://github.com/easychen/wecomchan\n"
  },
  {
    "path": "docs/ja/config/parser.md",
    "content": "# パーサー設定\n\nABのパーサーは集約されたRSSリンクを解析するために使用されます。RSSフィードに新しいエントリが表示されると、ABはタイトルを解析して自動ダウンロードルールを生成します。\n\n::: tip\nv3.1以降、パーサー設定は個別のRSS設定に移動しました。**パーサータイプ**を設定するには、[RSSのパーサー設定][add_rss]を参照してください。\n:::\n\n## WebUIでのパーサー設定\n\n![parser](/image/config/parser.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- **有効**：RSSパーサーを有効にするかどうか。\n- **言語**はRSSパーサーの言語です。現在`zh`、`jp`、`en`をサポートしています。\n- **除外**はグローバルRSSパーサーフィルターです。文字列または正規表現を入力でき、ABはRSS解析時に一致するエントリをフィルタリングします。\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`rss_parser`\n\n| パラメータ | 説明               | タイプ    | WebUIオプション      | デフォルト      |\n|-----------|-------------------|---------|---------------------|----------------|\n| enable    | RSSパーサー有効    | ブール値 | RSSパーサー有効      | true           |\n| filter    | RSSパーサーフィルター | 配列    | フィルター           | [720,\\d+-\\d+] |\n| language  | RSSパーサー言語    | 文字列   | RSSパーサー言語      | zh             |\n\n\n[rss_token]: rss\n[add_rss]: /ja/feature/rss#パーサー設定\n[reproxy]: /ja/config/proxy#リバースプロキシ\n"
  },
  {
    "path": "docs/ja/config/program.md",
    "content": "# プログラム設定\n\n## WebUI設定\n\n![program](/image/config/program.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\n- インターバル時間パラメータは秒単位です。分単位で設定する場合は秒に変換してください。\n- RSSはRSSチェック間隔で、自動ダウンロードルールの生成頻度に影響します。\n- リネームはリネームチェック間隔です。リネームのチェック頻度を変更する必要がある場合に修正してください。\n- WebUIポートはポート番号です。Dockerを使用している場合、変更後にDockerでポートを再マッピングする必要があることに注意してください。\n\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`program`\n\n| パラメータ   | 説明               | タイプ          | WebUIオプション      | デフォルト |\n|-------------|-------------------|-----------------|---------------------|-----------|\n| rss_time    | RSSチェック間隔    | 整数（秒）       | RSSチェック間隔      | 7200      |\n| rename_time | リネームチェック間隔 | 整数（秒）       | リネームチェック間隔  | 60        |\n| webui_port  | WebUIポート        | 整数            | WebUIポート          | 7892      |\n"
  },
  {
    "path": "docs/ja/config/proxy.md",
    "content": "# プロキシとリバースプロキシ\n\n## プロキシ\n\n![proxy](/image/config/proxy.png){width=500}{class=ab-shadow-card}\n\n<br/>\n\nABはネットワーク問題を解決するためにHTTPおよびSOCKS5プロキシをサポートしています。\n\n- **有効**：プロキシを有効にするかどうか。\n- **タイプ**はプロキシタイプです。\n- **ホスト**はプロキシアドレスです。\n- **ポート**はプロキシポートです。\n\n::: tip\n**SOCKS5**モードでは、ユーザー名とパスワードが必要です。\n:::\n\n## `config.json`設定オプション\n\n設定ファイルの対応するオプションは以下のとおりです：\n\n設定セクション：`proxy`\n\n| パラメータ | 説明             | タイプ    | WebUIオプション   | デフォルト |\n|-----------|-----------------|---------|-----------------|-----------|\n| enable    | プロキシ有効     | ブール値 | プロキシ         | false     |\n| type      | プロキシタイプ   | 文字列   | プロキシタイプ   | http      |\n| host      | プロキシアドレス | 文字列   | プロキシアドレス  |           |\n| port      | プロキシポート   | 整数     | プロキシポート    |           |\n| username  | プロキシユーザー名 | 文字列   | プロキシユーザー名 |          |\n| password  | プロキシパスワード | 文字列   | プロキシパスワード |          |\n\n## リバースプロキシ\n\n- Mikan Projectの代替ドメイン`mikanime.tv`を使用して、RSS購読URLの`mikanani.me`を置き換えます。\n- Cloudflare Workerをリバースプロキシとして使用し、RSSフィード内のすべての`mikanani.me`ドメインを置き換えます。\n\n## Cloudflare Workers\n\n他のサービスのブロックをバイパスするために使用されるアプローチに基づいて、Cloudflare Workersを使用してリバースプロキシを設定できます。ドメインの登録とCloudflareへのバインド方法は、このガイドの範囲外です。Workersに以下のコードを追加して、独自のドメインを使用してMikan Projectにアクセスし、RSSリンクからトレントをダウンロードします：\n\n```js\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // コンテンツタイプがRSSの場合のみ置換を実行\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // CORSヘッダーを追加\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // CORSヘッダーを追加\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n"
  },
  {
    "path": "docs/ja/config/rss.md",
    "content": "# RSS購読設定\n\nAutoBangumiは集約されたアニメRSSフィードを自動的に解析し、字幕グループとアニメ名に基づいてダウンロードルールを生成して、完全自動のアニメ追跡を可能にします。\n以下では、[Mikan Project][mikan-site]を例として、RSS購読URLを取得する方法を説明します。\n\nMikan Projectのメインサイトは一部の地域でブロックされている場合があります。プロキシなしでアクセスできない場合は、以下の代替ドメインを使用してください：\n\n[Mikan Project (代替)][mikan-cn-site]\n\n## 購読URLの取得\n\nこのプロジェクトはMikan Projectが提供するRSS URLの解析に基づいています。自動アニメ追跡を有効にするには、Mikan ProjectのRSS URLを登録して取得する必要があります：\n\n![image](/image/rss/rss-token.png){data-zoomable}\n\nRSS URLは以下のようになります：\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# または\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n## Mikan Project購読のヒント\n\nAutoBangumiは受信したすべてのRSSエントリを解析するため、購読時に以下の点に注意してください：\n\n![image](/image/rss/advanced-subscription.png){data-zoomable}\n\n- プロファイル設定で詳細設定を有効にしてください。\n- アニメごとに1つの字幕グループのみを購読してください。Mikan Projectでアニメのポスターをクリックしてサブメニューを開き、単一の字幕グループを選択します。\n- 字幕グループが簡体字中国語と繁体字中国語の両方の字幕を提供している場合、Mikan Projectは通常選択方法を提供します。1つの字幕タイプを選択してください。\n- 字幕タイプの選択がない場合は、AutoBangumiで`filter`を設定してフィルタリングするか、ルール生成後にqBittorrentで手動でフィルタリングできます。\n- OVAと映画の購読は現在解析がサポートされていません。\n\n\n[mikan-site]: https://mikanani.me/\n[mikan-cn-site]: https://mikanime.tv/\n"
  },
  {
    "path": "docs/ja/deploy/docker-cli.md",
    "content": "# Docker CLIでデプロイ\n\n## 新バージョンに関する注意\n\nAutoBangumi 2.6以降、WebUIで直接すべてを設定できます。まずコンテナを起動してから、WebUIで設定できます。以前のバージョンの環境変数設定は自動的に移行されます。環境変数は引き続き機能しますが、初回起動時にのみ有効です。\n\n## データと設定ディレクトリの作成\n\n更新後もABのデータと設定が保持されるように、Dockerボリュームまたはバインドマウントの使用を推奨します。\n\n```shell\n# バインドマウントを使用\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\nバインドマウントまたはDockerボリュームのいずれかを選択してください：\n```shell\n# Dockerボリュームを使用\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## Docker CLIでAutoBangumiをデプロイ\n\n以下のコマンドをコピーして実行してください。\n\n作業ディレクトリがAutoBangumiであることを確認してください。\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Shanghai \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\nDockerボリュームを使用する場合は、バインドパスを適宜置き換えてください：\n```shell\n  -v AutoBangumi_config:/app/config \\\n  -v AutoBangumi_data:/app/data \\\n```\n\nAB WebUIは自動的に起動しますが、メインプログラムは一時停止状態です。`http://abhost:7892`にアクセスして設定してください。\n\nABは自動的に環境変数を`config.json`に書き込み、実行を開始します。\n\n高度なデプロイには_[Portainer](https://www.portainer.io)_または同様のDocker管理UIの使用を推奨します。\n"
  },
  {
    "path": "docs/ja/deploy/docker-compose.md",
    "content": "# Docker Composeでデプロイ\n\n`docker-compose.yml`ファイルを使用した**AutoBangumi**のワンクリックデプロイ方法です。\n\n## Docker Composeのインストール\n\nDocker Composeは通常Dockerにバンドルされています。以下で確認してください：\n\n```bash\ndocker compose -v\n```\n\nインストールされていない場合は、以下でインストールしてください：\n\n```bash\n$ sudo apt-get update\n$ sudo apt-get install docker-compose-plugin\n```\n\n## **AutoBangumi**のデプロイ\n\n### AutoBangumiとデータディレクトリの作成\n\n```bash\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\n### オプション1：カスタムDocker Compose設定\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\n上記の内容を`docker-compose.yml`ファイルにコピーしてください。\n\n### オプション2：Docker Compose設定ファイルのダウンロード\n\n`docker-compose.yml`ファイルを手動で作成したくない場合、プロジェクトでは事前に作成された設定を提供しています：\n\n- **AutoBangumi**のみをインストール：\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/AutoBangumi/docker-compose.yml\n  ```\n- **qBittorrent**と**AutoBangumi**をインストール：\n  ```bash\n  wget https://raw.githubusercontent.com/EstrellaXD/Auto_Bangumi/main/docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml\n  ```\n\nインストール方法を選択し、コマンドを実行して`docker-compose.yml`ファイルをダウンロードしてください。必要に応じてテキストエディタでパラメータをカスタマイズできます。\n\n### 環境変数の定義\n\nダウンロードしたAB+QB Docker Composeファイルを使用している場合は、以下の環境変数を定義する必要があります：\n\n```shell\nexport \\\nQB_PORT=<YOUR_PORT>\n```\n\n- `QB_PORT`：既存のqBittorrentポートまたは希望するカスタムポートを入力します。例：`8080`\n\n### Docker Composeの起動\n\n```bash\ndocker compose up -d\n```\n"
  },
  {
    "path": "docs/ja/deploy/dsm.md",
    "content": "# Synology NAS（DSM 7.2）デプロイ（QNAPも同様）\n\nDSM 7.2はDocker Composeをサポートしているため、ワンクリックデプロイにDocker Composeの使用を推奨します。\n\n## 設定とデータディレクトリの作成\n\n`/volume1/docker/`の下に`AutoBangumi`フォルダを作成し、その中に`config`と`data`サブフォルダを作成します。\n\n## Container Manager（Docker）パッケージのインストール\n\nパッケージセンターを開き、Container Manager（Docker）パッケージをインストールします。\n\n![install-docker](/image/dsm/install-docker.png){data-zoomable}\n\n## Docker Compose経由でABをインストール\n\n**プロジェクト**をクリックし、**作成**をクリックして、**Docker Compose**を選択します。\n\n![new-compose](/image/dsm/new-compose.png){data-zoomable}\n\n以下の内容をコピーして**Docker Compose**に貼り付けます：\n```yaml\nversion: \"3.4\"\n\nservices:\n  ab:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: \"auto_bangumi\"\n    restart: unless-stopped\n    ports:\n      - \"7892:7892\"\n    volumes:\n      - \"./config:/app/config\"\n      - \"./data:/app/data\"\n    network_mode: bridge\n    environment:\n      - TZ=Asia/Shanghai\n      - AB_METHOD=Advance\n      - PGID=1000\n      - PUID=1000\n      - UMASK=022\n```\n\n**次へ**をクリックし、**完了**をクリックします。\n\n![create](/image/dsm/create.png){data-zoomable}\n\n作成後、`http://<NAS IP>:7892`にアクセスしてABに入り、設定を行います。\n\n## Docker Compose経由でABとqBittorrentをインストール\n\nプロキシとIPv6の両方がある場合、Synology NASのDockerでIPv6を設定するのは複雑です。複雑さを軽減するために、ABとqBittorrentの両方をホストネットワークにインストールすることを推奨します。\n\n以下の設定は、DockerにデプロイされたローカルIPの指定ポートでアクセス可能なClashプロキシがあることを前提としています。\n\n前のセクションに従って、以下の内容を調整して**Docker Compose**に貼り付けます：\n\n```yaml\n  qbittorrent:\n    container_name: qbittorrent\n    image: linuxserver/qbittorrent\n    hostname: qbittorrent\n    environment:\n      - PGID=1000  # 必要に応じて変更\n      - PUID=1000  # 必要に応じて変更\n      - WEBUI_PORT=8989\n      - TZ=Asia/Shanghai\n    volumes:\n      - ./qb_config:/config\n      - your_anime_path:/downloads # アニメ保存ディレクトリに変更してください。ABでダウンロードパスを/downloadsに設定\n    networks:\n      - host\n    restart: unless-stopped\n\n  auto_bangumi:\n    container_name: AutoBangumi\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=1000  # 必要に応じて変更\n      - PUID=1000  # 必要に応じて変更\n      - UMASK=022\n      - AB_DOWNLOADER_HOST=127.0.0.1:8989  # 必要に応じてポートを変更\n    volumes:\n      - /volume1/docker/ab/config:/app/config\n      - /volume1/docker/ab/data:/app/data\n    network_mode: host\n    environment:\n      - AB_METHOD=Advance\n    dns:\n      - 8.8.8.8\n    restart: unless-stopped\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    depends_on:\n      - qbittorrent\n\n```\n\n## 追加の注意事項\n\nPGIDとPUIDの値はシステムに応じて決定する必要があります。新しいSynology NASデバイスでは、通常`PUID=1026, PGID=100`です。qBittorrentのポートを変更する場合は、すべての場所で更新してください。\n\nプロキシ設定については、[プロキシ設定](/ja/config/proxy)を参照してください。\n\n低性能マシンでは、デフォルト設定がCPUを大量に使用し、ABがqBに接続できなくなり、qB WebUIにアクセスできなくなる可能性があります。\n\n220+などのデバイスでは、CPU使用率を下げるための推奨qBittorrent設定：\n\n- 設定 -> 接続 -> 接続制限\n  - グローバル最大接続数：300\n  - Torrentあたりの最大接続数：60\n  - グローバルアップロードスロット制限：15\n  - Torrentあたりのアップロードスロット：4\n- BitTorrent\n  - 最大アクティブチェックTorrent数：1\n  - Torrentキューイング\n    - 最大アクティブダウンロード数：3\n    - 最大アクティブアップロード数：5\n    - 最大アクティブTorrent数：10\n- RSS\n  - RSSリーダー\n    - フィードあたりの最大記事数：50\n"
  },
  {
    "path": "docs/ja/deploy/local.md",
    "content": "# ローカルデプロイ\n\n::: warning\nローカルデプロイは予期しない問題を引き起こす可能性があります。代わりにDockerの使用を強く推奨します。\n\nこのドキュメントには更新の遅れがある可能性があります。質問がある場合は、[Issues](https://github.com/EstrellaXD/Auto_Bangumi/issues)で提起してください。\n:::\n\n## 最新リリースのダウンロード\n\n```bash\nVERSION=$(curl -s \"https://api.github.com/repos/EstrellaXD/Auto_Bangumi/releases/latest\" | grep '\"tag_name\":' | sed -E 's/.*\"([^\"]+)\".*/\\1/')\ncurl -L -O \"https://github.com/EstrellaXD/Auto_Bangumi/releases/download/$VERSION/app-v$VERSION.zip\"\n```\n\n## アーカイブの展開\n\nUnix/WSLシステムでは、以下のコマンドを使用します。Windowsでは手動で展開してください。\n\n```bash\nunzip app-v$VERSION.zip -d AutoBangumi\ncd AutoBangumi\n```\n\n\n## 仮想環境の作成と依存関係のインストール\n\nローカルにPython 3.10以上とpipがインストールされていることを確認してください。\n\n```bash\ncd src\npython3 -m venv env\npython3 pip install -r requirements.txt\n```\n\n## 設定とデータディレクトリの作成\n\n```bash\nmkdir config\nmkdir data\n```\n\n## AutoBangumiの実行\n\n```bash\npython3 main.py\n```\n\n\n## Windows起動時の自動起動\n\n`nssm`を使用して起動時の自動起動を設定できます。`nssm`を使用した例：\n\n```powershell\nnssm install AutoBangumi (Get-Command python).Source\nnssm set AutoBangumi AppParameters (Get-Item .\\main.py).FullName\nnssm set AutoBangumi AppDirectory (Get-Item ..).FullName\nnssm set AutoBangumi Start SERVICE_DELAYED_AUTO_START\n```\n"
  },
  {
    "path": "docs/ja/deploy/quick-start.md",
    "content": "# クイックスタート\n\nAutoBangumiはDockerでのデプロイを推奨しています。\nデプロイ前に、[Docker Engine][docker-engine]または[Docker Desktop][docker-desktop]がインストールされていることを確認してください。\n\n## データと設定ディレクトリの作成\n\nABのデータと設定を更新時に永続化するために、バインドマウントまたはDockerボリュームの使用を推奨します。\n\n```shell\n# バインドマウントを使用\nmkdir -p ${HOME}/AutoBangumi/{config,data}\ncd ${HOME}/AutoBangumi\n```\n\nバインドマウントまたはDockerボリュームのいずれかを選択：\n\n```shell\n# Dockerボリュームを使用\ndocker volume create AutoBangumi_config\ndocker volume create AutoBangumi_data\n```\n\n## DockerでAutoBangumiをデプロイ\n\nこれらのコマンドを実行する際は、AutoBangumiディレクトリにいることを確認してください。\n\n### オプション1：Docker CLIでデプロイ\n\n以下のコマンドをコピーして実行：\n\n```shell\ndocker run -d \\\n  --name=AutoBangumi \\\n  -v ${HOME}/AutoBangumi/config:/app/config \\\n  -v ${HOME}/AutoBangumi/data:/app/data \\\n  -p 7892:7892 \\\n  -e TZ=Asia/Tokyo \\\n  -e PUID=$(id -u) \\\n  -e PGID=$(id -g) \\\n  -e UMASK=022 \\\n  --network=bridge \\\n  --dns=8.8.8.8 \\\n  --restart unless-stopped \\\n  ghcr.io/estrellaxd/auto_bangumi:latest\n```\n\n### オプション2：Docker Composeでデプロイ\n\n以下の内容を`docker-compose.yml`ファイルにコピー：\n\n```yaml\nversion: \"3.8\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    ports:\n      - \"7892:7892\"\n    network_mode: bridge\n    restart: unless-stopped\n    dns:\n      - 8.8.8.8\n    environment:\n      - TZ=Asia/Tokyo\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022\n```\n\n以下のコマンドでコンテナを起動：\n\n```shell\ndocker compose up -d\n```\n\n## qBittorrentのインストール\n\nqBittorrentをまだインストールしていない場合は、最初にインストールしてください：\n\n- [DockerでqBittorrentをインストール][qbittorrent-docker]\n- [Windows/macOSでqBittorrentをインストール][qbittorrent-desktop]\n- [Linuxでqbittorrent-noxをインストール][qbittorrent-nox]\n\n## 集約RSSリンクの取得（Mikan Projectを例として）\n\n[Mikan Project][mikan-project]にアクセスし、アカウントを登録してログインし、右下の**RSS**ボタンをクリックしてリンクをコピーします。\n\n![mikan-rss](/image/rss/rss-token.png){data-zoomable}\n\nRSS URLは以下のようになります：\n\n```txt\nhttps://mikanani.me/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n# または\nhttps://mikanime.tv/RSS/MyBangumi?token=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n```\n\n詳細な手順については、[Mikan RSS設定][config-rss]を参照してください。\n\n\n## AutoBangumiの設定\n\nABをインストール後、WebUIは自動的に起動しますが、メインプログラムは一時停止状態です。`http://abhost:7892`にアクセスして設定できます。\n\n1. Webページを開きます。デフォルトのユーザー名は`admin`、デフォルトのパスワードは`adminadmin`です。初回ログイン後すぐに変更してください。\n2. ダウンローダーのアドレス、ポート、ユーザー名、パスワードを入力します。\n\n![ab-webui](/image/config/downloader.png){width=500}{class=ab-shadow-card}\n\n3. **適用**をクリックして設定を保存します。ABが再起動し、右上のドットが緑色になると、ABが正常に動作していることを示します。\n\n4. 右上の**+**ボタンをクリックし、**集約RSS**にチェックを入れ、パーサータイプを選択し、Mikan RSS URLを入力します。\n\n![ab-rss](/image/config/add-rss.png){width=500}{class=ab-shadow-card}\n\nABが集約RSSを解析するのを待ちます。解析が完了すると、自動的にアニメを追加し、ダウンロードを管理します。\n\n\n\n[docker-engine]: https://docs.docker.com/engine/install/\n[docker-desktop]: https://www.docker.com/products/docker-desktop\n[config-rss]: ../config/rss\n[mikan-project]: https://mikanani.me/\n[qbittorrent-docker]: https://hub.docker.com/r/superng6/qbittorrent\n[qbittorrent-desktop]: https://www.qbittorrent.org/download\n[qbittorrent-nox]: https://www.qbittorrent.org/download-nox\n"
  },
  {
    "path": "docs/ja/dev/database.md",
    "content": "# データベース開発者ガイド\n\nこのガイドでは、AutoBangumiのデータベースアーキテクチャ、モデル、および操作について説明します。\n\n## 概要\n\nAutoBangumiはデータベースとして**SQLite**を使用し、ORMには**SQLModel**（Pydantic + SQLAlchemyハイブリッド）を使用しています。データベースファイルは`data/data.db`にあります。\n\n### アーキテクチャ\n\n```\nmodule/database/\n├── engine.py       # SQLAlchemyエンジン設定\n├── combine.py      # Databaseクラス、マイグレーション、セッション管理\n├── bangumi.py      # Bangumi（アニメ購読）操作\n├── rss.py          # RSSフィード操作\n├── torrent.py      # トレント追跡操作\n└── user.py         # ユーザー認証操作\n```\n\n## コアコンポーネント\n\n### Databaseクラス\n\n`combine.py`の`Database`クラスがメインエントリーポイントです。SQLModelの`Session`を継承し、すべてのサブデータベースへのアクセスを提供します：\n\n```python\nfrom module.database import Database\n\nwith Database() as db:\n    # サブデータベースへのアクセス\n    bangumis = db.bangumi.search_all()\n    rss_items = db.rss.search_active()\n    torrents = db.torrent.search_all()\n```\n\n### サブデータベースクラス\n\n| クラス | モデル | 目的 |\n|-------|-------|------|\n| `BangumiDatabase` | `Bangumi` | アニメ購読ルール |\n| `RSSDatabase` | `RSSItem` | RSSフィードソース |\n| `TorrentDatabase` | `Torrent` | ダウンロードしたトレントの追跡 |\n| `UserDatabase` | `User` | 認証 |\n\n## モデル\n\n### Bangumiモデル\n\nアニメ購読のコアモデル：\n\n```python\nclass Bangumi(SQLModel, table=True):\n    id: int                          # 主キー\n    official_title: str              # 表示名（例：\"無職転生\"）\n    title_raw: str                   # トレントマッチング用の生タイトル（インデックス付き）\n    season: int = 1                  # シーズン番号\n    episode_offset: int = 0          # エピソード番号調整\n    season_offset: int = 0           # シーズン番号調整\n    rss_link: str                    # カンマ区切りRSSフィードURL\n    filter: str                      # 除外フィルター（例：\"720,\\\\d+-\\\\d+\"）\n    poster_link: str                 # TMDBポスターURL\n    save_path: str                   # ダウンロード先パス\n    rule_name: str                   # qBittorrent RSSルール名\n    added: bool = False              # ルールがダウンローダーに追加されたかどうか\n    deleted: bool = False            # ソフト削除フラグ（インデックス付き）\n    archived: bool = False           # 完結シリーズ用（インデックス付き）\n    needs_review: bool = False       # オフセット不一致検出\n    needs_review_reason: str         # レビューの理由\n    suggested_season_offset: int     # 提案されたシーズンオフセット\n    suggested_episode_offset: int    # 提案されたエピソードオフセット\n    air_weekday: int                 # 放送日（0=日曜日、6=土曜日）\n```\n\n### RSSItemモデル\n\nRSSフィード購読：\n\n```python\nclass RSSItem(SQLModel, table=True):\n    id: int                          # 主キー\n    name: str                        # 表示名\n    url: str                         # フィードURL（ユニーク、インデックス付き）\n    aggregate: bool = True           # トレントを解析するかどうか\n    parser: str = \"mikan\"            # パーサータイプ：mikan、dmhy、nyaa\n    enabled: bool = True             # アクティブフラグ\n    connection_status: str           # \"healthy\"または\"error\"\n    last_checked_at: str             # ISOタイムスタンプ\n    last_error: str                  # 最後のエラーメッセージ\n```\n\n### Torrentモデル\n\nダウンロードしたトレントを追跡：\n\n```python\nclass Torrent(SQLModel, table=True):\n    id: int                          # 主キー\n    name: str                        # トレント名（インデックス付き）\n    url: str                         # トレント/マグネットURL（ユニーク、インデックス付き）\n    rss_id: int                      # ソースRSSフィードID\n    bangumi_id: int                  # リンクされたBangumi ID（nullable）\n    qb_hash: str                     # qBittorrentインフォハッシュ（インデックス付き）\n    downloaded: bool = False         # ダウンロード完了\n```\n\n## 一般的な操作\n\n### BangumiDatabase\n\n```python\nwith Database() as db:\n    # 作成\n    db.bangumi.add(bangumi)              # 単一挿入\n    db.bangumi.add_all(bangumi_list)     # バッチ挿入（重複排除）\n\n    # 読み取り\n    db.bangumi.search_all()              # 全レコード（キャッシュ、5分TTL）\n    db.bangumi.search_id(123)            # IDで検索\n    db.bangumi.match_torrent(\"torrent name\")  # title_rawマッチで検索\n    db.bangumi.not_complete()            # 未完了シリーズ\n    db.bangumi.get_needs_review()        # レビューフラグ付き\n\n    # 更新\n    db.bangumi.update(bangumi)           # 単一レコード更新\n    db.bangumi.update_all(bangumi_list)  # バッチ更新\n\n    # 削除\n    db.bangumi.delete_one(123)           # ハード削除\n    db.bangumi.disable_rule(123)         # ソフト削除（deleted=True）\n```\n\n### RSSDatabase\n\n```python\nwith Database() as db:\n    # 作成\n    db.rss.add(rss_item)                 # 単一挿入\n    db.rss.add_all(rss_items)            # バッチ挿入（重複排除）\n\n    # 読み取り\n    db.rss.search_all()                  # 全フィード\n    db.rss.search_active()               # 有効なフィードのみ\n    db.rss.search_aggregate()            # 有効 + aggregate=True\n\n    # 更新\n    db.rss.update(id, rss_update)        # 部分更新\n    db.rss.enable(id)                    # フィード有効化\n    db.rss.disable(id)                   # フィード無効化\n    db.rss.enable_batch([1, 2, 3])       # バッチ有効化\n    db.rss.disable_batch([1, 2, 3])      # バッチ無効化\n```\n\n### TorrentDatabase\n\n```python\nwith Database() as db:\n    # 作成\n    db.torrent.add(torrent)              # 単一挿入\n    db.torrent.add_all(torrents)         # バッチ挿入\n\n    # 読み取り\n    db.torrent.search_all()              # 全トレント\n    db.torrent.search_by_qb_hash(hash)   # qBittorrentハッシュで検索\n    db.torrent.search_by_url(url)        # URLで検索\n    db.torrent.check_new(torrents)       # 既存のものをフィルター\n\n    # 更新\n    db.torrent.update_qb_hash(id, hash)  # qb_hashを設定\n```\n\n## キャッシング\n\n### Bangumiキャッシュ\n\n`search_all()`の結果はモジュールレベルで5分のTTLでキャッシュされます：\n\n```python\n# bangumi.pyのモジュールレベルキャッシュ\n_bangumi_cache: list[Bangumi] | None = None\n_bangumi_cache_time: float = 0\n_BANGUMI_CACHE_TTL: float = 300.0  # 5分\n\n# キャッシュ無効化\ndef _invalidate_bangumi_cache():\n    global _bangumi_cache, _bangumi_cache_time\n    _bangumi_cache = None\n    _bangumi_cache_time = 0\n```\n\n**重要：** キャッシュは以下で自動的に無効化されます：\n- `add()`、`add_all()`\n- `update()`、`update_all()`\n- `delete_one()`、`delete_all()`\n- `archive_one()`、`unarchive_one()`\n- 任意のRSSリンク更新操作\n\n### セッションExpunge\n\nキャッシュされたオブジェクトは`DetachedInstanceError`を防ぐためにセッションから**expunge**されます：\n\n```python\nfor b in bangumis:\n    self.session.expunge(b)  # セッションから切り離す\n```\n\n## マイグレーションシステム\n\n### スキーマバージョニング\n\nマイグレーションは`schema_version`テーブルを介して追跡されます：\n\n```python\nCURRENT_SCHEMA_VERSION = 7\n\n# 各マイグレーション：(バージョン、説明、[SQLステートメント])\nMIGRATIONS = [\n    (1, \"add air_weekday column\", [...]),\n    (2, \"add connection status columns\", [...]),\n    (3, \"create passkey table\", [...]),\n    (4, \"add archived column\", [...]),\n    (5, \"rename offset to episode_offset\", [...]),\n    (6, \"add qb_hash column\", [...]),\n    (7, \"add suggested offset columns\", [...]),\n]\n```\n\n### 新しいマイグレーションの追加\n\n1. `combine.py`の`CURRENT_SCHEMA_VERSION`をインクリメント\n2. `MIGRATIONS`リストにマイグレーションタプルを追加：\n\n```python\nMIGRATIONS = [\n    # ... 既存のマイグレーション ...\n    (\n        8,\n        \"add my_new_column to bangumi\",\n        [\n            \"ALTER TABLE bangumi ADD COLUMN my_new_column TEXT DEFAULT NULL\",\n        ],\n    ),\n]\n```\n\n3. `run_migrations()`に冪等性チェックを追加：\n\n```python\nif \"bangumi\" in tables and version == 8:\n    columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n    if \"my_new_column\" in columns:\n        needs_run = False\n```\n\n4. `module/models/`の対応するPydanticモデルを更新\n\n### デフォルト値のバックフィル\n\nマイグレーション後、`_fill_null_with_defaults()`がモデルのデフォルトに基づいてNULL値を自動的に埋めます：\n\n```python\n# モデルが定義している場合：\nclass Bangumi(SQLModel, table=True):\n    my_field: bool = False\n\n# NULLの既存行はFalseに更新されます\n```\n\n## パフォーマンスパターン\n\n### バッチクエリ\n\n`add_all()`は、Nクエリの代わりに単一のクエリを使用して重複をチェックします：\n\n```python\n# 効率的：単一SELECT\nkeys_to_check = [(d.title_raw, d.group_name) for d in datas]\nconditions = [\n    and_(Bangumi.title_raw == tr, Bangumi.group_name == gn)\n    for tr, gn in keys_to_check\n]\nstatement = select(Bangumi.title_raw, Bangumi.group_name).where(or_(*conditions))\n```\n\n### 正規表現マッチング\n\n`match_list()`は、すべてのタイトルマッチ用に単一の正規表現パターンをコンパイルします：\n\n```python\n# 一度コンパイル、多くマッチ\nsorted_titles = sorted(title_index.keys(), key=len, reverse=True)\npattern = \"|\".join(re.escape(title) for title in sorted_titles)\ntitle_regex = re.compile(pattern)\n\n# トレントごとにO(n)ではなくO(1)ルックアップ\nfor torrent in torrent_list:\n    match = title_regex.search(torrent.name)\n```\n\n### インデックス付きカラム\n\n以下のカラムには高速ルックアップ用のインデックスがあります：\n\n| テーブル | カラム | インデックスタイプ |\n|---------|--------|------------------|\n| `bangumi` | `title_raw` | 通常 |\n| `bangumi` | `deleted` | 通常 |\n| `bangumi` | `archived` | 通常 |\n| `rssitem` | `url` | ユニーク |\n| `torrent` | `name` | 通常 |\n| `torrent` | `url` | ユニーク |\n| `torrent` | `qb_hash` | 通常 |\n\n## テスト\n\n### テストデータベースセットアップ\n\nテストはインメモリSQLiteデータベースを使用します：\n\n```python\n# conftest.py\n@pytest.fixture\ndef db_engine():\n    engine = create_engine(\"sqlite:///:memory:\")\n    SQLModel.metadata.create_all(engine)\n    yield engine\n    engine.dispose()\n\n@pytest.fixture\ndef db_session(db_engine):\n    with Session(db_engine) as session:\n        yield session\n```\n\n### ファクトリ関数\n\nテストデータ作成にはファクトリ関数を使用：\n\n```python\nfrom test.factories import make_bangumi, make_torrent, make_rss_item\n\ndef test_bangumi_search():\n    bangumi = make_bangumi(title_raw=\"Test Title\", season=2)\n    # ... テストロジック\n```\n\n## 設計ノート\n\n### 外部キーなし\n\nSQLite外部キー強制はデフォルトで無効になっています。リレーションシップ（`Torrent.bangumi_id`など）はデータベース制約ではなくアプリケーションロジックで管理されます。\n\n### ソフト削除\n\n`Bangumi.deleted`フラグはソフト削除を可能にします。クエリはユーザー向けデータには`deleted=False`でフィルターする必要があります：\n\n```python\nstatement = select(Bangumi).where(Bangumi.deleted == false())\n```\n\n### トレントタグ付け\n\nトレントはリネーム操作中のオフセットルックアップ用にqBittorrentで`ab:{bangumi_id}`でタグ付けされます。これにより、データベースクエリなしで高速な番組識別が可能になります。\n\n## 一般的な問題\n\n### DetachedInstanceError\n\nキャッシュされたオブジェクトを別のセッションからアクセスする場合：\n\n```python\n# 間違い：新しいセッションでキャッシュされたオブジェクトにアクセス\nbangumis = db.bangumi.search_all()  # キャッシュ済み\nwith Database() as new_db:\n    new_db.session.add(bangumis[0])  # エラー！\n\n# 正しい：オブジェクトはexpungeされ、独立して動作\nbangumis = db.bangumi.search_all()\nbangumis[0].title_raw = \"New Title\"  # OK、ただし永続化されない\n```\n\n### キャッシュの古さ\n\n手動SQLアップデートがORMをバイパスする場合、キャッシュを無効化：\n\n```python\nfrom module.database.bangumi import _invalidate_bangumi_cache\n\nwith engine.connect() as conn:\n    conn.execute(text(\"UPDATE bangumi SET ...\"))\n    conn.commit()\n\n_invalidate_bangumi_cache()  # 重要！\n```\n"
  },
  {
    "path": "docs/ja/dev/index.md",
    "content": "# コントリビューションガイド\n\nAutoBangumiをより良くするためにユーザーが遭遇する問題を解決する手助けをしていただけるコントリビューターを歓迎します。\n\nこのガイドでは、AutoBangumiにコードをコントリビュートする方法を説明します。Pull Requestを提出する前に数分間お読みください。\n\nこの記事では以下を扱います：\n\n- [プロジェクトロードマップ](#プロジェクトロードマップ)\n  - [Request for Comments (RFC)](#request-for-comments-rfc)\n- [Gitブランチ管理](#gitブランチ管理)\n  - [バージョン番号](#バージョン番号)\n  - [ブランチ開発、トランクリリース](#ブランチ開発トランクリリース)\n  - [ブランチライフサイクル](#ブランチライフサイクル)\n  - [Gitワークフローの概要](#gitワークフローの概要)\n- [Pull Request](#pull-request)\n- [リリースプロセス](#リリースプロセス)\n\n\n## プロジェクトロードマップ\n\nAutoBangumi開発チームは[GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen)ボードを使用して、計画された開発、進行中の修正、およびその進捗を管理しています。\n\nこれにより以下を理解できます：\n- 開発チームが取り組んでいること\n- あなたの意図するコントリビューションに合致するものがあり、直接参加できること\n- すでに進行中のものがあり、重複作業を避けられること\n\n[Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen)では、通常の`[Feature Request]`、`[BUG]`、小さな改善に加えて、**`[RFC]`**アイテムがあります。\n\n### Request for Comments (RFC)\n\n> issuesの`RFC`ラベルを介して既存の[AutoBangumi RFC](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+label%3ARFC)を見つけてください。\n\n小さな改善やバグ修正については、コードを調整してPull Requestを提出してください。[ブランチ管理](#gitブランチ管理)セクションを読んで正しいブランチに基づいて作業し、[Pull Request](#pull-request)セクションでPRがどのようにマージされるかを理解してください。\n\n<br/>\n\n広範囲にわたる**大きな**機能リファクタリングについては、まず[Issue: Feature Proposal](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?assignees=&labels=RFC&projects=&template=rfc.yml&title=%5BRFC%5D%3A+)を介してRFC提案を書き、アプローチを簡潔に説明し、開発者の議論とコンセンサスを求めてください。\n\n一部の提案は開発チームがすでに行った決定と競合する可能性があり、このステップは無駄な努力を避けるのに役立ちます。\n\n> 機能を追加または改善するかどうか（「実装方法」ではなく）について議論したい場合は -> [Issue: Feature Request](https://github.com/EstrellaXD/Auto_Bangumi/issues/new?labels=feature+request&template=feature_request.yml&title=%5BFeature+Request%5D+)を使用してください\n\n\n<br/>\n\n[RFC提案](https://github.com/EstrellaXD/Auto_Bangumi/issues?q=is%3Aissue+is%3Aopen+label%3ARFC)は**「機能/リファクタリングの具体的な開発前に開発者が技術設計/アプローチをレビューするためのドキュメント」**です。\n\n目的は、協力する開発者が「何をするか」と「どのように行われるか」を明確に知り、すべての開発者がオープンな議論に参加できるようにすることです。\n\nこれにより、影響（見落とされた考慮事項、後方互換性、既存機能との競合）を評価できます。\n\nしたがって、提案は問題を解決するための**アプローチ、設計、ステップ**の説明に焦点を当てます。\n\n\n## Gitブランチ管理\n\n### バージョン番号\n\nAutoBangumiプロジェクトのGitブランチは、リリースバージョンルールと密接に関連しています。\n\nAutoBangumiは[セマンティックバージョニング（SemVer）](https://semver.org/)に従い、`<Major>.<Minor>.<Patch>`形式を使用します：\n\n- **Major**：メジャーバージョン更新、互換性のない設定/API変更がある可能性\n- **Minor**：後方互換性のある新機能\n- **Patch**：後方互換性のあるバグ修正 / 小さな改善\n\n### ブランチ開発、トランクリリース\n\nAutoBangumiは「ブランチ開発、トランクリリース」モデルを使用しています。\n\n[**`main`**](https://github.com/EstrellaXD/Auto_Bangumi/commits/main)は安定した**トランクブランチ**で、リリースにのみ使用され、直接開発には使用されません。\n\n各Minorバージョンには、新機能とリリース後のメンテナンス用の対応する**開発ブランチ**があります。\n\n開発ブランチは`<Major>.<Minor>-dev`という名前で、例：`3.1-dev`、`3.0-dev`、`2.6-dev`。[All Branches](https://github.com/EstrellaXD/Auto_Bangumi/branches/all?query=-dev)で見つけてください。\n\n\n### ブランチライフサイクル\n\nMinor開発ブランチ（例：`3.1-dev`）が機能開発を完了し、**最初に**mainにマージするとき：\n- Minorバージョン（例：`3.1.0`）をリリース\n- **次の**Minor開発ブランチ（`3.2-dev`）を次バージョンの機能用に作成\n  - **前の**バージョンのブランチ（`3.0-dev`）はアーカイブ\n- このMinorブランチ（`3.1-dev`）はメンテナンスに入る — 新機能/リファクタリングなし、バグ修正のみ\n  - バグ修正はメンテナンスブランチにマージされ、その後`Patch`リリースのためにmainへ\n\nコントリビューターのGitブランチ選択：\n- **バグ修正** — **現在リリースされているバージョンの**Minorブランチに基づき、そのブランチにPR\n- **新機能/リファクタリング** — **次の未リリースバージョンの**Minorブランチに基づき、そのブランチにPR\n\n> 「現在リリースされているバージョン」は[[Releases page]](https://github.com/EstrellaXD/Auto_Bangumi/releases)の最新バージョンです\n\n\n### Gitワークフローの概要\n\n> コミットタイムラインは左から右へ --->\n\n![dev-branch](/image/dev/branch.png)\n\n\n## Pull Request\n\n上記のGitブランチ管理セクションに従って、正しいPRターゲットブランチを選択していることを確認してください：\n> - **バグ修正** → **現在リリースされているバージョンの**Minorメンテナンスブランチに PR\n> - **新機能/リファクタリング** → **次バージョンの**Minor開発ブランチに PR\n\n<br/>\n\n- PRは単一の関心事に対応し、無関係な変更を導入しないでください。\n\n  異なる関心事を複数のPRに分割して、チームがレビューごとに1つの問題に集中できるようにしてください。\n\n- PRタイトルと説明で、理由と意図を含めて変更を簡潔に説明してください。\n\n  PR説明に関連するissuesやRFCをリンクしてください。\n\n  これにより、コードレビュー中にチームがコンテキストを素早く理解できます。\n\n- 「メンテナーからの編集を許可」がチェックされていることを確認してください。これにより、小さな編集/リファクタリングを直接行うことができ、時間を節約できます。\n\n- ローカルテストとリンティングがパスすることを確認してください。これらはPR CIでもチェックされます。\n  - バグ修正と新機能については、チームが対応するユニットテストカバレッジを要求する場合があります。\n\n\n開発チームはコントリビューターのPRをレビューし、できるだけ早く議論またはマージを承認します。\n\n## リリースプロセス\n\nリリースは現在、開発チームが特定の「リリースPR」を手動でマージした後に自動的にトリガーされます。\n\nバグ修正PRは通常、迅速にリリースされ、通常は1週間以内です。\n\n新機能リリースはより長くかかり、予測が難しいです。開発進捗については[GitHub Project](https://github.com/EstrellaXD/Auto_Bangumi/projects?query=is%3Aopen)ボードを確認してください — すべての計画された機能が完了するとバージョンがリリースされます。\n"
  },
  {
    "path": "docs/ja/faq/index.md",
    "content": "# よくある質問\n\n## WebUI\n\n### WebUIアドレス\n\nデフォルトポートは7892です。サーバーデプロイメントの場合は`http://serverhost:7892`に、ローカルデプロイメントの場合は`http://localhost:7892`にアクセスします。ポートを変更した場合は、Dockerのポートマッピングも更新することを忘れないでください。\n\n### デフォルトのユーザー名とパスワード\n\n- デフォルトのユーザー名：`admin`、デフォルトのパスワード：`adminadmin`\n- 初回ログイン後にパスワードを変更してください。\n\n### パスワードの変更またはリセット\n\n- パスワード変更：ログイン後、右上の`···`をクリックし、`プロファイル`をクリックして、ユーザー名とパスワードを変更します。\n- 現在、簡単なパスワードリセット方法はありません。パスワードを忘れた場合は、`data/data.db`ファイルを削除して再起動してください。\n\n### 設定変更が反映されないのはなぜですか？\n\n- 設定変更後、**適用**ボタンをクリックし、次に`···`メニューの**再起動**をクリックしてメインプロセスを再起動します。\n- デバッグモードが有効な場合は、`···`メニューの**シャットダウン**をクリックしてコンテナを再起動します。\n\n### プログラムが正常に動作しているか確認する方法\n\n新しいWebUIには右上に小さなドットがあります。緑は正常に動作中、赤はエラーが発生してプログラムが一時停止中を意味します。\n\n### ポスターウォールに画像が表示されない\n\n- バージョン3.0の場合：\n    ABはデフォルトで`mikanani.me`アドレスをポスター画像ソースとして使用します。画像が表示されない場合、ネットワークがこれらの画像にアクセスできません。\n- バージョン3.1以降の場合：\n  - ポスターにエラーアイコンが表示される場合、画像がありません。右上メニューのポスター更新ボタンをクリックしてTMDBポスターを取得してください。\n  - ポスターの読み込みに失敗する場合は、ブラウザのキャッシュをクリアしてください。\n  - RSSアドレスとして`mikanime.tv`を使用している場合、クライアント側のプロキシがポスターの読み込みを妨げる可能性があります。`direct`ルールを追加してください。\n\n## v3.0はどのようにアニメを管理しますか\n\nv3.0にアップグレード後、ABはWebUIでアニメトレントとダウンロードルールを管理できます。トレントのダウンロードパスとルール名に依存します。\nQBでトレントのダウンロードパスを手動で変更すると、通知にポスターがない、トレントの削除に失敗するなどの問題が発生する可能性があります。\nできるだけAB内でアニメとトレントを管理してください。\n\n## ダウンロードとキーワードフィルタリング\n\n### ダウンロードパス\n\n**ダウンロードパスには何を入力すればよいですか？**\n- このパラメータはqBittorrentの設定と一致させるだけです：\n  - Docker：qBが`/downloads`を使用している場合は、`/downloads/Bangumi`に設定します。`Bangumi`は任意の名前に変更できます。\n  - Linux/macOS：`/home/usr/downloads`または`/User/UserName/Downloads`の場合は、末尾に`/Bangumi`を追加するだけです。\n  - Windows：`D:\\Media\\`を`D:\\Media\\Bangumi`に変更します\n\n### ダウンロードが自動的に開始されない\n\nAutoBangumiのログでトレント関連のエントリを確認してください。\n- 存在しない場合は、購読が正しいか確認してください。\n\n### ダウンロードが正しいディレクトリに保存されない\n\n- [ダウンロードパス](#ダウンロードパス)が正しいか確認してください。\n- qBittorrentのPGIDとPUID設定でフォルダ作成権限を確認してください。任意のトレントを指定ディレクトリに手動でダウンロードしてみてください — エラーが発生するかディレクトリが作成されない場合は、権限の問題です。\n- qBittorrentのデフォルト設定を確認してください：保存管理は手動に設定する必要があります（保存管理 >> デフォルトトレント管理モード >> 手動）。\n\n### 購読していないアニメが多数ダウンロードされる\n\n- Mikan購読が単一のアニメのすべての字幕グループを含んでいないか確認してください。アニメごとに1つのグループのみを購読し、詳細購読を有効にしてください。\n  - 詳細購読はMikan Projectのユーザー設定で有効にできます。\n- 正規表現フィルタリングが不十分な場合があります — 正規表現の拡張については次のセクションを参照してください。\n- どちらにも該当しない場合は、ログとともに[Issues][ISSUE]で報告してください。\n\n### フィルターキーワードの書き方\n\nABのフィルターキーワードは正規表現で、ルール作成時にのみ追加されます。作成後にルールを拡張するには、WebUI（v3.0以降）を使用して各アニメを個別に設定します。\n- フィルターキーワードは正規表現です — 不要なキーワードは`|`で区切ります。\n- デフォルトの`720|\\d+-\\d+`ルールは、すべてのコレクションと720Pアニメをフィルタリングします。ABをデプロイする前にフィルターを追加してください。以降の環境変数の変更は新しいルールにのみ影響します。\n- 一般的な正規表現キーワード（`|`で区切り）：\n  - `720` — 720、720P、720pなどをフィルタリング\n  - `\\d+-\\d+` — [1-12]のようなコレクションをフィルタリング\n  - `[Bb]aha` — Bahaリリースをフィルタリング\n  - `[Bb]ilibili`、`[Bb]-Global` — Bilibiliリリースをフィルタリング\n  - `繁`、`CHT` — 繁体字中国語字幕をフィルタリング\n- 特定のキーワードに一致させるには、QBのincludeフィールドに追加：`XXXXX+1080P\\+`ここで`1080P\\+`は1080P+リリースに一致します。\n\n### 初回デプロイ時に不要なアニメがダウンロードされた\n\n1. QBで余分な自動ダウンロードルールとファイルを削除します。\n2. 購読とフィルタールールを確認します。\n3. ブラウザでresetRule APIにアクセス：`http://localhost:7892/api/v1/resetRule`でルールをリセットします。\n4. ABを再起動します。\n\n### ABが購読より少ないRSSエントリを識別する\n\n新しいバージョンでは、ABのフィルターもデフォルトですべてのRSSエントリをフィルタリングします。すべてのフィルターを一度に追加しないでください。細かい制御には、WebUIで各アニメを個別に設定してください。\n\n### フィルターキーワードが機能しない\n\n- **グローバルフィルター**パラメータが正しく設定されているか確認してください。\n- QBのRSS自動ダウンロードルールを確認してください — 右側で一致したRSSを確認でき、ダウンロードルールを調整して保存をクリックすると、どのキーワードが問題を引き起こしているかを特定できます。\n\n## エピソード補完\n\n### エピソード補完が機能しない\n\n**エピソード補完**パラメータが正しく設定されているか確認してください。\n\n## ファイルリネーム\n\n### 解析エラー `Cannot parse XXX`\n\n- ABは現在コレクションの解析をサポートしていません。\n- コレクションでない場合は、GitHub Issuesで問題を報告してください。\n\n### `Rename failed`またはリネームエラー\n\n- ファイルパスを確認してください。標準的な保存パスは`/title/Season/Episode.mp4`である必要があります。非標準のパスは名前付けエラーの原因となります — qBittorrentの設定を確認してください。\n- `ダウンロードパス`が正しく入力されているか確認してください。パスが正しくないと適切なリネームができません。\n- その他の問題については、GitHub Issuesで報告してください。\n\n### 自動リネームされない\n\n- QBのトレントカテゴリが`Bangumi`であるか確認してください。\n- ABはダウンロード済みのファイルのみをリネームします。\n\n### AB以外のアニメをABでリネームする方法\n\n- トレントのカテゴリを`Bangumi`に変更するだけです。\n- 注意：リネームをトリガーするには、トレントが`Title/Season X/`フォルダに保存されている必要があります。\n\n### コレクションのリネーム方法\n\n1. コレクションのカテゴリを`Bangumi`に変更します。\n2. コレクションの保存パスを`Title/Season X/`に変更します。\n3. コレクションのダウンロードが完了するのを待つと、リネームが完了します。\n\n## Docker\n\n### 自動更新の方法\n\nDockerで`watchtower`デーモンを実行して、コンテナを自動的に更新します。\n\n[watchtower](https://containrrr.dev/watchtower) 公式ドキュメント\n\n### Docker Composeでの更新\n\nABがDocker Composeでデプロイされている場合は、`docker compose pull`で更新します。\n新しいイメージをpullした後、`docker compose up -d`で再起動します。\n\n`docker-compose.yml`に`pull_policy: always`を追加すると、起動するたびに最新のイメージをpullできます。\n\n### アップグレードで問題が発生した場合の対処法\n\n設定が異なる場合があるため、アップグレードによりプログラムが失敗する可能性があります。この場合、以前のデータと生成された設定ファイルをすべて削除してから、コンテナを再起動してください。\nその後、WebUIで再設定します。\n古いバージョンからアップグレードする場合は、まず[アップグレードガイド](/ja/changelog/2.6)を参照してください。\n\n上記でカバーされていない問題が発生した場合は、バグテンプレートを使用して[Issues][ISSUE]で報告してください。\n\n\n[ISSUE]: https://github.com/EstrellaXD/Auto_Bangumi/issues\n"
  },
  {
    "path": "docs/ja/faq/network.md",
    "content": "# ネットワーク問題\n\n## Mikan Projectに接続できない\n\nMikan Projectのメインサイト（`https://mikanani.me`）は一部の地域でブロックされている可能性があるため、ABは接続に失敗することがあります。以下の解決策を使用してください：\n\n- [Mikan Projectの代替ドメインを使用](#mikan-projectの代替ドメイン)\n- [プロキシを使用](#プロキシの設定)\n- [Cloudflare Workerリバースプロキシを使用](#cloudflare-workersリバースプロキシ)\n\n### Mikan Projectの代替ドメイン\n\nMikan Projectには新しいドメイン`https://mikanime.tv`があります。プロキシを有効にせずに、このドメインをABで使用してください。\n\n以下が表示される場合：\n```\nDNS/Connect ERROR\n```\n\n- ネットワーク接続を確認してください。問題がない場合は、DNS解決を確認してください。\n- ABに`dns=8.8.8.8`を追加してください。Hostネットワークモードを使用している場合、これは無視できます。\n\nプロキシを使用している場合、正しい設定であればこのエラーは通常発生しません。\n\n### プロキシの設定\n\n::: tip\nAB 3.1以降、ABはRSS更新と通知を自分で処理するため、ABでプロキシを設定するだけで十分です。\n:::\n\nABにはプロキシ設定が組み込まれています。プロキシを設定するには、[プロキシ設定](/ja/config/proxy)の指示に従ってHTTPまたはSOCKSプロキシを正しく設定してください。これでアクセス問題が解決されます。\n\n**3.1より前のバージョンでは、qBittorrentのプロキシ設定も必要です**\n\nQBで以下のようにプロキシを設定してください（SOCKSも同様のアプローチ）：\n\n<img width=\"483\" alt=\"image\" src=\"https://user-images.githubusercontent.com/33726646/233681562-cca3957a-a5de-40e2-8fb3-4cc7f57cc139.png\">\n\n\n### Cloudflare Workersリバースプロキシ\n\nCloudflare Workersを介したリバースプロキシアプローチも使用できます。ドメインの設定とCloudflareへのバインドは、このガイドの範囲外です。\nWorkersに以下のコードを追加して、独自のドメインを使用してMikan Projectにアクセスし、RSSリンクからトレントをダウンロードします：\n\n```javascript\nconst TELEGRAPH_URL = 'https://mikanani.me';\nconst MY_DOMAIN = 'https://yourdomain.com'\n\naddEventListener('fetch', event => {\n  event.respondWith(handleRequest(event.request))\n})\n\nasync function handleRequest(request) {\n  const url = new URL(request.url);\n  url.host = TELEGRAPH_URL.replace(/^https?:\\/\\//, '');\n\n  const modifiedRequest = new Request(url.toString(), {\n    headers: request.headers,\n    method: request.method,\n    body: request.body,\n    redirect: 'manual'\n  });\n\n  const response = await fetch(modifiedRequest);\n  const contentType = response.headers.get('Content-Type') || '';\n\n  // コンテンツタイプがRSSの場合のみ置換を実行\n  if (contentType.includes('application/xml')) {\n    const text = await response.text();\n    const replacedText = text.replace(/https?:\\/\\/mikanani\\.me/g, MY_DOMAIN);\n    const modifiedResponse = new Response(replacedText, response);\n\n    // CORSヘッダーを追加\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  } else {\n    const modifiedResponse = new Response(response.body, response);\n\n    // CORSヘッダーを追加\n    modifiedResponse.headers.set('Access-Control-Allow-Origin', '*');\n\n    return modifiedResponse;\n  }\n}\n```\n\n設定が完了したら、**RSSを追加する**際に`https://mikanani.me`をあなたのドメインに置き換えてください。\n\n## qBittorrentに接続できない\n\nまず、ABの**ダウンローダーアドレス**パラメータが正しいか確認してください。\n- ABとQBが同じDockerネットワーク上にある場合、コンテナ名を使用したアドレス指定を試してください。例：`http://qbittorrent:8080`。\n- ABとQBが同じDockerサーバー上にある場合、Dockerゲートウェイアドレスを使用してみてください。例：`http://172.17.0.1:8080`。\n- ABのネットワークモードが`host`でない場合、QBへのアクセスに`127.0.0.1`を使用しないでください。\n\nDocker内のコンテナが相互にアクセスできない場合は、QBのネットワーク接続設定でQBとAB間のネットワークリンクを設定してください。qBittorrentがHTTPSを使用する場合は、**ダウンローダーアドレス**に`https://`プレフィックスを追加してください。\n"
  },
  {
    "path": "docs/ja/faq/troubleshooting.md",
    "content": "---\ntitle: トラブルシューティング\n---\n\n## 一般的なトラブルシューティングフロー\n\n1. ABが起動に失敗した場合、起動コマンドが正しいか確認してください。正しくなく、修正方法がわからない場合は、ABを再デプロイしてみてください。\n2. ABをデプロイした後、まずログを確認してください。以下のような出力が表示されれば、ABは正常に動作しており、QBに接続されています：\n      ```\n      [2022-07-09 21:55:19,164] INFO:\t                _        ____                                    _\n      [2022-07-09 21:55:19,165] INFO:\t     /\\        | |      |  _ \\                                  (_)\n      [2022-07-09 21:55:19,166] INFO:\t    /  \\  _   _| |_ ___ | |_) | __ _ _ __   __ _ _   _ _ __ ___  _\n      [2022-07-09 21:55:19,167] INFO:\t   / /\\ \\| | | | __/ _ \\|  _ < / _` | '_ \\ / _` | | | | '_ ` _ \\| |\n      [2022-07-09 21:55:19,167] INFO:\t  / ____ \\ |_| | || (_) | |_) | (_| | | | | (_| | |_| | | | | | | |\n      [2022-07-09 21:55:19,168] INFO:\t /_/    \\_\\__,_|\\__\\___/|____/ \\__,_|_| |_|\\__, |\\__,_|_| |_| |_|_|\n      [2022-07-09 21:55:19,169] INFO:\t                                            __/ |\n      [2022-07-09 21:55:19,169] INFO:\t                                           |___/\n      [2022-07-09 21:55:19,170] INFO:\tVersion 3.0.1  Author: EstrellaXD Twitter: https://twitter.com/Estrella_Pan\n      [2022-07-09 21:55:19,171] INFO:\tGitHub: https://github.com/EstrellaXD/Auto_Bangumi/\n      [2022-07-09 21:55:19,172] INFO:\tStarting AutoBangumi...\n      [2022-07-09 21:55:20,717] INFO:\tAdd RSS Feed successfully.\n      [2022-07-09 21:55:21,761] INFO:\tStart collecting RSS info.\n      [2022-07-09 21:55:23,431] INFO:\tFinished\n      [2022-07-09 21:55:23,432] INFO:\tRunning....\n      ```\n   1. このログが表示される場合、ABはqBittorrentに接続できません。qBittorrentが実行されているか確認してください。実行されている場合は、[ネットワーク問題](/ja/faq/network)セクションに進んでください。\n        ```\n        [2022-07-09 22:01:24,534] WARNING:  Cannot connect to qBittorrent, wait 5min and retry\n        ```\n   2. このログが表示される場合、ABはMikan RSSに接続できません。[ネットワーク問題](/ja/faq/network)セクションに進んでください。\n        ```\n        [2022-07-09 21:55:21,761] INFO:\t    Start collecting RSS info.\n        [2022-07-09 22:01:24,534] WARNING:  Connected Failed, please check DNS/Connection\n        ```\n3. この時点で、QBにはダウンロードタスクがあるはずです。\n   1. ダウンロードでパスの問題が表示される場合、QBの「保存管理」→「デフォルトTorrent管理モード」が「手動」に設定されているか確認してください。\n   2. すべてのダウンロードに感嘆符が表示されるか、ダウンロードパスにカテゴリフォルダが作成されない場合は、QBの権限を確認してください。\n4. 上記のいずれでも問題が解決しない場合は、新しいqBittorrentを再デプロイしてみてください。\n5. それでも成功しない場合は、ログを添えて[Issues](https://www.github.com/EstrellaXD/Auto_Bangumi/issues)で報告してください。\n"
  },
  {
    "path": "docs/ja/feature/bangumi.md",
    "content": "# 番組管理\n\nホームページでアニメのポスターをクリックして、個別のアニメエントリを管理します。\n\n![Bangumi List](/image/feature/bangumi-list.png)\n\nアニメに複数のダウンロードルールがある場合（例：異なる字幕グループ）、ルール選択ポップアップが表示されます：\n\n![Rule Selection](/image/feature/rule-select.png)\n\nルールを選択すると、編集モーダルが開きます：\n\n![Edit Bangumi](/image/feature/bangumi-edit.png)\n\n## 通知バッジ\n\nv3.2以降、番組カードにはステータスを示すiOSスタイルの通知バッジが表示されます：\n\n- **`!`が付いた黄色バッジ**：購読にレビューが必要（例：オフセットの問題が検出された）\n- **数字バッジ**：このアニメに複数のルールが存在\n- **組み合わせバッジ**（例：`! | 2`）：警告と複数のルールがある\n\n警告のあるカードは、注意を引くために黄色のグローアニメーションも表示されます。\n\n## エピソードオフセットの自動検出\n\n一部のアニメには、RSSエピソード番号とTMDBデータの不一致を引き起こす複雑なシーズン構造があります。例：\n- 「葬送のフリーレン」シーズン1は6ヶ月の間隔を置いて2つのパートで放送されました\n- RSSは「S2E01」と表示しますが、TMDBは「S1E29」と見なします\n\nAB v3.2はこれらの問題を自動的に検出できます：\n\n1. 編集モーダルで**自動検出**ボタンをクリック\n2. ABはTMDBエピソード放送日を分析して「仮想シーズン」を特定\n3. 不一致が見つかった場合、ABは正しいオフセット値を提案\n4. **適用**をクリックしてオフセットを保存\n\nバックグラウンドスキャンスレッドも定期的に既存の購読のオフセット問題をチェックし、レビュー対象としてマークします。\n\n## アニメのアーカイブ / アーカイブ解除\n\nv3.2以降、完了したまたは非アクティブなアニメをアーカイブしてリストを整理できます。\n\n### 手動アーカイブ\n\n1. アニメのポスターをクリック\n2. 編集モーダルで**アーカイブ**ボタンをクリック\n3. アニメはリストの下部にある「アーカイブ済み」セクションに移動\n\n### 自動アーカイブ\n\nABは以下の場合にアニメを自動的にアーカイブできます：\n- TMDBのシリーズステータスが「終了」または「キャンセル」と表示\n- **設定** → メタデータを更新を使用して自動アーカイブをトリガー\n\n### アーカイブされたアニメの表示\n\nアーカイブされたアニメは、番組リストの下部にある折りたたみ可能な「アーカイブ済み」セクションに表示されます。クリックして展開し、アーカイブされたアイテムを表示します。\n\n### アーカイブ解除\n\nアーカイブされたアニメを復元するには：\n1. 「アーカイブ済み」セクションを展開\n2. アニメのポスターをクリック\n3. **アーカイブ解除**ボタンをクリック\n\n## アニメの無効化 / 削除\n\nABは継続的に**集約RSS**フィードを解析するため、不要になった集約RSSからのダウンロードルールについては：\n- アニメを無効化：アニメはダウンロードも再解析もされません\n- 集約RSSから購読を削除\n\nアニメエントリを削除すると、次の解析サイクルで再作成されます。\n\n## 詳細設定\n\n編集モーダルで**詳細設定**をクリックして、追加のオプションにアクセスします：\n\n![Advanced Settings](/image/feature/bangumi-edit-advanced.png)\n\n- **シーズンオフセット**：シーズン番号オフセットを調整\n- **エピソードオフセット**：エピソード番号オフセットを調整\n- **フィルター**：トレントマッチング用のカスタム正規表現フィルター\n"
  },
  {
    "path": "docs/ja/feature/calendar.md",
    "content": "# カレンダービュー\n\nv3.2以降、ABには購読しているアニメを放送日ごとに整理して表示するカレンダービューが含まれています。\n\n![Calendar](/image/feature/calendar.png)\n\n## 機能\n\n### 週間スケジュール\n\nカレンダーは、放送曜日（月曜日から日曜日）ごとに整理されたアニメを表示し、放送スケジュールデータのないアニメ用の「不明」列もあります。\n\n### Bangumi.tv連携\n\nABはBangumi.tvから放送スケジュールデータを取得して、各アニメの放送時間を正確に表示します。\n\n**スケジュールを更新**ボタンをクリックして、放送データを更新します。\n\n### グループ表示\n\nv3.2以降、複数のダウンロードルールを持つアニメはグループ化されます：\n\n- 複数の字幕グループルールがあっても、同じアニメは1回だけ表示されます\n- グループ化されたアニメをクリックすると、利用可能なすべてのルールが表示されます\n- 特定のルールを選択して編集\n\nこれにより、すべてのルールへのアクセスを提供しながら、カレンダーをすっきり保ちます。\n\n## ナビゲーション\n\nカレンダー内の任意のアニメポスターをクリックして：\n- アニメの詳細を表示\n- ダウンロードルールを編集\n- アーカイブ/無効化オプションにアクセス\n\n## ヒント\n\n::: tip\nアニメが「不明」列に表示される場合、Bangumi.tvに放送データがないか、アニメタイトルがマッチングできなかった可能性があります。\n:::\n"
  },
  {
    "path": "docs/ja/feature/rename.md",
    "content": "# ファイルリネーム\n\nABは現在3つのリネーム方法を提供しています：`pn`、`advance`、`none`。\n\n### pn\n\n`pure name`の略です。この方法はトレントダウンロード名を使用してリネームします。\n\n例：\n```\n[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six S01E01.mkv\n```\n\n### advance\n\n高度なリネーム。この方法は親フォルダ名を使用してリネームします。\n\n```\n/downloads/Bangumi/86 - Eighty Six(2023)/Season 1/[Lilith-Raws] 86 - Eighty Six - 01 [Baha][WEB-DL][1080p][AVC AAC][CHT][MKV].mkv\n>>\n86 - Eighty Six(2023) S01E01.mkv\n```\n\n### none\n\nリネームなし。ファイルはそのまま残されます。\n\n## コレクションのリネーム\n\nABはコレクションのリネームをサポートしています。コレクションのリネームには以下が必要です：\n- エピソードがコレクションの第1レベルディレクトリにある\n- ファイル名からエピソード番号を解析できる\n\nABはまた、第1レベルディレクトリ内の字幕ファイルもリネームできます。\n\nリネーム後、エピソードとディレクトリは`Season`フォルダに配置されます。\n\nリネームされたコレクションは`BangumiCollection`の下に移動され、カテゴリ分けされます。\n\n## エピソードオフセット\n\nv3.2以降、ABはリネーム用のエピソードオフセットをサポートしています。これは以下の場合に便利です：\n- RSSが期待と異なるエピソード番号を表示（例：S2E01がS1E29であるべき）\n- 放送ギャップによるアニメの「仮想シーズン」がある\n\n番組にオフセットが設定されている場合、ABはリネーム中に自動的に適用します：\n\n```\n元：S02E01.mkv\nオフセット適用後（シーズン：-1、エピソード：+28）：S01E29.mkv\n```\n\nオフセットを設定するには：\n1. アニメのポスターをクリック\n2. 詳細設定を開く\n3. シーズンオフセットおよび/またはエピソードオフセットの値を設定\n4. または「自動検出」を使用してABに正しいオフセットを提案させる\n\n自動検出の詳細については、[番組管理](/ja/feature/bangumi#エピソードオフセットの自動検出)を参照してください。\n"
  },
  {
    "path": "docs/ja/feature/rss.md",
    "content": "---\ntitle: RSS管理\n---\n\n# RSS管理\n\n## RSSマネージャーページ\n\nRSSマネージャーページには、すべてのRSS購読と接続ステータスが表示されます。\n\n![RSS Manager](/image/feature/rss-manager.png)\n\n### 接続ステータス\n\nv3.2以降、ABは各RSSソースの接続ステータスを追跡します：\n\n| ステータス | 説明 |\n|----------|------|\n| **接続済み**（緑） | RSSソースに到達可能で、有効なデータを返しています |\n| **エラー**（赤） | RSSソースが応答しないか、無効なデータを返しました |\n\nソースがエラーを表示している場合、ステータスラベルにマウスを合わせるとツールチップでエラーの詳細が表示されます。\n\nABは各RSSリフレッシュサイクルで接続ステータスを自動的に更新します。\n\n## コレクションの追加\n\nABは2つの手動ダウンロード方法を提供しています：\n**コレクト**と**サブスクライブ**。\n- **コレクト**はすべてのエピソードを一度にダウンロードし、完結したアニメに適しています。\n- **サブスクライブ**は対応するRSSリンクで自動ダウンロードルールを追加し、放送中のアニメに適しています。\n\n### RSSリンクの解析\n\nABはすべてのリソースサイトからのコレクションRSSリンクの解析をサポートしています。対応するサイトで希望するアニメのコレクションRSSを見つけ、ABの右上隅にある**+**ボタンをクリックし、ポップアップウィンドウにRSSリンクを貼り付けます。\n\n### ダウンロードの追加\n\n解析が成功すると、解析されたアニメ情報を表示するウィンドウが表示されます。**コレクト**または**サブスクライブ**をクリックしてダウンロードキューに追加します。\n\n### よくある問題\n\n解析エラーが発生した場合、RSSリンクが正しくないか、サポートされていない字幕グループの命名形式が原因である可能性があります。\n\n## 番組の管理\n\nv3.0以降、ABはWebUIで手動のアニメ管理を提供し、誤って解析されたアニメ情報を手動で調整できます。\n\n### アニメ情報の編集\n\nアニメリストで、アニメのポスターをクリックしてアニメ情報ページに入ります。\n情報を変更した後、**適用**をクリックします。\nABは変更に基づいてディレクトリを再調整し、ファイルを自動的にリネームします。\n\n\n### アニメの削除\n\nv3.0以降、アニメを手動で削除できます。アニメのポスターをクリックし、情報ページに入り、**削除**をクリックします。\n\n::: warning\nアニメを削除した後、RSS購読がキャンセルされていない場合、ABは引き続き再解析します。ダウンロードルールを無効にするには、[アニメの無効化](#アニメの無効化)を使用してください。\n:::\n\n### アニメの無効化\n\nv3.0以降、アニメを手動で無効にできます。アニメのポスターをクリックし、情報ページに入り、**無効**をクリックします。\n\n無効化されると、アニメのポスターはグレーアウトされ、最後にソートされます。ダウンロードルールを再度有効にするには、**有効**をクリックします。\n"
  },
  {
    "path": "docs/ja/feature/search.md",
    "content": "# トレント検索\n\nv3.1以降、ABにはアニメを素早く見つけるための検索機能が含まれています。\n\n## 検索機能の使用\n\n::: warning\n検索機能はメインプログラムのパーサーに依存しています。現在のバージョンはコレクションの解析をサポートしていません。コレクションを解析する際の`warning`は正常な動作です。\n:::\n\n検索バーはABのトップバーにあります。クリックして検索パネルを開きます。\n\n![Search Panel](/image/feature/search-panel.png)\n\nソースサイトを選択し、キーワードを入力すると、ABが自動的に解析して検索結果を表示します。アニメを追加するには、カードの右側にある追加ボタンをクリックします。\n\n::: tip\nソースが**Mikan**の場合、ABはデフォルトで`mikan`パーサーを使用します。他のソースの場合、TMDBパーサーが使用されます。\n:::\n\n## 検索ソースの管理\n\nv3.2以降、JSONファイルを編集せずに、設定ページで直接検索ソースを管理できます。\n\n### 検索プロバイダー設定パネル\n\n**設定** → **検索プロバイダー**に移動して、設定パネルにアクセスします。\n\n![Search Provider Settings](/image/feature/search-provider.png)\n\nここから以下が可能です：\n- すべての設定済み検索ソースを**表示**\n- 「プロバイダーを追加」ボタンで新しい検索ソースを**追加**\n- 既存のソースURLを**編集**\n- カスタムソースを**削除**（デフォルトソースmikan、nyaa、dmhyは削除できません）\n\n### URLテンプレート形式\n\nカスタムソースを追加する場合、URLには検索キーワードのプレースホルダーとして`%s`を含める必要があります。\n\n例：\n```\nhttps://example.com/rss/search?q=%s\n```\n\n`%s`はユーザーの検索クエリに置き換えられます。\n\n### デフォルトソース\n\n以下のソースは組み込みで、削除できません：\n\n| ソース | URLテンプレート |\n|--------|---------------|\n| mikan | `https://mikanani.me/RSS/Search?searchstr=%s` |\n| nyaa | `https://nyaa.si/?page=rss&q=%s&c=0_0&f=0` |\n| dmhy | `http://dmhy.org/topics/rss/rss.xml?keyword=%s` |\n\n### 設定ファイル経由でソースを追加\n\n`config/search_provider.json`を編集してソースを手動で追加することもできます：\n\n```json\n{\n  \"mikan\": \"https://mikanani.me/RSS/Search?searchstr=%s\",\n  \"nyaa\": \"https://nyaa.si/?page=rss&q=%s&c=0_0&f=0\",\n  \"dmhy\": \"http://dmhy.org/topics/rss/rss.xml?keyword=%s\",\n  \"bangumi.moe\": \"https://bangumi.moe/rss/search/%s\"\n}\n```\n"
  },
  {
    "path": "docs/ja/home/index.md",
    "content": "---\ntitle: 概要\n---\n\n<p align=\"center\">\n<picture>\n  <source media=\"(prefers-color-scheme: dark)\" srcset=\"/image/icons/dark-icon.svg\">\n  <source media=\"(prefers-color-scheme: light)\" srcset=\"/image/icons/light-icon.svg\">\n  <img src=\"/image/icons/light-icon.svg\" width=50%>\n</picture>\n</p>\n\n\n## AutoBangumiについて\n\n\n<p align=\"center\">\n  <img\n    title=\"AutoBangumi WebUI\"\n    alt=\"AutoBangumi WebUI\"\n    src=\"/image/preview/window.png\"\n    width=85%\n    data-zoomable\n  >\n</p>\n\n**`AutoBangumi`** は、RSSフィードに基づく全自動アニメダウンロード・整理ツールです。[Mikan Project][mikan]などのサイトでアニメを購読するだけで、新しいエピソードを自動的に追跡・ダウンロードします。\n\n整理されたファイル名とディレクトリ構造は、追加のメタデータスクレイピングなしで[Plex][plex]、[Jellyfin][jellyfin]などのメディアライブラリソフトウェアと直接互換性があります。\n\n## 機能\n\n- シンプルな一回限りの設定で継続的に使用可能\n- アニメ情報を抽出し、自動的にダウンロードルールを生成する手間いらずのRSSパーサー\n- アニメファイルの整理：\n\n  ```\n  Bangumi\n  ├── bangumi_A_title\n  │   ├── Season 1\n  │   │   ├── A S01E01.mp4\n  │   │   ├── A S01E02.mp4\n  │   │   ├── A S01E03.mp4\n  │   │   └── A S01E04.mp4\n  │   └── Season 2\n  │       ├── A S02E01.mp4\n  │       ├── A S02E02.mp4\n  │       ├── A S02E03.mp4\n  │       └── A S02E04.mp4\n  ├── bangumi_B_title\n  │   └─── Season 1\n  ```\n\n- 完全自動リネーム — リネーム後、99%以上のアニメファイルがメディアライブラリソフトウェアで直接スクレイピング可能\n\n  ```\n  [Lilith-Raws] Kakkou no Iinazuke - 07 [Baha][WEB-DL][1080p][AVC AAC][CHT][MP4].mp4\n  >>\n  Kakkou no Iinazuke S01E07.mp4\n  ```\n\n- 親フォルダ名に基づくすべての子ファイルのカスタムリネーム\n- シーズン途中からの追跡で現在のシーズンの見逃したエピソードをすべて補完\n- 異なるメディアライブラリソフトウェアに合わせて微調整できる高度にカスタマイズ可能なオプション\n- メンテナンス不要、完全に透明な動作\n- 完全なTMDB形式のファイルとアニメメタデータを生成する内蔵TMDBパーサー\n- Mikan RSSフィードのリバースプロキシサポート\n\n## コミュニティ\n\n- 更新通知：[Telegramチャンネル](https://t.me/autobangumi_update)\n- バグ報告：[Telegram](https://t.me/+yNisOnDGaX5jMTM9)\n\n## 謝辞\n\n[Sean](https://github.com/findix)氏のプロジェクトへの多大なご協力に感謝いたします。\n\n## コントリビュート\n\nIssuesとPull Requestsを歓迎します！\n\n<a href=\"https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors\">\n  <img src=\"https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi\" />\n</a>\n\n## 免責事項\n\nAutoBangumiは非公式の著作権チャンネルを通じてアニメを取得するため：\n\n- AutoBangumiを**商業目的で使用しないでください**。\n- AutoBangumiを含むビデオコンテンツを作成し、国内のビデオプラットフォーム（著作権関係者）で**配信しないでください**。\n- AutoBangumiを法律や規制に違反する活動に**使用しないでください**。\n\nAutoBangumiは教育目的および個人使用のみを目的としています。\n\n## ライセンス\n\n[MIT License](https://github.com/EstrellaXD/Auto_Bangumi/blob/main/LICENSE)\n\n[mikan]: https://mikanani.me\n[plex]: https://plex.tv\n[jellyfin]: https://jellyfin.org\n"
  },
  {
    "path": "docs/ja/home/pipline.md",
    "content": "# AutoBangumiの仕組み\n\nAutoBangumi（略称AB）は基本的にRSSパーサーです。アニメトレントサイトからのRSSフィードを解析し、トレントタイトルからメタデータを抽出し、ダウンロードルールを生成してqBittorrentに送信してダウンロードします。ダウンロード後、ファイルを標準的なメディアライブラリのディレクトリ構造に整理します。\n\n## パイプライン概要\n\n1. **RSS解析** — ABは定期的に購読しているRSSフィードを取得して解析します\n2. **タイトル分析** — トレントタイトルを解析してアニメ名、エピソード番号、シーズン、字幕グループ、解像度を抽出します\n3. **ルール生成** — 解析された情報に基づいてqBittorrentにダウンロードルールを作成します\n4. **ダウンロード管理** — qBittorrentが実際のトレントダウンロードを処理します\n5. **ファイル整理** — ダウンロードされたファイルはリネームされ、標準化されたディレクトリ構造に移動されます\n6. **メディアライブラリ対応** — 整理されたファイルはPlex、Jellyfin、その他のメディアサーバーで直接認識できます\n"
  },
  {
    "path": "docs/ja/index.md",
    "content": "---\n# https://vitepress.dev/reference/default-theme-home-page\nlayout: home\n\ntitle: AutoBangumi\ntitleTemplate: 全自動アニメ追跡、手間いらず！\n\nhero:\n  name: AutoBangumi\n  text: 全自動アニメ追跡、手間いらず！\n  tagline: RSS購読の自動解析、ダウンロード管理、ファイル整理\n  actions:\n    - theme: brand\n      text: クイックスタート\n      link: /ja/deploy/quick-start\n    - theme: alt\n      text: 概要\n      link: /ja/home/\n    - theme: alt\n      text: 更新履歴\n      link: /ja/changelog/3.2\n\nfeatures:\n  - icon:\n      src: /image/icons/rss.png\n    title: RSS購読解析\n    details: アニメのRSS購読を自動的に認識・解析。手動入力不要で、購読するだけで自動的に解析、ダウンロード、整理を完了します。\n  - icon:\n      src: /image/icons/qbittorrent-logo.svg\n    title: qBittorrentダウンローダー\n    details: qBittorrentを使用してアニメをダウンロード。AutoBangumiで既存のアニメ管理、過去のエピソードのダウンロード、エントリの削除が可能です。\n  - icon:\n      src: /image/icons/tmdb-icon.png\n    title: TMDBメタデータマッチング\n    details: TMDBを通じてアニメ情報をマッチングし、正確なメタデータを取得。複数の字幕グループ間でも正しく解析できます。\n  - icon:\n      src: /image/icons/plex-icon.png\n    title: Plex / Jellyfin / Infuse ...\n    details: マッチング結果に基づいてファイル名とディレクトリ構造を自動整理。メディアライブラリソフトウェアが高い成功率でメタデータをスクレイピングできるようにします。\n---\n\n\n<div class=\"container\">\n<div class=\"vp-doc\">\n\n## 謝辞\n\n### 感謝\n- [Mikan Project](https://mikanani.me) - 優れたアニメリソースを提供していただきありがとうございます。\n- [VitePress](https://vitepress.dev) - 優れたドキュメントフレームワークを提供していただきありがとうございます。\n- [qBittorrent](https://www.qbittorrent.org) - 優れたダウンローダーを提供していただきありがとうございます。\n- [Plex](https://www.plex.tv) / [Jellyfin](https://jellyfin.org) - 優れたセルフホストメディアライブラリを提供していただきありがとうございます。\n- [Infuse](https://firecore.com/infuse) - エレガントなビデオプレーヤーを提供していただきありがとうございます。\n- [弾弾 Play](https://www.dandanplay.com) - 優れたコメント付きプレーヤーを提供していただきありがとうございます。\n- すべてのアニメ制作チーム / 字幕グループ / ファンの皆様。\n\n### コントリビューター\n\n[\n  ![](https://contrib.rocks/image?repo=EstrellaXD/Auto_Bangumi){class=contributors-avatar}\n](https://github.com/EstrellaXD/Auto_Bangumi/graphs/contributors)\n\n## 免責事項\n\nAutoBangumiは非公式の著作権チャンネルを通じてアニメを取得するため：\n\n- AutoBangumiを**商業目的で使用しないでください**。\n- AutoBangumiを含むビデオコンテンツを作成し、国内のビデオプラットフォーム（著作権関係者）で**公開しないでください**。\n- AutoBangumiを法律や規制に違反する活動に**使用しないでください**。\n\n</div>\n</div>\n\n<style scoped>\n.container {\n  display: flex;\n  position: relative;\n  margin: 0 auto;\n  padding: 0 24px;\n  max-width: 1280px;\n}\n\n@media (min-width: 640px) {\n  .container {\n    padding-inline: 48px;\n  }\n}\n\n@media (min-width: 960px) {\n  .container {\n    padding-inline: 64px;\n  }\n}\n\n\n.contributors-avatar {\n  width: 600px;\n}\n</style>\n"
  },
  {
    "path": "docs/package.json",
    "content": "{\n  \"private\": true,\n  \"scripts\": {\n    \"docs:dev\": \"vitepress dev\",\n    \"docs:build\": \"vitepress build\",\n    \"docs:preview\": \"vitepress preview\"\n  },\n  \"devDependencies\": {\n    \"@vue/tsconfig\": \"^0.5.1\",\n    \"medium-zoom\": \"^1.1.0\",\n    \"typescript\": \"~5.6.0\",\n    \"vitepress\": \"1.6.4\",\n    \"vue\": \"^3.5.0\"\n  },\n  \"type\": \"module\",\n  \"engines\": {\n    \"node\": \">=20\"\n  }\n}\n"
  },
  {
    "path": "docs/plans/2026-01-25-search-panel-redesign.md",
    "content": "# Search Panel Redesign\n\n## Overview\n\nRedesign the search bar component from a dropdown list to a full modal-based search experience with advanced filtering capabilities.\n\n## Problems with Current Implementation\n\n1. **Click-outside clears everything** - `v-on-click-outside=\"clearSearch\"` causes accidental loss of results\n2. **Limited result display** - Absolute positioned list with no scroll container\n3. **No explicit close control** - User has no intentional way to dismiss except clicking outside\n4. **No filtering** - Results often contain same anime with different subtitle groups/seasons, hard to find the right one\n\n## Design Goals\n\n- Prevent accidental dismissal of search results\n- Support displaying many results with proper scrolling\n- Enable filtering by subtitle group, resolution, subtitle type, and season\n- Provide confirmation step before subscribing\n\n---\n\n## Search Panel Structure\n\n### Trigger & Toggle Behavior\n\n- Clicking the search input opens the search modal (if closed) or closes it (if open)\n- Pressing `Escape` closes the modal\n- A visible `×` close button in the modal header provides explicit dismissal\n- Clicking the backdrop does NOT close (prevents accidental loss of results)\n\n### Modal Layout\n\n```\n┌─────────────────────────────────────────────────────────┐\n│  🔍 [Search input]         [provider ▼]           [×]  │\n├─────────────────────────────────────────────────────────┤\n│ 字幕组:   [喵萌奶茶屋] [ANi] [桜都] [LoliHouse] [+3]    │\n│ 分辨率:   [1080p] [720p] [4K]                          │\n│ 字幕语言: [简中 CHS] [繁中 CHT] [双语] [内嵌] [外挂]     │\n│ 季度:     [S1] [S2] [剧场版]                           │\n│                                    [清除筛选] 8/24 结果 │\n├─────────────────────────────────────────────────────────┤\n│  ┌─────────┐ ┌─────────┐ ┌─────────┐                   │\n│  │ Result  │ │ Result  │ │ Result  │                   │\n│  │  Card   │ │  Card   │ │  Card   │                   │\n│  └─────────┘ └─────────┘ └─────────┘                   │\n│  ┌─────────┐ ┌─────────┐ ┌─────────┐                   │\n│  │ Result  │ │ Result  │ │ Result  │                   │\n│  │  Card   │ │  Card   │ │  Card   │                   │\n│  └─────────┘ └─────────┘ └─────────┘                   │\n│                    (scrollable grid)                    │\n└─────────────────────────────────────────────────────────┘\n```\n\n- Modal centered on screen with backdrop overlay\n- Fixed header with search input, provider selector, close button\n- Filter chips section below header (sticky when scrolling)\n- Scrollable grid of result cards (3 columns on desktop, responsive)\n\n---\n\n## Filter Chip System\n\n### Four Filter Dimensions\n\n1. **字幕组 (Subtitle Group)** - Fansub team name\n2. **分辨率 (Resolution)** - 720p, 1080p, 4K/2160p\n3. **字幕语言 (Subtitle Type)** - CHS (简中), CHT (繁中), 双语 (Dual), 内嵌 (Hardcoded), 外挂 (External ASS/SRT)\n4. **季度 (Season)** - S1, S2, Movie/剧场版, OVA\n\n### Auto-generated Filters\n\n- As results stream in, extract unique values for each dimension\n- Chips appear dynamically as new filter values are discovered\n- Parsing logic extracts metadata from torrent titles\n\n### Filter Behavior\n\n- Chips are toggleable - click to activate (highlighted), click again to deactivate\n- Multiple filters can be active simultaneously\n- Logic: AND within same category, OR across categories\n- Active filters show with filled background, inactive with outline style\n- \"清除筛选\" (Clear all) button appears when any filter is active\n- Result count updates dynamically: \"显示 8 / 24 个结果\"\n\n### Overflow Handling\n\n- If a category has >5 options, show first 4 + `[+N more]` chip that expands\n- Each category row can collapse/expand independently\n- Collapsed state shows badge count for active filters\n\n---\n\n## Result Cards\n\n### Card Design (Compact Grid Item)\n\n```\n┌──────────────────────────┐\n│ ┌──────┐  葬送的芙莉莲   │\n│ │poster│  Frieren        │\n│ │      │  ─────────────  │\n│ └──────┘  喵萌奶茶屋     │\n│           1080p · 简中   │\n│           S1 · 全28集    │\n└──────────────────────────┘\n```\n\n### Card Content\n\n- Thumbnail/poster (if available from API, placeholder if not)\n- Title (Chinese + Romaji/English)\n- Subtitle group badge\n- Resolution + Subtitle type tags\n- Season + Episode count\n\n### Streaming Animation\n\n- Cards fade in with slight upward slide (`opacity: 0 → 1`, `translateY: 8px → 0`)\n- Staggered delay: each card delays 50ms after previous\n- Grid re-flows smoothly as new cards appear\n- Filter changes use same fade animation for showing/hiding\n\n### Empty & Loading States\n\n| State | Display |\n|-------|---------|\n| Initial | \"输入关键词开始搜索\" |\n| Searching | Spinner in search input, cards stream in |\n| No results | \"未找到相关结果，试试其他关键词\" |\n| Filtered to zero | \"没有符合筛选条件的结果\" + \"清除筛选\" button |\n\n---\n\n## Confirmation Modal\n\nWhen user clicks a result card, a nested modal appears for review before subscribing.\n\n### Layout\n\n```\n┌─────────────────────────────────────────────────────┐\n│  添加订阅                                      [×]  │\n├─────────────────────────────────────────────────────┤\n│  ┌────────┐                                         │\n│  │        │  葬送的芙莉莲                           │\n│  │ poster │  Sousou no Frieren                      │\n│  │        │  ★ 9.2  ·  2023年秋  ·  全28集          │\n│  └────────┘                                         │\n├─────────────────────────────────────────────────────┤\n│  RSS 源:    [当前选择的RSS链接]              [复制] │\n│  字幕组:    喵萌奶茶屋                              │\n│  分辨率:    1080p                                   │\n│  字幕类型:  简体中文 (内嵌)                          │\n├─────────────────────────────────────────────────────┤\n│  高级设置 ▼                                         │\n│  ┌─────────────────────────────────────────────┐   │\n│  │ 过滤规则:  [自动生成的filter]                │   │\n│  │ 保存路径:  /media/anime/葬送的芙莉莲/       │   │\n│  │ 重命名:    ☑ 启用自动重命名                  │   │\n│  └─────────────────────────────────────────────┘   │\n├─────────────────────────────────────────────────────┤\n│                          [取消]    [确认订阅 ✓]     │\n└─────────────────────────────────────────────────────┘\n```\n\n### Behavior\n\n- Shows parsed metadata for user review\n- Advanced settings collapsed by default\n- \"确认订阅\" adds to subscriptions and closes both modals\n- \"取消\" returns to search results (results preserved)\n- Escape key returns to search results (not full close)\n\n---\n\n## Keyboard Navigation\n\n| Key | Action |\n|-----|--------|\n| `Enter` (in search input) | Trigger search |\n| `Escape` | Close confirmation modal (if open) → close search modal |\n| `Tab` | Navigate through filter chips, then result cards |\n| `Enter` (on focused card) | Open confirmation modal |\n| Arrow keys | Navigate grid (optional enhancement) |\n\n---\n\n## Responsive Design\n\n### Breakpoints\n\n| Viewport | Grid | Modal Width | Behavior |\n|----------|------|-------------|----------|\n| Desktop (>1024px) | 3 columns | 800px centered | Full experience |\n| Tablet (768-1024px) | 2 columns | 90% width | Filters collapse to single row |\n| Mobile (<768px) | 1 column | Full screen | Bottom sheet style |\n\n### Mobile Adaptations\n\n- Modal becomes full-screen bottom sheet\n- Filter chips horizontally scrollable (single row)\n- Cards stack vertically (single column)\n- Swipe down to close (in addition to × button)\n\n---\n\n## Component Structure\n\n```\nab-search-modal.vue (new - main modal container)\n├── ab-search-header.vue (search input + provider + close)\n├── ab-search-filters.vue (new - filter chips)\n├── ab-search-results.vue (new - scrollable grid)\n│   └── ab-search-card.vue (new - individual result card)\n└── ab-search-confirm.vue (new - confirmation modal)\n```\n\n### State Management\n\nExtend `useSearchStore` with:\n- `filters` - active filter state per dimension\n- `filteredResults` - computed results after filter application\n- `showModal` - modal open/close state\n- `selectedResult` - currently selected result for confirmation\n\n---\n\n## Implementation Notes\n\n### Filter Parsing\n\nNeed to extract metadata from torrent titles. Common patterns:\n- Subtitle group: `[喵萌奶茶屋]`, `【ANi】`\n- Resolution: `1080p`, `720p`, `2160p`, `4K`\n- Subtitle type: `简体`, `繁體`, `CHS`, `CHT`, `简日双语`, `内嵌`, `外挂`\n- Season: `S01`, `Season 1`, `第一季`, `剧场版`, `Movie`, `OVA`\n\n### SSE Streaming\n\nKeep existing SSE implementation but:\n- Parse metadata from each result as it arrives\n- Update filter options incrementally\n- Apply active filters to new results immediately\n\n### Animation\n\nUse Vue's `<TransitionGroup>` with staggered delays:\n```css\n.card-enter-active {\n  transition: all 0.3s ease;\n  transition-delay: calc(var(--index) * 50ms);\n}\n.card-enter-from {\n  opacity: 0;\n  transform: translateY(8px);\n}\n```\n"
  },
  {
    "path": "docs/plans/2026-02-23-calendar-drag-organize-design.md",
    "content": "# Calendar Drag-to-Organize Unknown Bangumi — Implementation Plan\n\n> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.\n\n**Goal:** Allow users to drag bangumi cards from the \"Unknown\" section into weekday columns in the calendar view, with a locked flag to prevent calendar refresh from overwriting manual assignments.\n\n**Architecture:** Add a `weekday_locked` boolean field to the Bangumi model. A new API endpoint sets weekday + locks it. The `refresh_calendar()` method skips locked items. Frontend uses vuedraggable for smooth drag-and-drop from Unknown to weekday columns, with a reset/unlock button on manually-pinned cards.\n\n**Tech Stack:** Python/FastAPI (backend), SQLModel/SQLite (data), Vue 3 + TypeScript + vuedraggable (frontend)\n\n---\n\n### Task 1: Add `weekday_locked` field to data model + migration\n\n**Files:**\n- Modify: `backend/src/module/models/bangumi.py:36-38`\n- Modify: `backend/src/module/database/combine.py:26,99-105`\n\n**Step 1: Add `weekday_locked` field to `Bangumi` model**\n\nIn `backend/src/module/models/bangumi.py`, after the `air_weekday` field (line 38), add:\n\n```python\nweekday_locked: bool = Field(\n    default=False, alias=\"weekday_locked\", title=\"放送星期锁定\"\n)\n```\n\n**Step 2: Add `weekday_locked` to `BangumiUpdate` model**\n\nIn the same file, after `air_weekday` in `BangumiUpdate` (line 79), add:\n\n```python\nweekday_locked: bool = Field(\n    default=False, alias=\"weekday_locked\", title=\"放送星期锁定\"\n)\n```\n\n**Step 3: Add database migration**\n\nIn `backend/src/module/database/combine.py`:\n\n1. Increment `CURRENT_SCHEMA_VERSION` from `8` to `9`\n2. Add migration entry after the existing migration 8:\n\n```python\n(\n    9,\n    \"add weekday_locked column to bangumi\",\n    [\n        \"ALTER TABLE bangumi ADD COLUMN weekday_locked BOOLEAN DEFAULT 0\",\n    ],\n),\n```\n\n3. Add skip-check in `run_migrations()` after the version 8 check:\n\n```python\nif \"bangumi\" in tables and version == 9:\n    columns = [col[\"name\"] for col in inspector.get_columns(\"bangumi\")]\n    if \"weekday_locked\" in columns:\n        needs_run = False\n```\n\n**Step 4: Run backend tests to verify migration**\n\nRun: `cd backend && uv run pytest src/test/ -v -k \"not test_mcp\"`\nExpected: All pass (new column has default, so no breaking changes)\n\n**Step 5: Commit**\n\n```bash\ngit add backend/src/module/models/bangumi.py backend/src/module/database/combine.py\ngit commit -m \"feat(model): add weekday_locked field to bangumi for manual calendar assignment\"\n```\n\n---\n\n### Task 2: Add backend API endpoint for setting weekday\n\n**Files:**\n- Modify: `backend/src/module/api/bangumi.py`\n- Modify: `backend/src/module/database/bangumi.py`\n\n**Step 1: Add `set_weekday` database method**\n\nIn `backend/src/module/database/bangumi.py`, add method to `BangumiDatabase`:\n\n```python\ndef set_weekday(self, _id: int, weekday: int | None) -> bool:\n    \"\"\"Set air_weekday and weekday_locked for manual calendar assignment.\"\"\"\n    bangumi = self.session.get(Bangumi, _id)\n    if not bangumi:\n        return False\n    if weekday is not None:\n        bangumi.air_weekday = weekday\n        bangumi.weekday_locked = True\n    else:\n        bangumi.air_weekday = None\n        bangumi.weekday_locked = False\n    self.session.add(bangumi)\n    self.session.commit()\n    _invalidate_bangumi_cache()\n    logger.debug(\n        \"[Database] Set weekday=%s, locked=%s for bangumi id %s\",\n        weekday,\n        bangumi.weekday_locked,\n        _id,\n    )\n    return True\n```\n\n**Step 2: Add API endpoint**\n\nIn `backend/src/module/api/bangumi.py`, add request model and endpoint:\n\n```python\nclass SetWeekdayRequest(BaseModel):\n    weekday: Optional[int] = None  # 0-6 for Mon-Sun, None to reset\n\n@router.patch(\n    path=\"/{bangumi_id}/weekday\",\n    response_model=APIResponse,\n    dependencies=[Depends(get_current_user)],\n)\nasync def set_weekday(bangumi_id: int, request: SetWeekdayRequest):\n    \"\"\"Manually set the broadcast weekday for a bangumi.\"\"\"\n    if request.weekday is not None and not (0 <= request.weekday <= 6):\n        return JSONResponse(\n            status_code=400,\n            content={\n                \"status\": False,\n                \"msg_en\": \"Weekday must be 0-6 (Mon-Sun) or null.\",\n                \"msg_zh\": \"星期必须是 0-6（周一至周日）或空。\",\n            },\n        )\n    with Database() as db:\n        success = db.bangumi.set_weekday(bangumi_id, request.weekday)\n    if success:\n        action = f\"weekday {request.weekday}\" if request.weekday is not None else \"unknown\"\n        return JSONResponse(\n            status_code=200,\n            content={\n                \"status\": True,\n                \"msg_en\": f\"Set bangumi to {action}.\",\n                \"msg_zh\": f\"已设置放送日为 {action}。\",\n            },\n        )\n    return JSONResponse(\n        status_code=404,\n        content={\n            \"status\": False,\n            \"msg_en\": f\"Bangumi {bangumi_id} not found.\",\n            \"msg_zh\": f\"未找到番剧 {bangumi_id}。\",\n        },\n    )\n```\n\n**Step 3: Modify `refresh_calendar()` to skip locked items**\n\nIn `backend/src/module/manager/torrent.py`, in `refresh_calendar()` method (line 212-213), change:\n\n```python\n# Before:\nif bangumi.deleted:\n    continue\n\n# After:\nif bangumi.deleted or bangumi.weekday_locked:\n    continue\n```\n\n**Step 4: Run tests**\n\nRun: `cd backend && uv run pytest src/test/ -v -k \"not test_mcp\"`\nExpected: All pass\n\n**Step 5: Commit**\n\n```bash\ngit add backend/src/module/api/bangumi.py backend/src/module/database/bangumi.py backend/src/module/manager/torrent.py\ngit commit -m \"feat(api): add PATCH /bangumi/{id}/weekday endpoint and skip locked items in calendar refresh\"\n```\n\n---\n\n### Task 3: Add frontend TypeScript types and API client\n\n**Files:**\n- Modify: `webui/types/bangumi.ts`\n- Modify: `webui/src/api/bangumi.ts`\n\n**Step 1: Add `weekday_locked` to TypeScript types**\n\nIn `webui/types/bangumi.ts`, add to `BangumiRule` interface (after `air_weekday` line 26):\n\n```typescript\nweekday_locked: boolean;\n```\n\nAdd to `ruleTemplate` (after `air_weekday: null` line 65):\n\n```typescript\nweekday_locked: false,\n```\n\n**Step 2: Add API method for setting weekday**\n\nIn `webui/src/api/bangumi.ts`, add method to `apiBangumi`:\n\n```typescript\n/**\n * 手动设置番剧的放送星期\n * @param bangumiId - bangumi 的 id\n * @param weekday - 0-6 for Mon-Sun, null to reset\n */\nasync setWeekday(bangumiId: number, weekday: number | null) {\n  const { data } = await axios.patch<ApiSuccess>(\n    `api/v1/bangumi/${bangumiId}/weekday`,\n    { weekday }\n  );\n  return data;\n},\n```\n\n**Step 3: Commit**\n\n```bash\ngit add webui/types/bangumi.ts webui/src/api/bangumi.ts\ngit commit -m \"feat(webui): add weekday_locked type and setWeekday API client\"\n```\n\n---\n\n### Task 4: Install vuedraggable\n\n**Files:**\n- Modify: `webui/package.json`\n\n**Step 1: Install vuedraggable**\n\n```bash\ncd webui && pnpm add vuedraggable@next\n```\n\n**Step 2: Commit**\n\n```bash\ngit add webui/package.json webui/pnpm-lock.yaml\ngit commit -m \"chore(webui): add vuedraggable dependency for calendar drag-and-drop\"\n```\n\n---\n\n### Task 5: Add i18n strings for drag-and-drop\n\n**Files:**\n- Modify: `webui/src/i18n/en.json`\n- Modify: `webui/src/i18n/zh-CN.json`\n\n**Step 1: Add English i18n strings**\n\nIn `webui/src/i18n/en.json`, inside the `\"calendar\"` object (before the closing `}`), add:\n\n```json\n\"drag_hint\": \"Drag to assign weekday\",\n\"pinned\": \"Manually assigned\",\n\"unpin\": \"Reset to unknown\",\n\"drop_here\": \"Drop here\"\n```\n\n**Step 2: Add Chinese i18n strings**\n\nIn `webui/src/i18n/zh-CN.json`, inside the `\"calendar\"` object, add:\n\n```json\n\"drag_hint\": \"拖拽以设置放送日\",\n\"pinned\": \"手动设置\",\n\"unpin\": \"重置为未知\",\n\"drop_here\": \"拖放到此处\"\n```\n\n**Step 3: Commit**\n\n```bash\ngit add webui/src/i18n/en.json webui/src/i18n/zh-CN.json\ngit commit -m \"feat(i18n): add calendar drag-and-drop strings\"\n```\n\n---\n\n### Task 6: Implement drag-and-drop in calendar.vue (Desktop)\n\n**Files:**\n- Modify: `webui/src/pages/index/calendar.vue`\n\nThis is the main implementation task. The calendar.vue file needs:\n\n1. **Import vuedraggable** and add drag-and-drop functionality\n2. **Wrap Unknown section cards** in a `<draggable>` component as the drag source\n3. **Wrap each weekday column** in a `<draggable>` component as drop targets\n4. **Handle the `onChange` event** to call the API when a card is dropped\n5. **Add reset/unpin button** on cards with `weekday_locked === true`\n6. **Add visual pin indicator** on locked cards\n7. **Add drop-zone highlighting** CSS for when dragging over a weekday column\n\nKey implementation details:\n\n- vuedraggable uses `group` option to allow cross-list dragging\n- Unknown list: `group: { name: 'calendar', pull: true, put: false }` (can pull from, cannot put into)\n- Weekday lists: `group: { name: 'calendar', pull: false, put: true }` (can put into, cannot pull from)\n- On `change` event with `added` property, extract the bangumi group's primary ID and target day index, then call `apiBangumi.setWeekday(id, dayIndex)`\n- After API success, update the local bangumi store to reflect the change\n- The reset button calls `apiBangumi.setWeekday(id, null)` and refreshes store\n\nCSS additions:\n- `.calendar-column--drop-active`: highlight border when dragging over\n- `.calendar-card--pinned`: pin icon overlay\n- `.calendar-unpin-btn`: reset button style\n- `.sortable-ghost`: semi-transparent placeholder during drag\n- `.sortable-drag`: shadow on the card being dragged\n\n**Step 1: Implement the full calendar.vue changes**\n\n(See implementation — this is a substantial template + script change)\n\n**Step 2: Test manually in dev server**\n\n```bash\ncd webui && pnpm dev\n```\n\nVerify:\n- Unknown cards can be dragged to weekday columns\n- Weekday column highlights on dragover\n- Dropped cards show pin icon\n- Pin icon has working reset button\n- Cards with weekday_locked show pin in weekday columns\n- Mobile view still works (no drag on mobile — touch has different UX)\n\n**Step 3: Commit**\n\n```bash\ngit add webui/src/pages/index/calendar.vue\ngit commit -m \"feat(calendar): add drag-and-drop from Unknown to weekday columns with pin/reset\"\n```\n\n---\n\n### Task 7: Update bangumi store to handle weekday_locked\n\n**Files:**\n- Modify: `webui/src/store/bangumi.ts` (if needed for reactive updates after setWeekday)\n\n**Step 1: Add `setWeekday` action to store**\n\nAdd a store action that calls the API and updates the local bangumi array reactively:\n\n```typescript\nasync function setWeekday(bangumiId: number, weekday: number | null) {\n  await apiBangumi.setWeekday(bangumiId, weekday);\n  // Update local state\n  const item = bangumi.value?.find((b) => b.id === bangumiId);\n  if (item) {\n    item.air_weekday = weekday;\n    item.weekday_locked = weekday !== null;\n  }\n}\n```\n\n**Step 2: Commit**\n\n```bash\ngit add webui/src/store/bangumi.ts\ngit commit -m \"feat(store): add setWeekday action for calendar drag-and-drop\"\n```\n\n---\n\n### Task 8: Final integration test and type check\n\n**Step 1: Run type check**\n\n```bash\ncd webui && pnpm test:build\n```\n\n**Step 2: Run backend tests**\n\n```bash\ncd backend && uv run pytest src/test/ -v -k \"not test_mcp\"\n```\n\n**Step 3: Run lint**\n\n```bash\ncd webui && pnpm lint\ncd backend && uv run ruff check src\n```\n\n**Step 4: Fix any issues and commit**\n\n```bash\ngit add -A\ngit commit -m \"fix: resolve type and lint issues from calendar drag-and-drop feature\"\n```\n"
  },
  {
    "path": "docs/resource/docker-compose/AutoBangumi/docker-compose.yml",
    "content": "version: \"3.4\"\n\nservices:\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    network_mode: bridge\n    ports:\n      - \"7892:7892\"\n    restart: unless-stopped\n    dns:\n      - 223.5.5.5\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022"
  },
  {
    "path": "docs/resource/docker-compose/qBittorrent+AutoBangumi/docker-compose.yml",
    "content": "version: \"3.4\"\nservices:\n  qbittorrent:\n    container_name: qbittorrent\n    image: linuxserver/qbittorrent\n    hostname: qbittorrent\n    environment:\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - TZ=Asia/Shanghai\n    volumes:\n      - ./qb_config:/config\n      - <your_anime_path>:/downloads # 注意 修改此处为自己存放动漫的目录,ab 内下载路径填写downloads\n    network_mode: bridge\n    restart: unless-stopped\n\n  AutoBangumi:\n    image: \"ghcr.io/estrellaxd/auto_bangumi:latest\"\n    container_name: AutoBangumi\n    depends_on:\n      - qbittorrent\n    volumes:\n      - ./config:/app/config\n      - ./data:/app/data\n    network_mode: bridge\n    dns:\n      - 223.5.5.5\n    restart: unless-stopped\n    environment:\n      - TZ=Asia/Shanghai\n      - PGID=$(id -g)\n      - PUID=$(id -u)\n      - UMASK=022"
  },
  {
    "path": "docs/resource/unraid.xml",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\n<Container version=\"2\">\n  <Name Default=\"AutoBangumi\">AutoBangumi</Name>\n  <Repository>estrellaxd/auto_bangumi:latest</Repository>\n  <Registry>https://registry.hub.docker.com/r/estrellaxd/auto_bangumi</Registry>\n  <Network Default=\"bridge\">bridge</Network>\n  <Category>MediaServer:Video Tools Productivity</Category>\n  <MyIP/>\n  <Shell>sh</Shell>\n  <Privileged>false</Privileged>\n  <Support>https://github.com/EstrellaXD/Auto_Bangumi/issues</Support>\n  <Project/>\n  <Overview>\n    AutoBangumi 是基于 Mikan Project、qBittorrent 的全自动追番整理下载工具。只需要在 Mikan Project 上订阅番剧，就可以全自动追番。并且整理完成的名称和目录可以直接被 Plex、Jellyfin 等媒体库软件识别，无需二次刮削。\n  </Overview>\n  <Category/>\n  <WebUI>http://[IP]:[PORT:7892]/</WebUI>\n  <TemplateURL/>\n  <Icon>https://quantil.jsdelivr.net/gh/9bingyin/static@main/images/AutoBangumi.png</Icon>\n  <ExtraParams>--dns 8.8.8.8 --dns 223.5.5.5</ExtraParams>\n  <PostArgs/>\n  <CPUset/>\n  <DonateText/>\n  <DonateLink/>\n  <Requires/>\n  <Config Name=\"WebUI\" Target=\"7892\" Default=\"7892\" Mode=\"tcp\" Description=\"\" Type=\"Port\" Display=\"always\" Required=\"false\" Mask=\"false\">7892</Config>\n  <Config Name=\"Config\" Target=\"/app/config\" Default=\"\" Mode=\"rw\" Description=\"\" Type=\"Path\" Display=\"always\" Required=\"false\" Mask=\"false\">/mnt/user/appdata/AutoBangumi/config</Config>\n  <Config Name=\"Data\" Target=\"/app/data\" Default=\"\" Mode=\"rw\" Description=\"\" Type=\"Path\" Display=\"always\" Required=\"false\" Mask=\"false\">/mnt/user/appdata/AutoBangumi/data</Config>\n  <Config Name=\"PUID\" Target=\"PUID\" Default=\"99\" Mode=\"\" Description=\"\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"false\"/>\n  <Config Name=\"PGID\" Target=\"PGID\" Default=\"100\" Mode=\"\" Description=\"\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"false\"/>\n  <Config Name=\"UMASK\" Target=\"UMASK\" Default=\"022\" Mode=\"\" Description=\"\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"false\"/>\n  <Config Name=\"TZ\" Target=\"TZ\" Default=\"Asia/Shanghai\" Mode=\"\" Description=\"\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"false\"/>\n</Container>"
  },
  {
    "path": "docs/tsconfig.json",
    "content": "{\n  \"extends\": \"@vue/tsconfig/tsconfig.json\",\n  \"compilerOptions\": {\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\"\n  }\n}\n"
  },
  {
    "path": "docs/vercel.json",
    "content": "{\n  \"buildCommand\": \"pnpm docs:build\",\n  \"outputDirectory\": \".vitepress/dist\",\n  \"installCommand\": \"pnpm install\",\n  \"framework\": \"vitepress\"\n}\n"
  },
  {
    "path": "entrypoint.sh",
    "content": "#!/bin/bash\n# shellcheck shell=bash\n\numask ${UMASK}\n\nif [ -f /config/bangumi.json ]; then\n    mv /config/bangumi.json /app/data/bangumi.json\nfi\n\ngroupmod -o -g \"${PGID}\" ab\nusermod -o -u \"${PUID}\" ab\n\nchown ab:ab -R /app /home/ab\n\nexec su-exec \"${PUID}:${PGID}\" python main.py"
  },
  {
    "path": "scripts/generate-beta-notes.sh",
    "content": "#!/bin/bash\n# Generate release notes for beta versions\n# Usage: ./generate-beta-notes.sh <new-tag> [previous-tag]\n\nset -e\n\nNEW_TAG=\"${1:?Usage: $0 <new-tag> [previous-tag]}\"\nREPO_URL=\"https://github.com/EstrellaXD/Auto_Bangumi\"\n\n# Auto-detect previous beta tag if not provided\nif [ -z \"$2\" ]; then\n    PREV_TAG=$(git tag --sort=-v:refname | grep -E \"^${NEW_TAG%-*}\" | grep beta | head -2 | tail -1)\nelse\n    PREV_TAG=\"$2\"\nfi\n\nif [ -z \"$PREV_TAG\" ] || [ \"$PREV_TAG\" = \"$NEW_TAG\" ]; then\n    echo \"## Initial Beta Release\"\n    echo \"\"\n    echo \"First beta for this version series.\"\n    exit 0\nfi\n\necho \"## Changes since ${PREV_TAG#v}\"\necho \"\"\n\n# Get commits and categorize\nFEATS=$(git log --oneline \"$PREV_TAG..$NEW_TAG\" --no-merges --grep=\"^feat\" --format=\"- %s\" 2>/dev/null || true)\nFIXES=$(git log --oneline \"$PREV_TAG..$NEW_TAG\" --no-merges --grep=\"^fix\" --format=\"- %s\" 2>/dev/null || true)\nPERFS=$(git log --oneline \"$PREV_TAG..$NEW_TAG\" --no-merges --grep=\"^perf\" --format=\"- %s\" 2>/dev/null || true)\nDOCS=$(git log --oneline \"$PREV_TAG..$NEW_TAG\" --no-merges --grep=\"^docs\" --format=\"- %s\" 2>/dev/null || true)\nOTHERS=$(git log --oneline \"$PREV_TAG..$NEW_TAG\" --no-merges --grep=\"^chore\\|^refactor\\|^test\\|^ci\" --format=\"- %s\" 2>/dev/null || true)\n\nif [ -n \"$FEATS\" ]; then\n    echo \"### Features\"\n    echo \"$FEATS\"\n    echo \"\"\nfi\n\nif [ -n \"$FIXES\" ]; then\n    echo \"### Fixes\"\n    echo \"$FIXES\"\n    echo \"\"\nfi\n\nif [ -n \"$PERFS\" ]; then\n    echo \"### Performance\"\n    echo \"$PERFS\"\n    echo \"\"\nfi\n\nif [ -n \"$DOCS\" ]; then\n    echo \"### Documentation\"\n    echo \"$DOCS\"\n    echo \"\"\nfi\n\n# Don't show chore/refactor/test in release notes (too noisy)\n\necho \"---\"\necho \"**Full Changelog**: ${REPO_URL}/compare/${PREV_TAG}...${NEW_TAG}\"\n"
  },
  {
    "path": "webui/.eslintignore",
    "content": "/build\n/dist\n/dev-dist\n"
  },
  {
    "path": "webui/.eslintrc.json",
    "content": "{\n  \"extends\": [\"@antfu\", \"prettier\", \"plugin:storybook/recommended\"],\n  \"rules\": {\n    \"antfu/if-newline\": [\"off\"],\n    \"no-console\": [\"off\"],\n    \"vue/custom-event-name-casing\": [\"off\"]\n  }\n}\n"
  },
  {
    "path": "webui/.husky/pre-commit",
    "content": "#!/usr/bin/env sh\n. \"$(dirname -- \"$0\")/_/husky.sh\"\n\ncd ./webui && pnpm run lint:fix && pnpm run format\n"
  },
  {
    "path": "webui/.neoconf.json",
    "content": "{\n  \"volar\": { \"enable\": true }\n}\n"
  },
  {
    "path": "webui/.npmrc",
    "content": "public-hoist-pattern[]=@vue/runtime-core\npublic-hoist-pattern[]=*eslint*\npublic-hoist-pattern[]=*prettier*\n"
  },
  {
    "path": "webui/.prettierignore",
    "content": "/build\n/dist\n/dev-dist\n/pnpm-lock.yaml\nauto-imports.d.ts\ncomponents.d.ts\nrouter-type.d.ts\n"
  },
  {
    "path": "webui/.prettierrc.json",
    "content": "{\n  \"singleQuote\": true\n}\n"
  },
  {
    "path": "webui/.storybook/main.ts",
    "content": "import type { StorybookConfig } from '@storybook/vue3-vite';\nimport Unocss from 'unocss/vite';\n\nconst config: StorybookConfig = {\n  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],\n  addons: [\n    '@storybook/addon-links',\n    '@storybook/addon-essentials',\n    '@storybook/addon-interactions',\n  ],\n  framework: {\n    name: '@storybook/vue3-vite',\n    options: {},\n  },\n  docs: {\n    autodocs: 'tag',\n  },\n  viteFinal(config) {\n    config.plugins?.push(Unocss());\n    // Add other configuration here depending on your use case\n    return config;\n  },\n};\nexport default config;\n"
  },
  {
    "path": "webui/.storybook/preview.ts",
    "content": "import type { Preview } from '@storybook/vue3';\nimport '@unocss/reset/tailwind-compat.css';\nimport 'uno.css';\n\nconst preview: Preview = {\n  parameters: {\n    actions: { argTypesRegex: '^on[A-Z].*' },\n    controls: {\n      matchers: {\n        color: /(background|color)$/i,\n        date: /Date$/,\n      },\n    },\n  },\n};\n\nexport default preview;\n"
  },
  {
    "path": "webui/.vscode/extensions.json",
    "content": "{\n  \"recommendations\": [\"Vue.volar\"]\n}\n"
  },
  {
    "path": "webui/.vscode/settings.json",
    "content": "{\n  \"i18n-ally.localesPaths\": [\"src/i18n\"],\n  \"commentTranslate.targetLanguage\": \"zh-CN\",\n  \"i18n-ally.sourceLanguage\": \"zh-CN\",\n  \"typescript.tsdk\": \"node_modules/typescript/lib\",\n  \"i18n-ally.keystyle\": \"nested\"\n}\n"
  },
  {
    "path": "webui/LICENSE",
    "content": "MIT License\n\nCopyright (c) 2022 Rewrite0\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "webui/README.md",
    "content": "# Auto_Bangumi_WebUI\n\n使用 Vue3 + TypeScript 构建的 [Auto_Bangumi](https://github.com/EstrellaXD/Auto_Bangumi) 的 WebUI\n"
  },
  {
    "path": "webui/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\" />\n    <meta name=\"robots\" content=\"noindex, nofollow\" />\n    <meta name=\"theme-color\" content=\"#FAFAFA\" />\n    <link rel=\"icon\" href=\"/images/logo.svg\" />\n    <link rel=\"apple-touch-icon\" href=\"/images/apple-touch-icon.png\" />\n    <meta name=\"description\" content=\"Automated Bangumi Download Tool\" />\n    <link rel=\"preconnect\" href=\"https://fonts.googleapis.com\" />\n    <link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin />\n    <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap\" />\n    <title>AutoBangumi</title>\n    <script>\n      // Apply dark mode before render to prevent flash\n      (function() {\n        const saved = localStorage.getItem('theme');\n        const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;\n        if (saved === 'dark' || (!saved && prefersDark)) {\n          document.documentElement.classList.add('dark');\n        }\n      })();\n    </script>\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script type=\"module\" src=\"/src/main.ts\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "webui/package.json",
    "content": "{\n  \"name\": \"ab-webui\",\n  \"type\": \"module\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"packageManager\": \"pnpm@9.11.0+sha512.0a203ffaed5a3f63242cd064c8fb5892366c103e328079318f78062f24ea8c9d50bc6a47aa3567cabefd824d170e78fa2745ed1f16b132e16436146b7688f19b\",\n  \"scripts\": {\n    \"prepare\": \"cd .. && husky install ./webui/.husky\",\n    \"test:build\": \"vue-tsc --noEmit\",\n    \"build\": \"vite build\",\n    \"dev\": \"vite\",\n    \"format\": \"prettier --write .\",\n    \"format:check\": \"prettier --check .\",\n    \"lint\": \"eslint .\",\n    \"lint:fix\": \"eslint . --fix\",\n    \"preview\": \"vite preview\",\n    \"test\": \"vitest\",\n    \"storybook\": \"storybook dev -p 6006\",\n    \"build-storybook\": \"storybook build\",\n    \"generate-pwa-assets\": \"pwa-assets-generator --preset minimal public/images/logo.svg\"\n  },\n  \"dependencies\": {\n    \"@headlessui/vue\": \"^1.7.23\",\n    \"@vueuse/components\": \"^10.11.1\",\n    \"@vueuse/core\": \"^10.11.1\",\n    \"axios\": \"^0.27.2\",\n    \"naive-ui\": \"^2.39.0\",\n    \"pinia\": \"^2.2.2\",\n    \"vue\": \"^3.5.8\",\n    \"vue-i18n\": \"^9.14.0\",\n    \"vue-inline-svg\": \"^3.1.4\",\n    \"vue-router\": \"^4.4.5\",\n    \"vuedraggable\": \"^4.1.0\"\n  },\n  \"devDependencies\": {\n    \"@antfu/eslint-config\": \"^0.38.6\",\n    \"@icon-park/vue-next\": \"^1.4.2\",\n    \"@intlify/unplugin-vue-i18n\": \"^0.11.0\",\n    \"@storybook/addon-essentials\": \"^7.6.20\",\n    \"@storybook/addon-interactions\": \"^7.6.20\",\n    \"@storybook/addon-links\": \"^7.6.20\",\n    \"@storybook/blocks\": \"^7.6.20\",\n    \"@storybook/testing-library\": \"0.0.14-next.2\",\n    \"@storybook/vue3\": \"^7.6.20\",\n    \"@storybook/vue3-vite\": \"^7.6.20\",\n    \"@types/node\": \"^18.19.50\",\n    \"@unocss/preset-attributify\": \"^0.55.7\",\n    \"@unocss/preset-rem-to-px\": \"^0.51.13\",\n    \"@unocss/reset\": \"^0.51.13\",\n    \"@vitejs/plugin-vue\": \"^4.6.2\",\n    \"@vitejs/plugin-vue-jsx\": \"^3.1.0\",\n    \"@vue/runtime-dom\": \"^3.5.8\",\n    \"@vue/test-utils\": \"^2.4.6\",\n    \"eslint\": \"^8.57.1\",\n    \"eslint-config-prettier\": \"^8.10.0\",\n    \"eslint-plugin-storybook\": \"^0.6.15\",\n    \"happy-dom\": \"^12.10.3\",\n    \"husky\": \"^8.0.3\",\n    \"prettier\": \"^2.8.8\",\n    \"radash\": \"^12.1.0\",\n    \"sass-embedded\": \"^1.79.3\",\n    \"storybook\": \"^7.6.20\",\n    \"typescript\": \"^4.9.5\",\n    \"unocss\": \"^0.51.13\",\n    \"unplugin-auto-import\": \"^0.10.3\",\n    \"unplugin-vue-components\": \"^0.24.1\",\n    \"unplugin-vue-router\": \"^0.6.4\",\n    \"vite\": \"^4.5.5\",\n    \"vite-plugin-pwa\": \"^0.16.7\",\n    \"vitest\": \"^0.30.1\",\n    \"vue-tsc\": \"^1.8.27\"\n  }\n}\n"
  },
  {
    "path": "webui/public/robots.txt",
    "content": "# robots.txt generated at http://tool.chinaz.com/robots/ \nUser-agent: Baiduspider\nDisallow: /\nUser-agent: Sosospider\nDisallow: /\nUser-agent: sogou spider\nDisallow: /\nUser-agent: YodaoBot\nDisallow: /\nUser-agent: Googlebot\nDisallow: /\nUser-agent: Bingbot\nDisallow: /\nUser-agent: Slurp\nDisallow: /\nUser-agent: Teoma\nDisallow: /\nUser-agent: ia_archiver\nDisallow: /\nUser-agent: twiceler\nDisallow: /\nUser-agent: MSNBot\nDisallow: /\nUser-agent: Scrubby\nDisallow: /\nUser-agent: Robozilla\nDisallow: /\nUser-agent: Gigabot\nDisallow: /\nUser-agent: googlebot-image\nDisallow: /\nUser-agent: googlebot-mobile\nDisallow: /\nUser-agent: yahoo-mmcrawler\nDisallow: /\nUser-agent: yahoo-blogs/v3.9\nDisallow: /\nUser-agent: psbot\nDisallow: /"
  },
  {
    "path": "webui/src/App.vue",
    "content": "<script setup lang=\"ts\">\nimport { computed } from 'vue';\nimport { type GlobalThemeOverrides, NConfigProvider, NMessageProvider, darkTheme } from 'naive-ui';\n\nconst { isDark } = useDarkMode();\nconst { refresh, isLoggedIn } = useAuth();\n\nif (isLoggedIn.value) {\n  refresh();\n}\n\nconst lightOverrides: GlobalThemeOverrides = {\n  common: {\n    primaryColor: '#6C4AB6',\n    primaryColorHover: '#563A92',\n    primaryColorPressed: '#4A3291',\n    bodyColor: '#FAFAFA',\n    cardColor: '#FFFFFF',\n    borderColor: '#E2E8F0',\n    textColorBase: '#1E293B',\n    textColor1: '#1E293B',\n    textColor2: '#64748B',\n    textColor3: '#94A3B8',\n  },\n  Spin: {\n    color: '#6C4AB6',\n  },\n  DataTable: {\n    thColor: 'transparent',\n    thColorHover: 'transparent',\n    tdColorHover: 'var(--color-surface-hover)',\n    borderColor: 'var(--color-border)',\n  },\n  Checkbox: {\n    colorChecked: '#6C4AB6',\n    borderFocus: '#6C4AB6',\n    boxShadowFocus: '0 0 0 2px rgba(108, 74, 182, 0.2)',\n    borderChecked: '1px solid #6C4AB6',\n  },\n};\n\nconst darkOverrides: GlobalThemeOverrides = {\n  common: {\n    primaryColor: '#8B6CC7',\n    primaryColorHover: '#A78BDB',\n    primaryColorPressed: '#7B5CB7',\n    bodyColor: '#0F172A',\n    cardColor: '#1E293B',\n    borderColor: '#334155',\n    textColorBase: '#F1F5F9',\n    textColor1: '#F1F5F9',\n    textColor2: '#94A3B8',\n    textColor3: '#64748B',\n  },\n  Spin: {\n    color: '#8B6CC7',\n  },\n  DataTable: {\n    thColor: 'transparent',\n    thColorHover: 'transparent',\n    tdColorHover: 'var(--color-surface-hover)',\n    borderColor: 'var(--color-border)',\n  },\n  Checkbox: {\n    colorChecked: '#8B6CC7',\n    borderFocus: '#8B6CC7',\n    boxShadowFocus: '0 0 0 2px rgba(139, 108, 199, 0.2)',\n    borderChecked: '1px solid #8B6CC7',\n  },\n};\n\nconst themeOverrides = computed(() => isDark.value ? darkOverrides : lightOverrides);\nconst naiveTheme = computed(() => isDark.value ? darkTheme : null);\n</script>\n\n<template>\n  <Suspense>\n    <NConfigProvider :theme=\"naiveTheme\" :theme-overrides=\"themeOverrides\">\n      <NMessageProvider>\n        <RouterView></RouterView>\n      </NMessageProvider>\n    </NConfigProvider>\n  </Suspense>\n</template>\n\n<style lang=\"scss\">\n@import './style/var';\n@import './style/transition';\n@import './style/global';\n</style>\n"
  },
  {
    "path": "webui/src/api/__tests__/auth.test.ts",
    "content": "/**\n * Tests for Auth API logic\n * Note: These tests focus on the data structures and transformations\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { mockLoginSuccess } from '@/test/mocks/api';\n\ndescribe('Auth API Data Structures', () => {\n  describe('login response', () => {\n    it('should have access_token and token_type', () => {\n      expect(mockLoginSuccess.access_token).toBeDefined();\n      expect(mockLoginSuccess.token_type).toBe('bearer');\n    });\n\n    it('should have string access_token', () => {\n      expect(typeof mockLoginSuccess.access_token).toBe('string');\n      expect(mockLoginSuccess.access_token.length).toBeGreaterThan(0);\n    });\n  });\n\n  describe('login request formation', () => {\n    it('should create URLSearchParams with username and password', () => {\n      const username = 'testuser';\n      const password = 'testpassword';\n\n      const formData = new URLSearchParams({\n        username,\n        password,\n      });\n\n      expect(formData.toString()).toContain('username=testuser');\n      expect(formData.toString()).toContain('password=testpassword');\n    });\n\n    it('should properly encode special characters in credentials', () => {\n      const username = 'test@user.com';\n      const password = 'pass&word=123';\n\n      const formData = new URLSearchParams({\n        username,\n        password,\n      });\n\n      expect(formData.get('username')).toBe('test@user.com');\n      expect(formData.get('password')).toBe('pass&word=123');\n    });\n  });\n\n  describe('update request formation', () => {\n    it('should create update payload with username and password', () => {\n      const username = 'newuser';\n      const password = 'newpassword123';\n\n      const payload = {\n        username,\n        password,\n      };\n\n      expect(payload.username).toBe('newuser');\n      expect(payload.password).toBe('newpassword123');\n    });\n  });\n\n  describe('API endpoint paths', () => {\n    const AUTH_ENDPOINTS = {\n      login: 'api/v1/auth/login',\n      logout: 'api/v1/auth/logout',\n      refresh: 'api/v1/auth/refresh_token',\n      update: 'api/v1/auth/update',\n    };\n\n    it('should have correct login endpoint', () => {\n      expect(AUTH_ENDPOINTS.login).toBe('api/v1/auth/login');\n    });\n\n    it('should have correct logout endpoint', () => {\n      expect(AUTH_ENDPOINTS.logout).toBe('api/v1/auth/logout');\n    });\n\n    it('should have correct refresh endpoint', () => {\n      expect(AUTH_ENDPOINTS.refresh).toBe('api/v1/auth/refresh_token');\n    });\n\n    it('should have correct update endpoint', () => {\n      expect(AUTH_ENDPOINTS.update).toBe('api/v1/auth/update');\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/api/__tests__/bangumi.test.ts",
    "content": "/**\n * Tests for Bangumi API data transformation logic\n * Note: These tests focus on the filter/rss_link string<->array transformations\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  mockBangumiAPI,\n  mockBangumiRule,\n} from '@/test/mocks/api';\n\ndescribe('Bangumi API Logic', () => {\n  describe('getAll transformation (string to array)', () => {\n    // This transformation happens when receiving data from API\n    const transformApiResponse = <T extends { filter: string; rss_link: string }>(item: T) => ({\n      ...item,\n      filter: item.filter.split(','),\n      rss_link: item.rss_link.split(','),\n    });\n\n    it('should transform filter string to array', () => {\n      const apiData = { ...mockBangumiAPI, filter: '720', rss_link: 'url1' };\n      const result = transformApiResponse(apiData);\n\n      expect(Array.isArray(result.filter)).toBe(true);\n      expect(result.filter).toEqual(['720']);\n    });\n\n    it('should handle empty filter string', () => {\n      const apiData = { ...mockBangumiAPI, filter: '', rss_link: '' };\n      const result = transformApiResponse(apiData);\n\n      expect(result.filter).toEqual(['']);\n      expect(result.rss_link).toEqual(['']);\n    });\n\n    it('should handle multiple comma-separated values', () => {\n      const apiData = {\n        ...mockBangumiAPI,\n        filter: '720,1080,480',\n        rss_link: 'url1,url2,url3',\n      };\n      const result = transformApiResponse(apiData);\n\n      expect(result.filter).toEqual(['720', '1080', '480']);\n      expect(result.rss_link).toEqual(['url1', 'url2', 'url3']);\n    });\n\n    it('should preserve other fields during transformation', () => {\n      const apiData = {\n        ...mockBangumiAPI,\n        id: 42,\n        title_raw: 'Test Title',\n        filter: '720',\n        rss_link: 'url1',\n      };\n      const result = transformApiResponse(apiData);\n\n      expect(result.id).toBe(42);\n      expect(result.title_raw).toBe('Test Title');\n    });\n  });\n\n  describe('updateRule transformation (array to string)', () => {\n    // This transformation happens when sending data to API\n    const transformForUpdate = (rule: { id: number; filter: string[]; rss_link: string[] }) => {\n      const { id, ...rest } = rule;\n      return {\n        ...rest,\n        filter: rule.filter.join(','),\n        rss_link: rule.rss_link.join(','),\n      };\n    };\n\n    it('should transform filter array to string', () => {\n      const rule = { ...mockBangumiRule, filter: ['720'], rss_link: ['url1'] };\n      const result = transformForUpdate(rule);\n\n      expect(typeof result.filter).toBe('string');\n      expect(result.filter).toBe('720');\n    });\n\n    it('should join multiple filter values with commas', () => {\n      const rule = {\n        ...mockBangumiRule,\n        filter: ['720', '1080', '480'],\n        rss_link: ['url1', 'url2'],\n      };\n      const result = transformForUpdate(rule);\n\n      expect(result.filter).toBe('720,1080,480');\n      expect(result.rss_link).toBe('url1,url2');\n    });\n\n    it('should omit id from update payload', () => {\n      const rule = { ...mockBangumiRule, id: 123 };\n      const result = transformForUpdate(rule);\n\n      expect(result).not.toHaveProperty('id');\n    });\n\n    it('should handle empty arrays', () => {\n      const rule = { ...mockBangumiRule, filter: [], rss_link: [] };\n      const result = transformForUpdate(rule);\n\n      expect(result.filter).toBe('');\n      expect(result.rss_link).toBe('');\n    });\n  });\n\n  describe('deleteRule logic', () => {\n    it('should use single endpoint for single ID', () => {\n      const id = 1;\n      const isArray = Array.isArray(id);\n\n      expect(isArray).toBe(false);\n      // Single ID should use: `api/v1/bangumi/delete/${id}`\n    });\n\n    it('should use many endpoint for array of IDs', () => {\n      const ids = [1, 2, 3];\n      const isArray = Array.isArray(ids);\n\n      expect(isArray).toBe(true);\n      // Array should use: `api/v1/bangumi/delete/many`\n    });\n  });\n\n  describe('API endpoint paths', () => {\n    const BANGUMI_ENDPOINTS = {\n      getAll: 'api/v1/bangumi/get/all',\n      getOne: (id: number) => `api/v1/bangumi/get/${id}`,\n      update: (id: number) => `api/v1/bangumi/update/${id}`,\n      delete: (id: number) => `api/v1/bangumi/delete/${id}`,\n      deleteMany: 'api/v1/bangumi/delete/many',\n      disable: (id: number) => `api/v1/bangumi/disable/${id}`,\n      disableMany: 'api/v1/bangumi/disable/many',\n      enable: (id: number) => `api/v1/bangumi/enable/${id}`,\n      archive: (id: number) => `api/v1/bangumi/archive/${id}`,\n      unarchive: (id: number) => `api/v1/bangumi/unarchive/${id}`,\n      resetAll: 'api/v1/bangumi/reset/all',\n      detectOffset: 'api/v1/bangumi/detect-offset',\n      needsReview: 'api/v1/bangumi/needs-review',\n    };\n\n    it('should generate correct getOne endpoint', () => {\n      expect(BANGUMI_ENDPOINTS.getOne(42)).toBe('api/v1/bangumi/get/42');\n    });\n\n    it('should generate correct update endpoint', () => {\n      expect(BANGUMI_ENDPOINTS.update(42)).toBe('api/v1/bangumi/update/42');\n    });\n\n    it('should have correct static endpoints', () => {\n      expect(BANGUMI_ENDPOINTS.getAll).toBe('api/v1/bangumi/get/all');\n      expect(BANGUMI_ENDPOINTS.deleteMany).toBe('api/v1/bangumi/delete/many');\n      expect(BANGUMI_ENDPOINTS.needsReview).toBe('api/v1/bangumi/needs-review');\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/api/__tests__/rss.test.ts",
    "content": "/**\n * Tests for RSS API logic\n * Note: These tests focus on data structures and endpoint paths\n */\n\nimport { describe, it, expect } from 'vitest';\nimport {\n  mockRSSItem,\n  mockRSSList,\n} from '@/test/mocks/api';\n\ndescribe('RSS API Logic', () => {\n  describe('RSS data structure', () => {\n    it('should have required RSS fields', () => {\n      expect(mockRSSItem).toHaveProperty('id');\n      expect(mockRSSItem).toHaveProperty('name');\n      expect(mockRSSItem).toHaveProperty('url');\n      expect(mockRSSItem).toHaveProperty('enabled');\n    });\n\n    it('should have correct field types', () => {\n      expect(typeof mockRSSItem.id).toBe('number');\n      expect(typeof mockRSSItem.name).toBe('string');\n      expect(typeof mockRSSItem.url).toBe('string');\n      expect(typeof mockRSSItem.enabled).toBe('boolean');\n    });\n  });\n\n  describe('RSS list operations', () => {\n    it('should handle empty list', () => {\n      const emptyList: typeof mockRSSList = [];\n      expect(emptyList.length).toBe(0);\n    });\n\n    it('should be able to filter enabled feeds', () => {\n      const enabled = mockRSSList.filter((rss) => rss.enabled);\n      expect(enabled.length).toBeGreaterThanOrEqual(0);\n    });\n\n    it('should be able to filter disabled feeds', () => {\n      const disabled = mockRSSList.filter((rss) => !rss.enabled);\n      expect(disabled.length).toBeGreaterThanOrEqual(0);\n    });\n  });\n\n  describe('batch operations data format', () => {\n    it('should format deleteMany as array of IDs', () => {\n      const idsToDelete = [1, 2, 3];\n      expect(Array.isArray(idsToDelete)).toBe(true);\n      expect(idsToDelete).toEqual([1, 2, 3]);\n    });\n\n    it('should format disableMany as array of IDs', () => {\n      const idsToDisable = [1, 2];\n      expect(Array.isArray(idsToDisable)).toBe(true);\n      expect(idsToDisable).toEqual([1, 2]);\n    });\n\n    it('should format enableMany as array of IDs', () => {\n      const idsToEnable = [1, 2, 3];\n      expect(Array.isArray(idsToEnable)).toBe(true);\n      expect(idsToEnable).toEqual([1, 2, 3]);\n    });\n  });\n\n  describe('API endpoint paths', () => {\n    const RSS_ENDPOINTS = {\n      get: 'api/v1/rss',\n      add: 'api/v1/rss/add',\n      delete: (id: number) => `api/v1/rss/delete/${id}`,\n      deleteMany: 'api/v1/rss/delete/many',\n      disable: (id: number) => `api/v1/rss/disable/${id}`,\n      disableMany: 'api/v1/rss/disable/many',\n      update: (id: number) => `api/v1/rss/update/${id}`,\n      enableMany: 'api/v1/rss/enable/many',\n      refreshAll: 'api/v1/rss/refresh/all',\n      refresh: (id: number) => `api/v1/rss/refresh/${id}`,\n      getTorrent: (id: number) => `api/v1/rss/torrent/${id}`,\n    };\n\n    it('should have correct base RSS endpoint', () => {\n      expect(RSS_ENDPOINTS.get).toBe('api/v1/rss');\n    });\n\n    it('should have correct add endpoint', () => {\n      expect(RSS_ENDPOINTS.add).toBe('api/v1/rss/add');\n    });\n\n    it('should generate correct delete endpoint for ID', () => {\n      expect(RSS_ENDPOINTS.delete(1)).toBe('api/v1/rss/delete/1');\n      expect(RSS_ENDPOINTS.delete(42)).toBe('api/v1/rss/delete/42');\n    });\n\n    it('should have correct deleteMany endpoint', () => {\n      expect(RSS_ENDPOINTS.deleteMany).toBe('api/v1/rss/delete/many');\n    });\n\n    it('should generate correct disable endpoint for ID', () => {\n      expect(RSS_ENDPOINTS.disable(1)).toBe('api/v1/rss/disable/1');\n    });\n\n    it('should have correct batch operation endpoints', () => {\n      expect(RSS_ENDPOINTS.disableMany).toBe('api/v1/rss/disable/many');\n      expect(RSS_ENDPOINTS.enableMany).toBe('api/v1/rss/enable/many');\n    });\n\n    it('should generate correct update endpoint for ID', () => {\n      expect(RSS_ENDPOINTS.update(1)).toBe('api/v1/rss/update/1');\n    });\n\n    it('should have correct refresh endpoints', () => {\n      expect(RSS_ENDPOINTS.refreshAll).toBe('api/v1/rss/refresh/all');\n      expect(RSS_ENDPOINTS.refresh(1)).toBe('api/v1/rss/refresh/1');\n    });\n\n    it('should generate correct getTorrent endpoint for ID', () => {\n      expect(RSS_ENDPOINTS.getTorrent(1)).toBe('api/v1/rss/torrent/1');\n    });\n  });\n\n  describe('update payload', () => {\n    it('should include all RSS fields in update', () => {\n      const updatedRSS = { ...mockRSSItem, name: 'Updated Feed' };\n\n      expect(updatedRSS.id).toBe(mockRSSItem.id);\n      expect(updatedRSS.name).toBe('Updated Feed');\n      expect(updatedRSS.url).toBe(mockRSSItem.url);\n      expect(updatedRSS.enabled).toBe(mockRSSItem.enabled);\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/api/auth.ts",
    "content": "import type { LoginSuccess, Update } from '#/auth';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiAuth = {\n  async login(username: string, password: string) {\n    const formData = new URLSearchParams({\n      username,\n      password,\n    });\n\n    const { data } = await axios.post<LoginSuccess>(\n      'api/v1/auth/login',\n      formData,\n      {\n        headers: {\n          'Content-Type': 'application/x-www-form-urlencoded',\n        },\n      }\n    );\n\n    return data;\n  },\n\n  async refresh() {\n    const { data } = await axios.get<LoginSuccess>('api/v1/auth/refresh_token');\n    return data;\n  },\n\n  async logout() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/auth/logout');\n    return data;\n  },\n\n  async update(username: string, password: string) {\n    const { data } = await axios.post<Update>('api/v1/auth/update', {\n      username,\n      password,\n    });\n\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/bangumi.ts",
    "content": "import { omit } from 'radash';\nimport type {\n  BangumiAPI,\n  BangumiRule,\n  DetectOffsetRequest,\n  DetectOffsetResponse,\n  OffsetSuggestion,\n} from '#/bangumi';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiBangumi = {\n  /**\n   * 获取所有 bangumi 数据\n   * @returns 所有 bangumi 数据\n   */\n  async getAll() {\n    const { data } = await axios.get<BangumiAPI[]>('api/v1/bangumi/get/all');\n    const result: BangumiRule[] = data.map((bangumi) => ({\n      ...bangumi,\n      filter: bangumi.filter.split(','),\n      rss_link: bangumi.rss_link.split(','),\n      air_weekday: bangumi.air_weekday ?? null,\n    }));\n    return result;\n  },\n\n  /**\n   * 获取指定 bangumiId 的规则\n   * @param bangumiId  bangumi id\n   * @returns 指定 bangumi 的规则\n   */\n  async getRule(bangumiId: number) {\n    const { data } = await axios.get<BangumiAPI>(\n      `api/v1/bangumi/get/${bangumiId}`\n    );\n    const result: BangumiRule = {\n      ...data,\n      filter: data.filter.split(','),\n      rss_link: data.rss_link.split(','),\n      air_weekday: data.air_weekday ?? null,\n    };\n    return result;\n  },\n\n  /**\n   * 更新指定 bangumiId 的规则\n   * @param bangumiId - 需要更新的 bangumi 的 id\n   * @param bangumiRule\n   * @returns axios 请求返回的数据\n   */\n  async updateRule(bangumiId: number, bangumiRule: BangumiRule) {\n    const rule: BangumiAPI = {\n      ...bangumiRule,\n      filter: bangumiRule.filter.join(','),\n      rss_link: bangumiRule.rss_link.join(','),\n    };\n    const post = omit(rule, ['id']);\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/bangumi/update/${bangumiId}`,\n      post\n    );\n    return data;\n  },\n\n  /**\n   * 删除指定 bangumiId 的数据库规则，会在重新匹配到后重建\n   * @param bangumiId - 需要删除的 bangumi 的 id\n   * @param file - 是否同时删除关联文件。\n   * @returns axios 请求返回的数据\n   */\n  async deleteRule(bangumiId: number | number[], file: boolean) {\n    let url = 'api/v1/bangumi/delete';\n    let ids: undefined | number[];\n\n    if (typeof bangumiId === 'number') {\n      url = `${url}/${bangumiId}`;\n    } else {\n      url = `${url}/many`;\n      ids = bangumiId;\n    }\n\n    const { data } = await axios.delete<ApiSuccess>(url, {\n      data: ids,\n      params: {\n        file,\n      },\n    });\n    return data;\n  },\n\n  /**\n   * 删除指定 bangumiId 的规则。如果 file 为 true，则同时删除关联文件。\n   * @param bangumiId - 需要删除规则的 bangumi 的 id。\n   * @param file - 是否同时删除关联文件。\n   * @returns axios 请求返回的数据\n   */\n  async disableRule(bangumiId: number | number[], file: boolean) {\n    let url = 'api/v1/bangumi/disable';\n    let ids: undefined | number[];\n\n    if (typeof bangumiId === 'number') {\n      url = `${url}/${bangumiId}`;\n    } else {\n      url = `${url}/many`;\n      ids = bangumiId;\n    }\n\n    const { data } = await axios.delete<ApiSuccess>(url, {\n      data: ids,\n      params: {\n        file,\n      },\n    });\n    return data;\n  },\n\n  /**\n   * 启用指定 bangumiId 的规则\n   * @param bangumiId - 需要启用的 bangumi 的 id\n   */\n  async enableRule(bangumiId: number) {\n    const { data } = await axios.get<ApiSuccess>(\n      `api/v1/bangumi/enable/${bangumiId}`\n    );\n    return data;\n  },\n\n  /**\n   * 重置所有 bangumi 数据\n   */\n  async resetAll() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/bangumi/reset/all');\n    return data;\n  },\n\n  /**\n   * 刷新所有没有海报的 bangumi 海报\n   */\n  async refreshPoster() {\n    const { data } = await axios.get<ApiSuccess>(\n      'api/v1/bangumi/refresh/poster/all'\n    );\n    return data;\n  },\n\n  /**\n   * 从 Bangumi.tv 刷新放送日历数据\n   */\n  async refreshCalendar() {\n    const { data } = await axios.get<ApiSuccess>(\n      'api/v1/bangumi/refresh/calendar'\n    );\n    return data;\n  },\n\n  /**\n   * 归档指定 bangumi\n   * @param bangumiId - 需要归档的 bangumi 的 id\n   */\n  async archiveRule(bangumiId: number) {\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/bangumi/archive/${bangumiId}`\n    );\n    return data;\n  },\n\n  /**\n   * 取消归档指定 bangumi\n   * @param bangumiId - 需要取消归档的 bangumi 的 id\n   */\n  async unarchiveRule(bangumiId: number) {\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/bangumi/unarchive/${bangumiId}`\n    );\n    return data;\n  },\n\n  /**\n   * 刷新 TMDB 元数据并自动归档已完结番剧\n   */\n  async refreshMetadata() {\n    const { data } = await axios.get<ApiSuccess>(\n      'api/v1/bangumi/refresh/metadata'\n    );\n    return data;\n  },\n\n  /**\n   * 获取自动检测的剧集偏移量建议\n   * @param bangumiId - bangumi 的 id\n   */\n  async suggestOffset(bangumiId: number) {\n    const { data } = await axios.get<OffsetSuggestion>(\n      `api/v1/bangumi/suggest-offset/${bangumiId}`\n    );\n    return data;\n  },\n\n  /**\n   * 检测季度/集数与 TMDB 的不匹配\n   * @param request - 包含标题、解析的季度和集数\n   */\n  async detectOffset(request: DetectOffsetRequest) {\n    const { data } = await axios.post<DetectOffsetResponse>(\n      'api/v1/bangumi/detect-offset',\n      request\n    );\n    return data;\n  },\n\n  /**\n   * 清除 bangumi 的需要检查标记\n   * @param bangumiId - bangumi 的 id\n   */\n  async dismissReview(bangumiId: number) {\n    const { data } = await axios.post<ApiSuccess>(\n      `api/v1/bangumi/dismiss-review/${bangumiId}`\n    );\n    return data;\n  },\n\n  /**\n   * 手动设置番剧的放送星期\n   * @param bangumiId - bangumi 的 id\n   * @param weekday - 0-6 for Mon-Sun, null to reset\n   */\n  async setWeekday(bangumiId: number, weekday: number | null) {\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/bangumi/${bangumiId}/weekday`,\n      { weekday }\n    );\n    return data;\n  },\n\n  /**\n   * 获取所有需要检查偏移量的 bangumi\n   */\n  async getNeedsReview() {\n    const { data } = await axios.get<BangumiAPI[]>(\n      'api/v1/bangumi/needs-review'\n    );\n    return data.map((bangumi) => ({\n      ...bangumi,\n      filter: bangumi.filter.split(','),\n      rss_link: bangumi.rss_link.split(','),\n      air_weekday: bangumi.air_weekday ?? null,\n    })) as BangumiRule[];\n  },\n};\n"
  },
  {
    "path": "webui/src/api/check.ts",
    "content": "export const apiCheck = {\n  /**\n   * 检测下载器\n   */\n  async downloader() {\n    const { data } = await axios.get<Boolean>('api/v1/check/downloader');\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/config.ts",
    "content": "import type { Config } from '#/config';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiConfig = {\n  /**\n   * 获取 config 数据\n   */\n  async getConfig() {\n    const { data } = await axios.get<Config>('api/v1/config/get');\n    return data;\n  },\n\n  /**\n   * 更新 config 数据\n   * @param newConfig - 需要更新的 config\n   */\n  async updateConfig(newConfig: Config) {\n    const { data } = await axios.patch<ApiSuccess>(\n      'api/v1/config/update',\n      newConfig\n    );\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/download.ts",
    "content": "import type { BangumiAPI, BangumiRule } from '#/bangumi';\nimport type { RSS } from '#/rss';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiDownload = {\n  /**\n   * 解析 RSS 链接\n   * @param rss_item - RSS 链接\n   */\n  async analysis(rss_item: RSS) {\n    const { data } = await axios.post<BangumiAPI>(\n      'api/v1/rss/analysis',\n      rss_item\n    );\n\n    const result: BangumiRule = {\n      ...data,\n      filter: data.filter.split(','),\n      rss_link: data.rss_link.split(','),\n    };\n    return result;\n  },\n\n  /**\n   * 旧番\n   * @param bangumiData - Bangumi 数据\n   */\n  async collection(bangumiData: BangumiRule) {\n    const postData: BangumiAPI = {\n      ...bangumiData,\n      filter: bangumiData.filter.join(','),\n      rss_link: bangumiData.rss_link.join(','),\n    };\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/rss/collect',\n      postData\n    );\n    return data;\n  },\n\n  /**\n   * 新番\n   * @param bangumiData - Bangumi 数据\n   */\n  async subscribe(bangumiData: BangumiRule, rss: RSS) {\n    const bangumi: BangumiAPI = {\n      ...bangumiData,\n      filter: bangumiData.filter.join(','),\n      rss_link: bangumiData.rss_link.join(','),\n    };\n    const postData = {\n      data: bangumi,\n      rss,\n    };\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/rss/subscribe',\n      postData\n    );\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/downloader.ts",
    "content": "import type { QbTorrentInfo } from '#/downloader';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiDownloader = {\n  async getTorrents() {\n    const { data } = await axios.get<QbTorrentInfo[]>(\n      'api/v1/downloader/torrents'\n    );\n    return data!;\n  },\n\n  async pause(hashes: string[]) {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/downloader/torrents/pause',\n      { hashes }\n    );\n    return data!;\n  },\n\n  async resume(hashes: string[]) {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/downloader/torrents/resume',\n      { hashes }\n    );\n    return data!;\n  },\n\n  async deleteTorrents(hashes: string[], deleteFiles = false) {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/downloader/torrents/delete',\n      { hashes, delete_files: deleteFiles }\n    );\n    return data!;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/log.ts",
    "content": "import type { ApiSuccess } from '#/api';\n\nexport const apiLog = {\n  async getLog() {\n    const { data } = await axios.get<string>('api/v1/log');\n    return data;\n  },\n\n  async clearLog() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/log/clear');\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/notification.ts",
    "content": "import type { NotificationProviderConfig, NotificationType } from '#/config';\nimport type { TupleToUnion } from '#/utils';\n\nexport interface TestProviderRequest {\n  provider_index: number;\n}\n\nexport interface TestProviderConfigRequest {\n  type: TupleToUnion<NotificationType>;\n  enabled?: boolean;\n  token?: string;\n  chat_id?: string;\n  webhook_url?: string;\n  server_url?: string;\n  device_key?: string;\n  user_key?: string;\n  api_token?: string;\n  template?: string;\n  url?: string;\n}\n\nexport interface TestResponse {\n  success: boolean;\n  message: string;\n  message_zh: string;\n  message_en: string;\n}\n\nexport const apiNotification = {\n  /**\n   * Test a configured provider by index\n   */\n  async testProvider(request: TestProviderRequest) {\n    const { data } = await axios.post<TestResponse>(\n      'api/v1/notification/test',\n      request\n    );\n    return { data };\n  },\n\n  /**\n   * Test an unsaved provider configuration\n   */\n  async testProviderConfig(request: TestProviderConfigRequest) {\n    const { data } = await axios.post<TestResponse>(\n      'api/v1/notification/test-config',\n      request\n    );\n    return { data };\n  },\n};\n"
  },
  {
    "path": "webui/src/api/passkey.ts",
    "content": "import type { ApiSuccess } from '#/api';\nimport type { LoginSuccess } from '#/auth';\nimport type {\n  AuthenticationOptions,\n  PasskeyAuthFinishRequest,\n  PasskeyAuthStartRequest,\n  PasskeyCreateRequest,\n  PasskeyDeleteRequest,\n  PasskeyItem,\n  RegistrationOptions,\n} from '#/passkey';\n\n/**\n * Passkey API 客户端\n */\nexport const apiPasskey = {\n  // ============ 注册流程 ============\n\n  /**\n   * 获取注册选项（步骤 1）\n   */\n  async getRegistrationOptions(): Promise<RegistrationOptions> {\n    const { data } = await axios.post<RegistrationOptions>(\n      'api/v1/passkey/register/options'\n    );\n    return data;\n  },\n\n  /**\n   * 提交注册结果（步骤 2）\n   */\n  async verifyRegistration(request: PasskeyCreateRequest): Promise<ApiSuccess> {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/passkey/register/verify',\n      request\n    );\n    return data;\n  },\n\n  // ============ 认证流程 ============\n\n  /**\n   * 获取登录选项（步骤 1）\n   */\n  async getLoginOptions(\n    request: PasskeyAuthStartRequest\n  ): Promise<AuthenticationOptions> {\n    const { data } = await axios.post<AuthenticationOptions>(\n      'api/v1/passkey/auth/options',\n      request\n    );\n    return data;\n  },\n\n  /**\n   * 提交认证结果（步骤 2）\n   */\n  async loginWithPasskey(\n    request: PasskeyAuthFinishRequest\n  ): Promise<LoginSuccess> {\n    const { data } = await axios.post<LoginSuccess>(\n      'api/v1/passkey/auth/verify',\n      request\n    );\n    return data;\n  },\n\n  // ============ 管理 ============\n\n  /**\n   * 获取 Passkey 列表\n   */\n  async list(): Promise<PasskeyItem[]> {\n    const { data } = await axios.get<PasskeyItem[]>('api/v1/passkey/list');\n    return data;\n  },\n\n  /**\n   * 删除 Passkey\n   */\n  async delete(request: PasskeyDeleteRequest): Promise<ApiSuccess> {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/passkey/delete',\n      request\n    );\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/program.ts",
    "content": "import type { ApiSuccess } from '#/api';\n\nexport const apiProgram = {\n  /**\n   * 重启\n   */\n  async restart() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/restart');\n    return data;\n  },\n\n  /**\n   * 启动\n   */\n  async start() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/start');\n    return data;\n  },\n\n  /**\n   * 停止\n   */\n  async stop() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/stop');\n    return data;\n  },\n\n  /**\n   * 状态\n   */\n  async status() {\n    const { data } = await axios.get<{ status: boolean; version: string }>(\n      'api/v1/status'\n    );\n\n    return data!;\n  },\n\n  /**\n   * 终止\n   */\n  async shutdown() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/shutdown');\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/rss.ts",
    "content": "import type { RSS } from '#/rss';\nimport type { Torrent } from '#/torrent';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiRSS = {\n  async get() {\n    const { data } = await axios.get<RSS[]>('api/v1/rss');\n    return data!;\n  },\n\n  async add(rss: RSS) {\n    const { data } = await axios.post<ApiSuccess>('api/v1/rss/add', rss);\n    return data;\n  },\n\n  async delete(rss_id: number) {\n    const { data } = await axios.delete<ApiSuccess>(\n      `api/v1/rss/delete/${rss_id}`\n    );\n    return data!;\n  },\n\n  async deleteMany(rss_list: number[]) {\n    const { data } = await axios.post<ApiSuccess>(\n      `api/v1/rss/delete/many`,\n      rss_list\n    );\n    return data!;\n  },\n\n  async disable(rss_id: number) {\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/rss/disable/${rss_id}`\n    );\n    return data!;\n  },\n\n  async disableMany(rss_list: number[]) {\n    const { data } = await axios.post<ApiSuccess>(\n      `api/v1/rss/disable/many`,\n      rss_list\n    );\n    return data!;\n  },\n\n  async update(rss_id: number, rss: RSS) {\n    const { data } = await axios.patch<ApiSuccess>(\n      `api/v1/rss/update/${rss_id}`,\n      rss\n    );\n    return data!;\n  },\n\n  async enableMany(rss_list: number[]) {\n    const { data } = await axios.post<ApiSuccess>(\n      `api/v1/rss/enable/many`,\n      rss_list\n    );\n    return data!;\n  },\n\n  async refreshAll() {\n    const { data } = await axios.get<ApiSuccess>('api/v1/rss/refresh/all');\n    return data!;\n  },\n\n  async refresh(rss_id: number) {\n    const { data } = await axios.get<ApiSuccess>(\n      `api/v1/rss/refresh/${rss_id}`\n    );\n    return data!;\n  },\n\n  async getTorrent(rss_id: number) {\n    const { data } = await axios.get<Torrent[]>(`api/v1/rss/torrent/${rss_id}`);\n    return data!;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/search.ts",
    "content": "import type { Ref } from 'vue';\nimport { omit } from 'radash';\nimport type { BangumiAPI, BangumiRule } from '#/bangumi';\n\ntype EventSourceStatus = 'OPEN' | 'CONNECTING' | 'CLOSED';\n\nexport const apiSearch = {\n  get() {\n    const eventSource = ref(null) as Ref<EventSource | null>;\n    const status = ref<EventSourceStatus>('CLOSED');\n    const data = ref<BangumiRule[]>([]);\n\n    const keyword = ref('');\n    const provider = ref('');\n\n    const close = () => {\n      if (eventSource.value) {\n        eventSource.value.close();\n        eventSource.value = null;\n        status.value = 'CLOSED';\n      }\n    };\n\n    const _init = () => {\n      status.value = 'CONNECTING';\n\n      const url = `api/v1/search/bangumi?site=${\n        provider.value\n      }&keywords=${encodeURIComponent(keyword.value)}`;\n\n      const es = new EventSource(url, { withCredentials: true });\n      eventSource.value = es;\n      es.onopen = () => {\n        status.value = 'OPEN';\n      };\n      es.onmessage = (e) => {\n        const _data = JSON.parse(e.data) as BangumiAPI;\n        const newData: BangumiRule = {\n          ...omit(_data, ['filter', 'rss_link']),\n          filter: _data.filter.split(','),\n          rss_link: _data.rss_link.split(','),\n        };\n        data.value = [...data.value, newData];\n      };\n      es.onerror = (err) => {\n        console.error('EventSource error:', err);\n        close();\n      };\n    };\n\n    const open = () => {\n      data.value = [];\n      _init();\n    };\n\n    return {\n      keyword,\n      provider,\n      status,\n      data,\n      open,\n      close,\n    };\n  },\n\n  async getProvider() {\n    const { data } = await axios.get<string[]>('api/v1/search/provider');\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/api/setup.ts",
    "content": "import type {\n  SetupCompleteRequest,\n  SetupStatus,\n  TestDownloaderRequest,\n  TestNotificationRequest,\n  TestResult,\n} from '#/setup';\nimport type { ApiSuccess } from '#/api';\n\nexport const apiSetup = {\n  async getStatus() {\n    const { data } = await axios.get<SetupStatus>('api/v1/setup/status');\n    return data;\n  },\n\n  async testDownloader(config: TestDownloaderRequest) {\n    const { data } = await axios.post<TestResult>(\n      'api/v1/setup/test-downloader',\n      config\n    );\n    return data;\n  },\n\n  async testRSS(url: string) {\n    const { data } = await axios.post<TestResult>('api/v1/setup/test-rss', {\n      url,\n    });\n    return data;\n  },\n\n  async testNotification(config: TestNotificationRequest) {\n    const { data } = await axios.post<TestResult>(\n      'api/v1/setup/test-notification',\n      config\n    );\n    return data;\n  },\n\n  async complete(config: SetupCompleteRequest) {\n    const { data } = await axios.post<ApiSuccess>(\n      'api/v1/setup/complete',\n      config\n    );\n    return data;\n  },\n};\n"
  },
  {
    "path": "webui/src/components/ab-add-rss.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CheckOne, Close, Copy, Down, ErrorPicture, Link, Right } from '@icon-park/vue-next';\nimport { NDynamicTags, NSpin } from 'naive-ui';\nimport type { BangumiRule } from '#/bangumi';\nimport type { RSS } from '#/rss';\nimport { rssTemplate } from '#/rss';\nimport { ruleTemplate } from '#/bangumi';\n\n/** v-model show */\nconst show = defineModel('show', { default: false });\n\nconst message = useMessage();\nconst { getAll } = useBangumiStore();\nconst { t } = useMyI18n();\n\nconst rss = ref<RSS>({ ...rssTemplate });\nconst rule = defineModel<BangumiRule>('rule', { default: () => ({ ...ruleTemplate }) });\nconst parserTypes = ['tmdb', 'mikan', 'parser'] as const;\n\n// UI state\nconst step = ref<'input' | 'confirm'>('input');\nconst showAdvanced = ref(true);\nconst copied = ref(false);\nconst offsetLoading = ref(false);\nconst offsetReason = ref('');\n\nconst loading = reactive({\n  analyze: false,\n  collect: false,\n  subscribe: false,\n});\n\nconst { execute: addRssAggregate } = useApi(apiRSS.add, {\n  showMessage: true,\n  onBeforeExecute() {\n    loading.analyze = true;\n  },\n  onSuccess() {\n    show.value = false;\n  },\n  onFinally() {\n    loading.analyze = false;\n  },\n});\n\nconst { execute: analyzeRss } = useApi(apiDownload.analysis, {\n  showMessage: true,\n  onBeforeExecute() {\n    loading.analyze = true;\n  },\n  onSuccess(res) {\n    rule.value = res;\n    step.value = 'confirm';\n  },\n  onFinally() {\n    loading.analyze = false;\n  },\n});\n\nconst { execute: executeCollect } = useApi(apiDownload.collection, {\n  showMessage: true,\n  onBeforeExecute() {\n    loading.collect = true;\n  },\n  onSuccess() {\n    getAll();\n    show.value = false;\n  },\n  onFinally() {\n    loading.collect = false;\n  },\n});\n\nconst { execute: executeSubscribe } = useApi(apiDownload.subscribe, {\n  showMessage: true,\n  onBeforeExecute() {\n    loading.subscribe = true;\n  },\n  onSuccess() {\n    getAll();\n    show.value = false;\n  },\n  onFinally() {\n    loading.subscribe = false;\n  },\n});\n\n// Computed\nconst posterSrc = computed(() => resolvePosterUrl(rule.value.poster_link));\n\nconst infoTags = computed(() => {\n  const tags: { value: string; type: string }[] = [];\n  const { season, season_raw, dpi, subtitle, group_name } = rule.value;\n\n  if (season || season_raw) {\n    const seasonDisplay = season_raw || (season ? `S${season}` : '');\n    tags.push({ value: seasonDisplay, type: 'season' });\n  }\n\n  if (dpi) {\n    tags.push({ value: dpi, type: 'resolution' });\n  }\n\n  if (subtitle) {\n    tags.push({ value: subtitle, type: 'subtitle' });\n  }\n\n  if (group_name) {\n    tags.push({ value: group_name, type: 'group' });\n  }\n\n  return tags;\n});\n\n// Watchers\nwatch(show, (val) => {\n  if (!val) {\n    // Reset state when closing\n    rss.value = { ...rssTemplate };\n    step.value = 'input';\n    offsetReason.value = '';\n  } else if (val && rule.value.official_title !== '') {\n    // If rule already has data, go to confirm step\n    step.value = 'confirm';\n  }\n});\n\n// Methods\nfunction close() {\n  show.value = false;\n}\n\nfunction addRss() {\n  if (rss.value.url === '') {\n    message.error(t('notify.please_enter', [t('notify.rss_link')]));\n    return;\n  }\n\n  if (rss.value.aggregate) {\n    addRssAggregate(rss.value);\n  } else {\n    analyzeRss(rss.value);\n  }\n}\n\nfunction goBack() {\n  step.value = 'input';\n}\n\nlet copyTimer: ReturnType<typeof setTimeout> | undefined;\n\nasync function copyRssLink() {\n  const rssLink = rule.value.rss_link?.[0] || rss.value.url || '';\n  if (rssLink) {\n    await navigator.clipboard.writeText(rssLink);\n    copied.value = true;\n    clearTimeout(copyTimer);\n    copyTimer = setTimeout(() => {\n      copied.value = false;\n    }, 2000);\n  }\n}\n\nonBeforeUnmount(() => {\n  clearTimeout(copyTimer);\n});\n\nasync function autoDetectOffset() {\n  if (!rule.value.id) return;\n  offsetLoading.value = true;\n  offsetReason.value = '';\n  try {\n    const result = await apiBangumi.suggestOffset(rule.value.id);\n    rule.value.episode_offset = result.suggested_offset;\n    offsetReason.value = result.reason;\n  } catch (e) {\n    console.error('Failed to detect offset:', e);\n    message.error('Failed to detect offset');\n  } finally {\n    offsetLoading.value = false;\n  }\n}\n\nfunction collect() {\n  if (!rule.value) return;\n  executeCollect(rule.value);\n}\n\nfunction subscribe() {\n  if (!rule.value) return;\n  executeSubscribe(rule.value, rss.value);\n}\n</script>\n\n<template>\n  <Teleport to=\"body\">\n    <Transition name=\"modal\">\n      <div v-if=\"show\" class=\"add-backdrop\" @click.self=\"close\">\n        <div class=\"add-modal\" role=\"dialog\" aria-modal=\"true\">\n          <!-- Header -->\n          <header class=\"add-header\">\n            <h2 class=\"add-title\">{{ $t('topbar.add.title') }}</h2>\n            <button class=\"close-btn\" aria-label=\"Close\" @click=\"close\">\n              <Close theme=\"outline\" size=\"18\" />\n            </button>\n          </header>\n\n          <!-- Step 1: Input RSS -->\n          <div v-if=\"step === 'input'\" class=\"add-content\">\n            <div class=\"form-section\">\n              <!-- RSS Link -->\n              <div class=\"form-group\">\n                <label class=\"form-label\">{{ $t('topbar.add.rss_link') }}</label>\n                <div class=\"input-wrapper\">\n                  <Link theme=\"outline\" size=\"16\" class=\"input-icon\" />\n                  <input\n                    v-model=\"rss.url\"\n                    type=\"text\"\n                    class=\"form-input form-input--with-icon\"\n                    :placeholder=\"$t('topbar.add.placeholder_link')\"\n                  />\n                </div>\n              </div>\n\n              <!-- Name -->\n              <div class=\"form-group\">\n                <label class=\"form-label\">{{ $t('topbar.add.name') }}</label>\n                <input\n                  v-model=\"rss.name\"\n                  type=\"text\"\n                  class=\"form-input\"\n                  :placeholder=\"$t('topbar.add.placeholder_name')\"\n                />\n              </div>\n\n              <!-- Options row -->\n              <div class=\"options-row\">\n                <!-- Aggregate Switch -->\n                <div class=\"option-item\">\n                  <label class=\"option-label\">{{ $t('topbar.add.aggregate') }}</label>\n                  <label class=\"switch\">\n                    <input v-model=\"rss.aggregate\" type=\"checkbox\" />\n                    <span class=\"switch-slider\"></span>\n                  </label>\n                </div>\n\n                <!-- Parser Select -->\n                <div class=\"option-item\">\n                  <label class=\"option-label\">{{ $t('topbar.add.parser') }}</label>\n                  <select v-model=\"rss.parser\" class=\"form-select\">\n                    <option v-for=\"type in parserTypes\" :key=\"type\" :value=\"type\">\n                      {{ type }}\n                    </option>\n                  </select>\n                </div>\n              </div>\n            </div>\n\n            <!-- Footer -->\n            <footer class=\"add-footer\">\n              <ab-button size=\"small\" :loading=\"loading.analyze\" @click=\"addRss\">\n                {{ $t('topbar.add.button') }}\n              </ab-button>\n            </footer>\n          </div>\n\n          <!-- Step 2: Confirm -->\n          <template v-else>\n            <div class=\"add-content\">\n              <!-- Bangumi Info -->\n              <div class=\"bangumi-info\">\n                <div class=\"bangumi-poster\">\n                  <template v-if=\"rule.poster_link\">\n                    <img :src=\"posterSrc\" :alt=\"rule.official_title\" />\n                  </template>\n                  <template v-else>\n                    <div class=\"poster-placeholder\">\n                      <ErrorPicture theme=\"outline\" size=\"32\" />\n                    </div>\n                  </template>\n                </div>\n                <div class=\"bangumi-meta\">\n                  <input\n                    v-model=\"rule.official_title\"\n                    type=\"text\"\n                    class=\"title-input\"\n                    :placeholder=\"$t('homepage.rule.official_title')\"\n                  />\n                  <p v-if=\"rule.title_raw\" class=\"bangumi-subtitle\">{{ rule.title_raw }}</p>\n                  <div class=\"meta-row\">\n                    <input\n                      :value=\"rule.year ?? ''\"\n                      type=\"text\"\n                      class=\"year-input\"\n                      :class=\"{ 'year-input--empty': !rule.year }\"\n                      :placeholder=\"$t('homepage.rule.year')\"\n                      @input=\"(e) => rule.year = (e.target as HTMLInputElement).value || null\"\n                    />\n                    <span class=\"meta-separator\">·</span>\n                    <label class=\"season-label\">S</label>\n                    <input\n                      v-model.number=\"rule.season\"\n                      type=\"number\"\n                      class=\"season-input\"\n                      min=\"1\"\n                    />\n                  </div>\n                </div>\n              </div>\n\n              <!-- Info Tags -->\n              <div v-if=\"infoTags.length > 0\" class=\"info-tags\">\n                <div\n                  v-for=\"tag in infoTags\"\n                  :key=\"tag.type\"\n                  class=\"info-tag\"\n                  :class=\"`info-tag--${tag.type}`\"\n                >\n                  {{ tag.value }}\n                </div>\n              </div>\n\n              <!-- RSS Link Display -->\n              <div v-if=\"rule.rss_link?.[0] || rss.url\" class=\"rss-section\">\n                <div class=\"info-row\">\n                  <span class=\"info-label\">{{ $t('search.confirm.rss') }}:</span>\n                  <span class=\"info-value info-value--link\">\n                    {{ rule.rss_link?.[0] || rss.url || '-' }}\n                  </span>\n                  <button class=\"copy-btn\" :class=\"{ copied }\" @click=\"copyRssLink\">\n                    <CheckOne v-if=\"copied\" theme=\"outline\" size=\"14\" />\n                    <Copy v-else theme=\"outline\" size=\"14\" />\n                  </button>\n                </div>\n              </div>\n\n              <!-- Advanced settings -->\n              <div class=\"advanced-section\">\n                <button class=\"advanced-toggle\" @click=\"showAdvanced = !showAdvanced\">\n                  <component :is=\"showAdvanced ? Down : Right\" theme=\"outline\" size=\"14\" />\n                  {{ $t('search.confirm.advanced') }}\n                </button>\n\n                <Transition name=\"expand\">\n                  <div v-show=\"showAdvanced\" class=\"advanced-content\">\n                    <!-- Filter rules row -->\n                    <div class=\"advanced-row advanced-row--tags\">\n                      <label class=\"advanced-label\">{{ $t('homepage.rule.filter') }}</label>\n                      <div class=\"advanced-control filter-tags\">\n                        <NDynamicTags v-model:value=\"rule.filter\" size=\"small\" />\n                      </div>\n                    </div>\n\n                    <!-- Offset row -->\n                    <div class=\"advanced-row\">\n                      <label class=\"advanced-label\">{{ $t('homepage.rule.offset') }}</label>\n                      <div class=\"advanced-control offset-controls\">\n                        <input\n                          v-model.number=\"rule.episode_offset\"\n                          type=\"number\"\n                          ab-input\n                          class=\"offset-input\"\n                        />\n                        <button\n                          class=\"detect-btn\"\n                          :disabled=\"offsetLoading || !rule.id\"\n                          @click=\"autoDetectOffset\"\n                        >\n                          <NSpin v-if=\"offsetLoading\" :size=\"14\" />\n                          <span v-else>{{ $t('homepage.rule.auto_detect') }}</span>\n                        </button>\n                      </div>\n                    </div>\n                    <div v-if=\"offsetReason\" class=\"offset-reason\">{{ offsetReason }}</div>\n                  </div>\n                </Transition>\n              </div>\n            </div>\n\n            <!-- Footer -->\n            <footer class=\"add-footer add-footer--confirm\">\n              <div class=\"footer-left\">\n                <ab-button size=\"small\" type=\"secondary\" @click=\"goBack\">\n                  {{ $t('setup.nav.previous') }}\n                </ab-button>\n              </div>\n              <div class=\"footer-right\">\n                <ab-button size=\"small\" :loading=\"loading.collect\" @click=\"collect\">\n                  {{ $t('topbar.add.collect') }}\n                </ab-button>\n                <ab-button size=\"small\" :loading=\"loading.subscribe\" @click=\"subscribe\">\n                  {{ $t('topbar.add.subscribe') }}\n                </ab-button>\n              </div>\n            </footer>\n          </template>\n        </div>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<style lang=\"scss\" scoped>\n.add-backdrop {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-overlay);\n  z-index: var(--z-modal);\n  padding: 16px;\n}\n\n.add-modal {\n  width: 100%;\n  max-width: 480px;\n  max-height: 90dvh; // Use dynamic viewport height for iOS Safari keyboard support\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n\n  // Fallback for browsers that don't support dvh\n  @supports not (max-height: 1dvh) {\n    max-height: 90vh;\n  }\n}\n\n.add-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--color-border);\n}\n\n.add-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n}\n\n.close-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  background: transparent;\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n    color: var(--color-text);\n  }\n}\n\n.add-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 20px;\n}\n\n// Form Section (Step 1)\n.form-section {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.form-group {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.form-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.input-wrapper {\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n\n.input-icon {\n  position: absolute;\n  left: 12px;\n  color: var(--color-text-muted);\n  pointer-events: none;\n}\n\n.form-input {\n  width: 100%;\n  height: 40px;\n  padding: 0 12px;\n  font-size: 14px;\n  font-family: inherit;\n  color: var(--color-text);\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  outline: none;\n  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);\n  }\n\n  &::placeholder {\n    color: var(--color-text-muted);\n  }\n\n  &--with-icon {\n    padding-left: 40px;\n  }\n}\n\n.form-select {\n  height: 36px;\n  padding: 0 32px 0 12px;\n  font-size: 13px;\n  font-family: inherit;\n  color: var(--color-text);\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  outline: none;\n  cursor: pointer;\n  appearance: none;\n  background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6,9 12,15 18,9'%3E%3C/polyline%3E%3C/svg%3E\");\n  background-repeat: no-repeat;\n  background-position: right 8px center;\n  transition: border-color var(--transition-fast);\n\n  &:focus {\n    border-color: var(--color-primary);\n  }\n}\n\n.options-row {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: space-between;\n  align-items: center;\n  gap: 16px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n}\n\n.option-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.option-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n// Custom Switch\n.switch {\n  position: relative;\n  display: inline-block;\n  width: 44px;\n  height: 24px;\n\n  input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n  }\n}\n\n.switch-slider {\n  position: absolute;\n  cursor: pointer;\n  inset: 0;\n  background: var(--color-border);\n  border-radius: 24px;\n  transition: background-color var(--transition-fast);\n\n  &::before {\n    content: '';\n    position: absolute;\n    left: 2px;\n    bottom: 2px;\n    width: 20px;\n    height: 20px;\n    background: #fff;\n    border-radius: 50%;\n    transition: transform var(--transition-fast);\n  }\n\n  input:checked + & {\n    background: var(--color-primary);\n  }\n\n  input:checked + &::before {\n    transform: translateX(20px);\n  }\n}\n\n// Footer\n.add-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: 12px;\n  padding: 16px 20px;\n  border-top: 1px solid var(--color-border);\n\n  &--confirm {\n    justify-content: space-between;\n  }\n}\n\n.footer-left {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.footer-right {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n// Bangumi Info (Step 2 - same as ab-edit-rule)\n.bangumi-info {\n  display: flex;\n  gap: 16px;\n  margin-bottom: 20px;\n}\n\n.bangumi-poster {\n  width: 80px;\n  height: 112px;\n  flex-shrink: 0;\n  border-radius: var(--radius-md);\n  overflow: hidden;\n  background: var(--color-surface-hover);\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n}\n\n.poster-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-text-muted);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n}\n\n.bangumi-meta {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.title-input {\n  width: 100%;\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 4px 0;\n  outline: none;\n  transition: border-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  &::placeholder {\n    color: var(--color-text-muted);\n    font-weight: 400;\n  }\n}\n\n.bangumi-subtitle {\n  font-size: 13px;\n  color: var(--color-text-muted);\n  margin: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.meta-row {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  margin-top: 4px;\n}\n\n.year-input {\n  width: 60px;\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 2px 0;\n  outline: none;\n  transition: border-color var(--transition-fast), background-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  &::placeholder {\n    color: var(--color-text-muted);\n  }\n\n  &--empty {\n    background: color-mix(in srgb, var(--color-warning) 15%, transparent);\n    border-bottom-color: var(--color-warning);\n    border-radius: var(--radius-xs) var(--radius-xs) 0 0;\n    padding: 2px 4px;\n\n    &::placeholder {\n      color: var(--color-warning);\n    }\n  }\n}\n\n.meta-separator {\n  color: var(--color-text-muted);\n}\n\n.season-label {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  font-weight: 500;\n}\n\n.season-input {\n  width: 40px;\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 2px 0;\n  outline: none;\n  text-align: center;\n  transition: border-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  &::-webkit-outer-spin-button,\n  &::-webkit-inner-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n  -moz-appearance: textfield;\n}\n\n// Info Tags\n.info-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-bottom: 16px;\n}\n\n.info-tag {\n  display: inline-flex;\n  align-items: center;\n  padding: 6px 14px;\n  border-radius: var(--radius-full);\n  font-size: 13px;\n  font-weight: 600;\n\n  &--season {\n    background: color-mix(in srgb, var(--color-primary) 12%, transparent);\n    color: var(--color-primary);\n  }\n\n  &--resolution {\n    background: color-mix(in srgb, var(--color-accent) 12%, transparent);\n    color: var(--color-accent);\n  }\n\n  &--subtitle {\n    background: color-mix(in srgb, var(--color-success) 12%, transparent);\n    color: var(--color-success);\n  }\n\n  &--group {\n    background: color-mix(in srgb, var(--color-warning) 12%, transparent);\n    color: var(--color-warning);\n  }\n}\n\n// RSS section\n.rss-section {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-bottom: 16px;\n}\n\n.info-row {\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n}\n\n.info-label {\n  flex-shrink: 0;\n  width: 70px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.info-value {\n  flex: 1;\n  min-width: 0;\n  font-size: 13px;\n  color: var(--color-text);\n  word-break: break-all;\n\n  &--link {\n    color: var(--color-primary);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n}\n\n.copy-btn {\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    color: var(--color-primary);\n  }\n\n  &.copied {\n    background: var(--color-success);\n    border-color: var(--color-success);\n    color: #fff;\n  }\n}\n\n// Advanced section\n.advanced-section {\n  margin-bottom: 8px;\n}\n\n.advanced-toggle {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 8px 0;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  transition: color var(--transition-fast);\n\n  &:hover {\n    color: var(--color-text);\n  }\n}\n\n.advanced-content {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-top: 8px;\n}\n\n.advanced-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  min-height: 32px;\n\n  &--tags {\n    align-items: flex-start;\n  }\n}\n\n.advanced-label {\n  flex-shrink: 0;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  line-height: 32px;\n}\n\n.advanced-control {\n  display: flex;\n  justify-content: flex-end;\n\n  :deep(.n-dynamic-tags) {\n    justify-content: flex-end;\n    min-height: 32px;\n\n    .n-tag {\n      height: 28px;\n      margin: 2px 0 2px 6px !important;\n    }\n\n    .n-button {\n      height: 28px;\n    }\n  }\n}\n\n.offset-controls {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  gap: 8px;\n  height: 32px;\n}\n\n.offset-input {\n  width: 70px;\n  height: 32px;\n  text-align: center;\n}\n\n.detect-btn {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 80px;\n  height: 32px;\n  padding: 0 14px;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: #fff;\n  background: var(--color-primary);\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  white-space: nowrap;\n  transition: background-color var(--transition-fast);\n\n  &:hover:not(:disabled) {\n    background: var(--color-primary-hover);\n  }\n\n  &:disabled {\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\n.offset-reason {\n  font-size: 12px;\n  color: var(--color-text-secondary);\n  margin-top: -4px;\n}\n\n// Expand transition\n.expand-enter-active,\n.expand-leave-active {\n  transition: all var(--transition-normal);\n  overflow: hidden;\n}\n\n.expand-enter-from,\n.expand-leave-to {\n  opacity: 0;\n  max-height: 0;\n  margin-top: 0;\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n// Modal transition\n.modal-enter-active,\n.modal-leave-active {\n  transition: opacity 200ms ease;\n\n  .add-modal {\n    transition: transform 200ms ease, opacity 200ms ease;\n  }\n}\n\n.modal-enter-from,\n.modal-leave-to {\n  opacity: 0;\n\n  .add-modal {\n    transform: scale(0.95) translateY(10px);\n    opacity: 0;\n  }\n}\n\n// Responsive\n@media (max-width: 480px) {\n  .options-row {\n    flex-direction: column;\n    gap: 16px;\n  }\n\n  .option-item {\n    justify-content: space-between;\n    width: 100%;\n  }\n\n  .add-footer--confirm {\n    flex-direction: column;\n    gap: 12px;\n  }\n\n  .footer-left,\n  .footer-right {\n    width: 100%;\n    justify-content: center;\n  }\n\n  .footer-right {\n    order: -1;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-bangumi-card.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ErrorPicture, Write } from '@icon-park/vue-next';\nimport type { BangumiRule } from '#/bangumi';\n\nconst props = withDefaults(\n  defineProps<{\n    type?: 'primary' | 'search' | 'mobile';\n    bangumi: BangumiRule;\n  }>(),\n  {\n    type: 'primary',\n  }\n);\n\ndefineEmits(['click']);\n\nconst posterSrc = computed(() => resolvePosterUrl(props.bangumi.poster_link));\n</script>\n\n<template>\n  <!-- Grid poster card -->\n  <div\n    v-if=\"type === 'primary'\"\n    class=\"card\"\n    role=\"button\"\n    tabindex=\"0\"\n    :aria-label=\"`Edit ${bangumi.official_title}`\"\n    @click=\"() => $emit('click')\"\n    @keydown.enter=\"() => $emit('click')\"\n  >\n    <div class=\"card-poster\" :class=\"{ 'card-poster--needs-review': bangumi.needs_review }\">\n      <template v-if=\"bangumi.poster_link\">\n        <img :src=\"posterSrc\" :alt=\"bangumi.official_title\" class=\"card-img\" />\n      </template>\n      <template v-else>\n        <div class=\"card-placeholder\">\n          <ErrorPicture theme=\"outline\" size=\"24\" />\n        </div>\n      </template>\n\n      <div class=\"card-overlay\">\n        <div class=\"card-overlay-tags\">\n          <ab-tag :title=\"`Season ${bangumi.season}`\" type=\"primary\" />\n          <ab-tag\n            v-if=\"bangumi.group_name\"\n            :title=\"bangumi.group_name\"\n            type=\"primary\"\n          />\n        </div>\n        <div\n          class=\"card-edit-btn\"\n          role=\"img\"\n          :aria-label=\"$t('homepage.rule.edit')\"\n        >\n          <Write size=\"18\" />\n        </div>\n      </div>\n    </div>\n\n    <div class=\"card-info\">\n      <div class=\"card-title\">{{ bangumi.official_title }}</div>\n    </div>\n  </div>\n\n  <!-- Search result card -->\n  <div v-else-if=\"type === 'search'\" class=\"search-card\">\n    <div class=\"search-card-inner\">\n      <div class=\"search-card-content\">\n        <div class=\"search-card-thumb\">\n          <template v-if=\"bangumi.poster_link\">\n            <img :src=\"posterSrc\" :alt=\"bangumi.official_title\" class=\"search-card-img\" />\n          </template>\n          <template v-else>\n            <div class=\"card-placeholder card-placeholder--small\">\n              <ErrorPicture theme=\"outline\" size=\"20\" />\n            </div>\n          </template>\n        </div>\n        <div class=\"search-card-meta\">\n          <div class=\"search-card-title\">{{ bangumi.official_title }}</div>\n          <div class=\"card-tags\">\n            <ab-tag\n              v-if=\"bangumi.season\"\n              :title=\"`Season ${bangumi.season}`\"\n              type=\"primary\"\n            />\n            <ab-tag\n              v-if=\"bangumi.group_name\"\n              :title=\"bangumi.group_name\"\n              type=\"primary\"\n            />\n            <ab-tag\n              v-if=\"bangumi.subtitle\"\n              :title=\"bangumi.subtitle\"\n              type=\"primary\"\n            />\n          </div>\n        </div>\n      </div>\n      <ab-add :round=\"true\" type=\"medium\" @click=\"() => $emit('click')\" />\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n// Grid poster card\n.card {\n  width: 150px;\n  cursor: pointer;\n  user-select: none;\n\n  // Focus ring for keyboard navigation\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 4px;\n    border-radius: var(--radius-md);\n  }\n}\n\n.card-poster {\n  position: relative;\n  aspect-ratio: 5 / 7;\n  border-radius: var(--radius-md);\n  overflow: hidden;\n  box-shadow: var(--shadow-md);\n  transition: box-shadow var(--transition-fast), transform var(--transition-fast);\n\n  .card:hover &,\n  .card:focus-visible & {\n    box-shadow: var(--shadow-lg);\n    transform: translateY(-2px);\n  }\n\n  // On touch devices, don't lift on hover\n  @include forTouch {\n    .card:hover & {\n      transform: none;\n    }\n  }\n}\n\n.card-img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n// Card glow animation when needs review - yellow\n.card-poster--needs-review {\n  animation: card-glow 2.5s ease-in-out infinite;\n}\n\n@keyframes card-glow {\n  0%, 100% {\n    box-shadow: var(--shadow-md), 0 0 0 0 rgba(251, 191, 36, 0);\n  }\n  50% {\n    box-shadow: var(--shadow-md), 0 0 16px 4px rgba(251, 191, 36, 0.6);\n  }\n}\n\n.card-placeholder {\n  width: 100%;\n  height: 100%;\n  min-height: 210px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-surface-hover);\n  color: var(--color-text-muted);\n  border: 1px solid var(--color-border);\n  transition: background-color var(--transition-normal);\n\n  &--small {\n    min-height: 44px;\n    height: 44px;\n  }\n}\n\n.card-overlay {\n  position: absolute;\n  inset: 0;\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  opacity: 0;\n  background: var(--color-overlay-light);\n  backdrop-filter: blur(2px);\n  transition: opacity var(--transition-normal);\n\n  .card:hover &,\n  .card:focus-visible & {\n    opacity: 1;\n  }\n\n  .card:active & {\n    background: var(--color-overlay);\n  }\n\n  // On touch devices, always show a subtle indicator\n  @include forTouch {\n    opacity: 1;\n    background: linear-gradient(to top, var(--color-overlay) 0%, transparent 50%);\n    backdrop-filter: none;\n\n    .card-edit-btn {\n      position: absolute;\n      bottom: 8px;\n      right: 8px;\n      width: 32px;\n      height: 32px;\n    }\n  }\n}\n\n.card-overlay-tags {\n  position: absolute;\n  bottom: 6px;\n  left: 6px;\n  right: 6px;\n  display: flex;\n  gap: 3px;\n  flex-wrap: wrap;\n\n  :deep(.tag) {\n    background: var(--color-overlay);\n    border-color: rgba(255, 255, 255, 0.4);\n    color: var(--color-white);\n    font-size: 9px;\n    padding: 1px 6px;\n  }\n\n  // On touch, move tags to avoid overlap with edit button\n  @include forTouch {\n    right: 44px;\n  }\n}\n\n.card-edit-btn {\n  width: 44px;\n  height: 44px;\n  border-radius: 50%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-primary);\n  color: var(--color-white);\n  box-shadow: var(--shadow-md);\n  transition: transform var(--transition-fast), background-color var(--transition-fast);\n\n  .card:active & {\n    transform: scale(0.9);\n  }\n\n  .card:hover &,\n  .card:focus-visible & {\n    background: var(--color-primary-hover);\n  }\n}\n\n.card-info {\n  padding: 8px 2px 4px;\n}\n\n.card-title {\n  font-size: 14px;\n  font-weight: 500;\n  line-height: 1.4;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  transition: color var(--transition-normal);\n}\n\n.card-tags {\n  display: flex;\n  gap: 4px;\n  flex-wrap: wrap;\n}\n\n// Search result card\n.search-card {\n  width: 100%;\n  max-width: 480px;\n  border-radius: var(--radius-lg);\n  padding: 4px;\n  background: var(--color-primary-light);\n  box-shadow: var(--shadow-sm);\n  transition: background-color var(--transition-normal);\n}\n\n.search-card-inner {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  background: var(--color-surface);\n  border-radius: var(--radius-md);\n  padding: 12px;\n  transition: background-color var(--transition-normal);\n}\n\n.search-card-content {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  flex: 1;\n  min-width: 0;\n}\n\n.search-card-thumb {\n  width: 72px;\n  height: 44px;\n  border-radius: var(--radius-sm);\n  overflow: hidden;\n  flex-shrink: 0;\n}\n\n.search-card-img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n}\n\n.search-card-meta {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  min-width: 0;\n}\n\n.search-card-title {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--color-primary);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-change-account.vue",
    "content": "<script lang=\"ts\" setup>\nconst show = defineModel('show', {\n  default: false,\n});\n\nconst { user, update } = useAuth();\n</script>\n\n<template>\n  <ab-popup\n    v-model:show=\"show\"\n    :title=\"$t('topbar.profile.pop_title')\"\n    css=\"w-365\"\n  >\n    <div space-y-16>\n      <ab-label :label=\"$t('topbar.profile.username')\">\n        <input\n          v-model=\"user.username\"\n          type=\"text\"\n          :placeholder=\"$t('topbar.profile.username')\"\n          ab-input\n        />\n      </ab-label>\n\n      <ab-label :label=\"$t('topbar.profile.password')\">\n        <input\n          v-model=\"user.password\"\n          type=\"password\"\n          :placeholder=\"$t('topbar.profile.password')\"\n          ab-input\n        />\n      </ab-label>\n\n      <div line></div>\n\n      <div flex=\"~ justify-end\">\n        <ab-button size=\"small\" @click=\"update\">{{\n          $t('topbar.profile.update_btn')\n        }}</ab-button>\n      </div>\n    </div>\n  </ab-popup>\n</template>\n"
  },
  {
    "path": "webui/src/components/ab-container.vue",
    "content": "<script lang=\"ts\" setup>\nwithDefaults(\n  defineProps<{\n    title: string;\n  }>(),\n  {\n    title: 'title',\n  }\n);\n</script>\n\n<template>\n  <div class=\"container-card\">\n    <div class=\"container-header\">\n      <div class=\"container-title\">{{ title }}</div>\n      <slot name=\"title-right\"></slot>\n    </div>\n\n    <div class=\"container-body\">\n      <slot></slot>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.container-card {\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  transition: border-color var(--transition-normal);\n}\n\n.container-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 0 14px;\n  height: 34px;\n  background: transparent;\n  color: var(--color-text-secondary);\n  border-bottom: 1px solid var(--color-border);\n  user-select: none;\n  transition: color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.container-title {\n  font-size: 15px;\n  font-weight: 600;\n}\n\n.container-body {\n  padding: 12px 14px;\n  background: var(--color-surface);\n  color: var(--color-text);\n  font-size: 14px;\n  transition: background-color var(--transition-normal),\n              color var(--transition-normal);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-edit-rule.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';\nimport { NDynamicTags, NSpin, useMessage } from 'naive-ui';\nimport type { BangumiRule, DetectOffsetResponse } from '#/bangumi';\n\nconst emit = defineEmits<{\n  (e: 'apply', rule: BangumiRule): void;\n  (e: 'enable', id: number): void;\n  (e: 'archive', id: number): void;\n  (e: 'unarchive', id: number): void;\n  (\n    e: 'deleteFile',\n    type: 'disable' | 'delete',\n    opts: { id: number; deleteFile: boolean }\n  ): void;\n}>();\n\nconst { t } = useMyI18n();\n\nconst show = defineModel('show', { default: false });\nconst rule = defineModel<BangumiRule>('rule', {\n  required: true,\n});\n\nconst message = useMessage();\n\n// Local deep copy for editing (prevents mutation of original)\nconst localRule = ref<BangumiRule>(JSON.parse(JSON.stringify(rule.value)));\n\n// Sync when rule changes (e.g., opening different item)\nwatch(rule, (newVal) => {\n  localRule.value = JSON.parse(JSON.stringify(newVal));\n}, { deep: true });\n\nconst posterSrc = computed(() => resolvePosterUrl(localRule.value.poster_link));\nconst showAdvanced = ref(true);\nconst copied = ref(false);\nconst offsetLoading = ref(false);\nconst offsetReason = ref('');\nconst dismissingReview = ref(false);\n\n// Delete file dialog state\nconst deleteFileDialog = reactive<{\n  show: boolean;\n  type: 'disable' | 'delete';\n}>({\n  show: false,\n  type: 'disable',\n});\n\nwatch(show, (val) => {\n  if (!val) {\n    deleteFileDialog.show = false;\n    showAdvanced.value = false;\n    offsetReason.value = '';\n  }\n});\n\n// Info tags for display\nconst infoTags = computed(() => {\n  const tags: { value: string; type: string }[] = [];\n  const { season, season_raw, dpi, subtitle, group_name } = localRule.value;\n\n  if (season || season_raw) {\n    const seasonDisplay = season_raw || (season ? `S${season}` : '');\n    tags.push({ value: seasonDisplay, type: 'season' });\n  }\n\n  if (dpi) {\n    tags.push({ value: dpi, type: 'resolution' });\n  }\n\n  if (subtitle) {\n    tags.push({ value: subtitle, type: 'subtitle' });\n  }\n\n  if (group_name) {\n    tags.push({ value: group_name, type: 'group' });\n  }\n\n  return tags;\n});\n\n// Copy RSS link\nlet copyTimer: ReturnType<typeof setTimeout> | undefined;\n\nasync function copyRssLink() {\n  const rssLink = localRule.value.rss_link?.[0] || '';\n  if (rssLink) {\n    await navigator.clipboard.writeText(rssLink);\n    copied.value = true;\n    clearTimeout(copyTimer);\n    copyTimer = setTimeout(() => {\n      copied.value = false;\n    }, 2000);\n  }\n}\n\nonBeforeUnmount(() => {\n  clearTimeout(copyTimer);\n});\n\n// Auto detect offset using the new detectOffset API\nasync function autoDetectOffset() {\n  if (!localRule.value.official_title || !localRule.value.season) return;\n  offsetLoading.value = true;\n  offsetReason.value = '';\n  try {\n    const result: DetectOffsetResponse = await apiBangumi.detectOffset({\n      title: localRule.value.official_title,\n      parsed_season: localRule.value.season,\n      parsed_episode: 1,\n    });\n\n    if (result.has_mismatch && result.suggestion) {\n      localRule.value.season_offset = result.suggestion.season_offset;\n      localRule.value.episode_offset = result.suggestion.episode_offset;\n      offsetReason.value = result.suggestion.reason;\n      // Clear needs_review after applying offset\n      localRule.value.needs_review = false;\n      localRule.value.needs_review_reason = null;\n      message.success(t('offset.suggestion_applied'));\n    } else {\n      offsetReason.value = t('offset.no_mismatch');\n      // Clear needs_review if no mismatch detected\n      localRule.value.needs_review = false;\n      localRule.value.needs_review_reason = null;\n      message.info(t('offset.no_mismatch'));\n    }\n  } catch (e) {\n    console.error('Failed to detect offset:', e);\n    message.error('Failed to detect offset');\n  } finally {\n    offsetLoading.value = false;\n  }\n}\n\n// Dismiss the needs_review warning\nasync function dismissReview() {\n  if (!localRule.value.id) return;\n  dismissingReview.value = true;\n  try {\n    await apiBangumi.dismissReview(localRule.value.id);\n    localRule.value.needs_review = false;\n    localRule.value.needs_review_reason = null;\n    message.success(t('offset.review_dismissed'));\n  } catch (e) {\n    console.error('Failed to dismiss review:', e);\n    message.error('Failed to dismiss review');\n  } finally {\n    dismissingReview.value = false;\n  }\n}\n\nconst close = () => (show.value = false);\n\nfunction showDeleteFileDialog() {\n  deleteFileDialog.show = true;\n  deleteFileDialog.type = 'delete';\n}\n\nfunction emitDeleteFile(deleteFile: boolean) {\n  emit('deleteFile', deleteFileDialog.type, {\n    id: rule.value.id,\n    deleteFile,\n  });\n}\n\nfunction emitApply() {\n  // Copy local changes back to rule before emitting\n  Object.assign(rule.value, localRule.value);\n  emit('apply', rule.value);\n}\n\nfunction emitEnable() {\n  emit('enable', rule.value.id);\n}\n\nfunction emitArchive() {\n  emit('archive', rule.value.id);\n}\n\nfunction emitUnarchive() {\n  emit('unarchive', rule.value.id);\n}\n</script>\n\n<template>\n  <!-- Enable deleted rule dialog -->\n  <ab-popup\n    v-if=\"rule.deleted\"\n    v-model:show=\"show\"\n    :title=\"$t('homepage.rule.enable_rule')\"\n    css=\"w-300 max-w-[90vw]\"\n  >\n    <div>{{ $t('homepage.rule.enable_hit') }}</div>\n    <div line my-8></div>\n    <div f-cer gap-x-10>\n      <ab-button size=\"small\" type=\"warn\" @click=\"emitEnable\">\n        {{ $t('homepage.rule.yes_btn') }}\n      </ab-button>\n      <ab-button size=\"small\" @click=\"close\">\n        {{ $t('homepage.rule.no_btn') }}\n      </ab-button>\n    </div>\n  </ab-popup>\n\n  <!-- Main edit modal -->\n  <Teleport v-else to=\"body\">\n    <Transition name=\"modal\">\n      <div v-if=\"show\" class=\"edit-backdrop\" @click.self=\"close\">\n        <div class=\"edit-modal\" role=\"dialog\" aria-modal=\"true\">\n          <!-- Header -->\n          <header class=\"edit-header\">\n            <h2 class=\"edit-title\">{{ $t('homepage.rule.edit_rule') }}</h2>\n            <button class=\"close-btn\" aria-label=\"Close\" @click=\"close\">\n              <Close theme=\"outline\" size=\"18\" />\n            </button>\n          </header>\n\n          <!-- Needs Review Warning Banner -->\n          <div v-if=\"localRule.needs_review\" class=\"review-warning\">\n            <div class=\"review-warning-main\">\n              <span class=\"review-warning-emoji\">⚠️</span>\n              <div class=\"review-warning-content\">\n                <div class=\"review-warning-title\">{{ $t('offset.needs_review') }}</div>\n                <div v-if=\"localRule.needs_review_reason\" class=\"review-warning-reason\">\n                  {{ localRule.needs_review_reason }}\n                </div>\n              </div>\n            </div>\n            <div class=\"review-warning-actions\">\n              <button\n                class=\"detect-btn\"\n                :disabled=\"offsetLoading\"\n                @click=\"autoDetectOffset\"\n              >\n                <NSpin v-if=\"offsetLoading\" :size=\"12\" />\n                <span v-else>{{ $t('homepage.rule.auto_detect') }}</span>\n              </button>\n              <button\n                class=\"dismiss-btn\"\n                :disabled=\"dismissingReview\"\n                @click=\"dismissReview\"\n              >\n                <NSpin v-if=\"dismissingReview\" :size=\"12\" />\n                <span v-else>{{ $t('offset.dismiss') }}</span>\n              </button>\n            </div>\n          </div>\n\n          <!-- Content -->\n          <div class=\"edit-content\">\n            <!-- Bangumi Info -->\n            <div class=\"bangumi-info\">\n              <div class=\"bangumi-poster\">\n                <template v-if=\"localRule.poster_link\">\n                  <img :src=\"posterSrc\" :alt=\"localRule.official_title\" />\n                </template>\n                <template v-else>\n                  <div class=\"poster-placeholder\">\n                    <ErrorPicture theme=\"outline\" size=\"32\" />\n                  </div>\n                </template>\n              </div>\n              <div class=\"bangumi-meta\">\n                <input\n                  v-model=\"localRule.official_title\"\n                  type=\"text\"\n                  class=\"title-input\"\n                  :placeholder=\"$t('homepage.rule.official_title')\"\n                />\n                <p v-if=\"localRule.title_raw\" class=\"bangumi-subtitle\">{{ localRule.title_raw }}</p>\n                <div class=\"meta-row\">\n                  <input\n                    :value=\"localRule.year ?? ''\"\n                    type=\"text\"\n                    class=\"year-input\"\n                    :class=\"{ 'year-input--empty': !localRule.year }\"\n                    :placeholder=\"$t('homepage.rule.year')\"\n                    @input=\"(e) => localRule.year = (e.target as HTMLInputElement).value || null\"\n                  />\n                  <span class=\"meta-separator\">·</span>\n                  <label class=\"season-label\">S</label>\n                  <input\n                    v-model.number=\"localRule.season\"\n                    type=\"number\"\n                    class=\"season-input\"\n                    min=\"1\"\n                  />\n                </div>\n              </div>\n            </div>\n\n            <!-- Info Tags -->\n            <div v-if=\"infoTags.length > 0\" class=\"info-tags\">\n              <div\n                v-for=\"tag in infoTags\"\n                :key=\"tag.type\"\n                class=\"info-tag\"\n                :class=\"`info-tag--${tag.type}`\"\n              >\n                {{ tag.value }}\n              </div>\n            </div>\n\n            <!-- RSS Link -->\n            <div v-if=\"localRule.rss_link?.[0]\" class=\"rss-section\">\n              <div class=\"info-row\">\n                <span class=\"info-label\">{{ $t('search.confirm.rss') }}:</span>\n                <span class=\"info-value info-value--link\">\n                  {{ localRule.rss_link?.[0] || '-' }}\n                </span>\n                <button class=\"copy-btn\" :class=\"{ copied }\" @click=\"copyRssLink\">\n                  <CheckOne v-if=\"copied\" theme=\"outline\" size=\"14\" />\n                  <Copy v-else theme=\"outline\" size=\"14\" />\n                </button>\n              </div>\n            </div>\n\n            <!-- Advanced settings -->\n            <div class=\"advanced-section\">\n              <button class=\"advanced-toggle\" @click=\"showAdvanced = !showAdvanced\">\n                <component :is=\"showAdvanced ? Down : Right\" theme=\"outline\" size=\"14\" />\n                {{ $t('search.confirm.advanced') }}\n              </button>\n\n              <Transition name=\"expand\">\n                <div v-show=\"showAdvanced\" class=\"advanced-content\">\n                  <!-- Filter rules row -->\n                  <div class=\"advanced-row advanced-row--tags\">\n                    <label class=\"advanced-label\">{{ $t('homepage.rule.filter') }}</label>\n                    <div class=\"advanced-control filter-tags\">\n                      <NDynamicTags v-model:value=\"localRule.filter\" size=\"small\" />\n                    </div>\n                  </div>\n\n                  <!-- Season Offset row -->\n                  <div class=\"advanced-row\">\n                    <label class=\"advanced-label\">{{ $t('homepage.rule.season_offset') }}</label>\n                    <div class=\"advanced-control offset-controls\">\n                      <input\n                        v-model.number=\"localRule.season_offset\"\n                        type=\"number\"\n                        ab-input\n                        class=\"offset-input\"\n                      />\n                    </div>\n                  </div>\n\n                  <!-- Episode Offset row -->\n                  <div class=\"advanced-row\">\n                    <label class=\"advanced-label\">{{ $t('homepage.rule.episode_offset') }}</label>\n                    <div class=\"advanced-control offset-controls\">\n                      <input\n                        v-model.number=\"localRule.episode_offset\"\n                        type=\"number\"\n                        ab-input\n                        class=\"offset-input\"\n                      />\n                    </div>\n                  </div>\n                </div>\n              </Transition>\n            </div>\n          </div>\n\n          <!-- Footer -->\n          <footer class=\"edit-footer\">\n            <div class=\"footer-left\">\n              <ab-button\n                v-if=\"localRule.archived\"\n                size=\"small\"\n                @click=\"emitUnarchive\"\n              >\n                {{ $t('homepage.rule.unarchive') }}\n              </ab-button>\n              <ab-button\n                v-else\n                size=\"small\"\n                @click=\"emitArchive\"\n              >\n                {{ $t('homepage.rule.archive') }}\n              </ab-button>\n              <ab-button\n                size=\"small\"\n                type=\"warn\"\n                @click=\"showDeleteFileDialog\"\n              >\n                {{ $t('homepage.rule.delete') }}\n              </ab-button>\n            </div>\n            <div class=\"footer-right\">\n              <ab-button size=\"small\" @click=\"emitApply\">\n                {{ $t('homepage.rule.apply') }}\n              </ab-button>\n            </div>\n          </footer>\n        </div>\n\n        <!-- Delete confirmation dialog -->\n        <Transition name=\"modal\">\n          <div v-if=\"deleteFileDialog.show\" class=\"delete-dialog-backdrop\" @click.self=\"deleteFileDialog.show = false\">\n            <div class=\"delete-dialog\">\n              <h3 class=\"delete-title\">{{ $t('homepage.rule.delete') }}</h3>\n              <p class=\"delete-message\">{{ $t('homepage.rule.delete_hit') }}</p>\n              <div class=\"delete-actions\">\n                <ab-button size=\"small\" type=\"secondary\" @click=\"emitDeleteFile(false)\">\n                  {{ $t('homepage.rule.no_btn') }}\n                </ab-button>\n                <ab-button size=\"small\" type=\"warn\" @click=\"emitDeleteFile(true)\">\n                  {{ $t('homepage.rule.yes_btn') }}\n                </ab-button>\n              </div>\n            </div>\n          </div>\n        </Transition>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<style lang=\"scss\" scoped>\n.edit-backdrop {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-overlay);\n  z-index: var(--z-modal);\n  padding: 16px;\n}\n\n.edit-modal {\n  width: 100%;\n  max-width: 480px;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n}\n\n.edit-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--color-border);\n}\n\n.edit-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n}\n\n.close-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  background: transparent;\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n    color: var(--color-text);\n  }\n}\n\n// Review warning banner\n.review-warning {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 10px 16px;\n  margin: 12px 20px;\n  background: #fef9ed;\n  border: 1px solid #fde68a;\n  border-radius: var(--radius-md);\n}\n\n.review-warning-main {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  flex: 1;\n  min-width: 0;\n}\n\n.review-warning-emoji {\n  font-size: 20px;\n  flex-shrink: 0;\n}\n\n.review-warning-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.review-warning-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: #92400e;\n}\n\n.review-warning-reason {\n  font-size: 12px;\n  color: #a16207;\n  line-height: 1.3;\n  margin-top: 2px;\n}\n\n.review-warning-actions {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-shrink: 0;\n}\n\n.review-warning-actions .detect-btn,\n.review-warning-actions .dismiss-btn {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  height: 32px;\n  padding: 0 14px;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  white-space: nowrap;\n  transition: all var(--transition-fast);\n\n  &:disabled {\n    cursor: wait;\n  }\n}\n\n.review-warning-actions .detect-btn {\n  min-width: 90px;\n  color: #fff;\n  background: var(--color-primary);\n  border: none;\n\n  &:hover:not(:disabled) {\n    background: var(--color-primary-hover);\n  }\n}\n\n.review-warning-actions .dismiss-btn {\n  min-width: 70px;\n  color: var(--color-text-secondary);\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n\n  &:hover:not(:disabled) {\n    border-color: var(--color-text-muted);\n    color: var(--color-text);\n  }\n}\n\n.edit-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 20px;\n}\n\n// Bangumi info section\n.bangumi-info {\n  display: flex;\n  gap: 16px;\n  margin-bottom: 20px;\n}\n\n.bangumi-poster {\n  width: 80px;\n  height: 112px;\n  flex-shrink: 0;\n  border-radius: var(--radius-md);\n  overflow: hidden;\n  background: var(--color-surface-hover);\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n}\n\n.poster-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-text-muted);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n}\n\n.bangumi-meta {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.title-input {\n  width: 100%;\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 4px 0;\n  outline: none;\n  transition: border-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  &::placeholder {\n    color: var(--color-text-muted);\n    font-weight: 400;\n  }\n}\n\n.bangumi-subtitle {\n  font-size: 13px;\n  color: var(--color-text-muted);\n  margin: 0;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.meta-row {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  margin-top: 4px;\n}\n\n.year-input {\n  width: 60px;\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 2px 0;\n  outline: none;\n  transition: border-color var(--transition-fast), background-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  &::placeholder {\n    color: var(--color-text-muted);\n  }\n\n  &--empty {\n    background: color-mix(in srgb, var(--color-warning) 15%, transparent);\n    border-bottom-color: var(--color-warning);\n    border-radius: var(--radius-xs) var(--radius-xs) 0 0;\n    padding: 2px 4px;\n\n    &::placeholder {\n      color: var(--color-warning);\n    }\n  }\n}\n\n.meta-separator {\n  color: var(--color-text-muted);\n}\n\n.season-label {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  font-weight: 500;\n}\n\n.season-input {\n  width: 40px;\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  border-bottom: 1px solid transparent;\n  padding: 2px 0;\n  outline: none;\n  text-align: center;\n  transition: border-color var(--transition-fast);\n\n  &:hover,\n  &:focus {\n    border-bottom-color: var(--color-border);\n  }\n\n  &:focus {\n    border-bottom-color: var(--color-primary);\n  }\n\n  // Hide spinner buttons\n  &::-webkit-outer-spin-button,\n  &::-webkit-inner-spin-button {\n    -webkit-appearance: none;\n    margin: 0;\n  }\n  -moz-appearance: textfield;\n}\n\n// Info Tags\n.info-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-bottom: 16px;\n}\n\n.info-tag {\n  display: inline-flex;\n  align-items: center;\n  padding: 6px 14px;\n  border-radius: var(--radius-full);\n  font-size: 13px;\n  font-weight: 600;\n\n  &--season {\n    background: color-mix(in srgb, var(--color-primary) 12%, transparent);\n    color: var(--color-primary);\n  }\n\n  &--resolution {\n    background: color-mix(in srgb, var(--color-accent) 12%, transparent);\n    color: var(--color-accent);\n  }\n\n  &--subtitle {\n    background: color-mix(in srgb, var(--color-success) 12%, transparent);\n    color: var(--color-success);\n  }\n\n  &--group {\n    background: color-mix(in srgb, var(--color-warning) 12%, transparent);\n    color: var(--color-warning);\n  }\n}\n\n// RSS section\n.rss-section {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-bottom: 16px;\n}\n\n.info-row {\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n}\n\n.info-label {\n  flex-shrink: 0;\n  width: 70px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.info-value {\n  flex: 1;\n  min-width: 0;\n  font-size: 13px;\n  color: var(--color-text);\n  word-break: break-all;\n\n  &--link {\n    color: var(--color-primary);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n}\n\n.copy-btn {\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    color: var(--color-primary);\n  }\n\n  &.copied {\n    background: var(--color-success);\n    border-color: var(--color-success);\n    color: #fff;\n  }\n}\n\n// Advanced section\n.advanced-section {\n  margin-bottom: 8px;\n}\n\n.advanced-toggle {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 8px 0;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  transition: color var(--transition-fast);\n\n  &:hover {\n    color: var(--color-text);\n  }\n}\n\n.advanced-content {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-top: 8px;\n}\n\n.advanced-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  min-height: 32px;\n\n  &--tags {\n    align-items: flex-start;\n  }\n}\n\n.advanced-label {\n  flex-shrink: 0;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  line-height: 32px;\n}\n\n.advanced-control {\n  display: flex;\n  justify-content: flex-end;\n\n  :deep(.n-dynamic-tags) {\n    justify-content: flex-end;\n    min-height: 32px;\n\n    .n-tag {\n      height: 28px;\n      margin: 2px 0 2px 6px !important;\n    }\n\n    .n-button {\n      height: 28px;\n    }\n  }\n}\n\n.offset-controls {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  gap: 8px;\n  height: 32px;\n}\n\n.offset-input {\n  width: 70px;\n  height: 32px;\n  text-align: center;\n}\n\n.detect-btn {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 80px;\n  height: 32px;\n  padding: 0 14px;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: #fff;\n  background: var(--color-primary);\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  white-space: nowrap;\n  transition: background-color var(--transition-fast);\n\n  &:hover:not(:disabled) {\n    background: var(--color-primary-hover);\n  }\n\n  &:disabled {\n    cursor: wait;\n  }\n}\n\n// Expand transition\n.expand-enter-active,\n.expand-leave-active {\n  transition: all var(--transition-normal);\n  overflow: hidden;\n}\n\n.expand-enter-from,\n.expand-leave-to {\n  opacity: 0;\n  max-height: 0;\n  margin-top: 0;\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n// Footer\n.edit-footer {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 16px 20px;\n  border-top: 1px solid var(--color-border);\n  flex-wrap: wrap;\n}\n\n.footer-left {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.footer-right {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n// Delete confirmation dialog\n.delete-dialog-backdrop {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-overlay);\n  z-index: calc(var(--z-modal) + 10);\n}\n\n.delete-dialog {\n  width: 100%;\n  max-width: 320px;\n  padding: 24px;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  text-align: center;\n}\n\n.delete-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0 0 12px;\n}\n\n.delete-message {\n  font-size: 14px;\n  color: var(--color-text-secondary);\n  margin: 0 0 20px;\n}\n\n.delete-actions {\n  display: flex;\n  justify-content: center;\n  gap: 12px;\n}\n\n// Modal transition\n.modal-enter-active,\n.modal-leave-active {\n  transition: opacity 200ms ease;\n\n  .edit-modal,\n  .delete-dialog {\n    transition: transform 200ms ease, opacity 200ms ease;\n  }\n}\n\n.modal-enter-from,\n.modal-leave-to {\n  opacity: 0;\n\n  .edit-modal,\n  .delete-dialog {\n    transform: scale(0.95) translateY(10px);\n    opacity: 0;\n  }\n}\n\n// Responsive adjustments\n@media (max-width: 480px) {\n  .edit-footer {\n    flex-direction: column;\n    gap: 12px;\n  }\n\n  .footer-left,\n  .footer-right {\n    width: 100%;\n    justify-content: center;\n  }\n\n  .footer-right {\n    order: -1;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-fold-panel.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Down, Up } from '@icon-park/vue-next';\nimport { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/vue';\n\nwithDefaults(\n  defineProps<{\n    title: string;\n    defaultOpen?: boolean;\n  }>(),\n  {\n    title: 'title',\n    defaultOpen: true,\n  }\n);\n</script>\n\n<template>\n  <Disclosure v-slot=\"{ open }\" :default-open=\"defaultOpen\">\n    <div class=\"fold-panel\">\n      <DisclosureButton class=\"fold-panel-header\">\n        <div class=\"fold-panel-title\">{{ title }}</div>\n        <Component :is=\"open ? Up : Down\" :size=\"14\" />\n      </DisclosureButton>\n\n      <DisclosurePanel>\n        <div class=\"fold-panel-body\">\n          <slot></slot>\n        </div>\n      </DisclosurePanel>\n    </div>\n  </Disclosure>\n</template>\n\n<style lang=\"scss\" scoped>\n.fold-panel {\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  transition: border-color var(--transition-normal);\n  min-width: 0; // Allow panel to shrink below content size\n  max-width: 100%;\n}\n\n.fold-panel-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  width: 100%;\n  padding: 0 14px;\n  height: 34px;\n  background: transparent;\n  color: var(--color-text-secondary);\n  border: none;\n  border-bottom: 1px solid var(--color-border);\n  cursor: pointer;\n  transition: color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.fold-panel-title {\n  font-size: 15px;\n  font-weight: 600;\n}\n\n.fold-panel-body {\n  background: var(--color-surface);\n  padding: 12px 14px;\n  font-size: 14px;\n  color: var(--color-text);\n  overflow-x: hidden;\n  transition: background-color var(--transition-normal),\n              color var(--transition-normal);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-image.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ErrorPicture } from '@icon-park/vue-next';\n\nwithDefaults(\n  defineProps<{\n    src?: string | null;\n    aspectRatio?: number;\n    objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';\n  }>(),\n  {\n    objectFit: 'cover',\n  }\n);\n</script>\n\n<template>\n  <div rel>\n    <template v-if=\"aspectRatio\">\n      <div\n        w-full\n        :style=\"{ paddingBottom: `calc(${1 / aspectRatio} * 100%)` }\"\n      ></div>\n\n      <img\n        v-if=\"src\"\n        :src=\"src\"\n        alt=\"poster\"\n        abs\n        top-0\n        left-0\n        :style=\"{ objectFit }\"\n        wh-full\n      />\n    </template>\n\n    <template v-else>\n      <img v-if=\"src\" :src=\"src\" alt=\"poster\" :style=\"{ objectFit }\" wh-full />\n\n      <div v-else wh-full f-cer border=\"1 white\">\n        <ErrorPicture theme=\"outline\" size=\"24\" fill=\"#333\" />\n      </div>\n    </template>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "webui/src/components/ab-label.vue",
    "content": "<script lang=\"ts\" setup>\nconst props = withDefaults(\n  defineProps<{\n    label: string | (() => string);\n  }>(),\n  {\n    label: '',\n  }\n);\n\nconst abLabel = computed(() => {\n  if (typeof props.label === 'function') return props.label();\n  else return props.label;\n});\n</script>\n\n<template>\n  <div class=\"label-row\">\n    <div class=\"label-text\">{{ abLabel }}</div>\n    <slot></slot>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.label-row {\n  display: flex;\n  flex-direction: column;\n  align-items: flex-start;\n  gap: 6px;\n  min-height: 32px;\n\n  @include forTablet {\n    flex-direction: row;\n    align-items: center;\n    justify-content: space-between;\n    gap: 12px;\n  }\n\n  // Make inputs full-width on mobile\n  :deep(input[ab-input]),\n  :deep(.ab-select),\n  :deep(.n-dynamic-tags) {\n    width: 100%;\n\n    @include forTablet {\n      width: auto;\n      min-width: 200px;\n    }\n  }\n}\n\n.label-text {\n  font-size: 14px;\n  color: var(--color-text);\n  transition: color var(--transition-normal);\n  flex-shrink: 0;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-popup.vue",
    "content": "<script lang=\"ts\" setup>\nimport {\n  Dialog,\n  DialogPanel,\n  TransitionChild,\n  TransitionRoot,\n} from '@headlessui/vue';\n\nconst props = withDefaults(\n  defineProps<{\n    title: string;\n    maskClick?: boolean;\n    css?: string;\n  }>(),\n  {\n    title: 'title',\n    maskClick: true,\n    css: '',\n  }\n);\n\nconst show = defineModel('show', { default: false });\nconst { isMobile } = useBreakpointQuery();\n\nfunction close() {\n  if (props.maskClick) {\n    show.value = false;\n  }\n}\n</script>\n\n<template>\n  <!-- Mobile: bottom sheet -->\n  <ab-bottom-sheet\n    v-if=\"isMobile\"\n    :show=\"show\"\n    :title=\"title\"\n    :closeable=\"maskClick\"\n    @update:show=\"show = $event\"\n  >\n    <slot></slot>\n  </ab-bottom-sheet>\n\n  <!-- Desktop/Tablet: centered dialog -->\n  <TransitionRoot v-else appear :show=\"show\" as=\"template\">\n    <Dialog as=\"div\" class=\"popup-dialog\" @close=\"close\">\n      <TransitionChild\n        as=\"template\"\n        enter=\"duration-300 ease-out\"\n        enter-from=\"opacity-0\"\n        enter-to=\"opacity-100\"\n        leave=\"duration-200 ease-in\"\n        leave-from=\"opacity-100\"\n        leave-to=\"opacity-0\"\n      >\n        <div class=\"popup-backdrop\" />\n      </TransitionChild>\n\n      <div class=\"popup-wrapper\">\n        <div class=\"popup-center\">\n          <TransitionChild\n            as=\"template\"\n            enter=\"duration-300 ease-out\"\n            enter-from=\"opacity-0 scale-95\"\n            enter-to=\"opacity-100 scale-100\"\n            leave=\"duration-200 ease-in\"\n            leave-from=\"opacity-100 scale-100\"\n            leave-to=\"opacity-0 scale-95\"\n          >\n            <DialogPanel>\n              <ab-container :title=\"title\" :class=\"[css]\">\n                <slot></slot>\n              </ab-container>\n            </DialogPanel>\n          </TransitionChild>\n        </div>\n      </div>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<style lang=\"scss\" scoped>\n.popup-dialog {\n  position: relative;\n  z-index: var(--z-modal, 50);\n}\n\n.popup-backdrop {\n  position: fixed;\n  inset: 0;\n  z-index: var(--z-modal-backdrop, 40);\n  background: rgba(108, 74, 182, 0.15);\n  backdrop-filter: blur(4px);\n}\n\n.popup-wrapper {\n  position: fixed;\n  inset: 0;\n  z-index: var(--z-modal, 50);\n  overflow-y: auto;\n}\n\n.popup-center {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 100%;\n  padding: 16px;\n  text-align: center;\n}\n\n:deep(.container-card) {\n  border: 1px solid var(--color-primary);\n  box-shadow: 0 8px 32px rgba(108, 74, 182, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);\n  border-radius: var(--radius-lg);\n  overflow: hidden;\n}\n\n:deep(.container-header) {\n  background: var(--color-primary);\n  color: #fff;\n  border-bottom: none;\n  height: 38px;\n}\n\n:deep(.container-body) {\n  border-radius: 0 0 var(--radius-lg) var(--radius-lg);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-rule.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { BangumiRule } from '#/bangumi';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\n\nconst rule = defineModel<BangumiRule>('rule', {\n  required: true,\n});\n\nconst offsetLoading = ref(false);\nconst offsetReason = ref('');\n\nasync function autoDetectOffset() {\n  if (!rule.value.id) return;\n  offsetLoading.value = true;\n  offsetReason.value = '';\n  try {\n    const result = await apiBangumi.suggestOffset(rule.value.id);\n    rule.value.episode_offset = result.suggested_offset;\n    offsetReason.value = result.reason;\n  } catch (e) {\n    console.error('Failed to detect offset:', e);\n  } finally {\n    offsetLoading.value = false;\n  }\n}\n\nconst items: SettingItem<BangumiRule>[] = [\n  {\n    configKey: 'official_title',\n    label: () => t('homepage.rule.official_title'),\n    type: 'input',\n    prop: {\n      type: 'text',\n    },\n  },\n  {\n    configKey: 'year',\n    label: () => t('homepage.rule.year'),\n    type: 'input',\n    css: 'w-72',\n    prop: {\n      type: 'text',\n    },\n  },\n  {\n    configKey: 'season',\n    label: () => t('homepage.rule.season'),\n    type: 'input',\n    css: 'w-72',\n    prop: {\n      type: 'number',\n    },\n  },\n  {\n    configKey: 'filter',\n    label: () => t('homepage.rule.exclude'),\n    type: 'dynamic-tags',\n  },\n];\n</script>\n\n<template>\n  <div class=\"rule-form\">\n    <ab-setting\n      v-for=\"i in items\"\n      :key=\"i.configKey\"\n      v-bind=\"i\"\n      v-model:data=\"rule[i.configKey]\"\n    ></ab-setting>\n\n    <!-- Offset field with auto-detect button -->\n    <ab-label :label=\"() => $t('homepage.rule.offset')\">\n      <div class=\"offset-controls\">\n        <input\n          v-model.number=\"rule.episode_offset\"\n          type=\"number\"\n          ab-input\n          class=\"offset-input\"\n        />\n        <ab-button\n          size=\"small\"\n          :loading=\"offsetLoading\"\n          @click=\"autoDetectOffset\"\n        >\n          {{ $t('homepage.rule.auto_detect') }}\n        </ab-button>\n      </div>\n    </ab-label>\n\n    <div v-if=\"offsetReason\" class=\"offset-reason\">{{ offsetReason }}</div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.rule-form {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.offset-controls {\n  display: flex;\n  align-items: center;\n  gap: 10px;\n  width: 100%;\n\n  @include forTablet {\n    width: auto;\n    min-width: 220px;\n  }\n\n  :deep(.ab-button) {\n    flex-shrink: 0;\n    white-space: nowrap;\n  }\n}\n\n.offset-input {\n  width: 80px;\n  flex-shrink: 0;\n\n  @include forMobile {\n    flex: 1;\n    min-width: 60px;\n  }\n}\n\n.offset-reason {\n  font-size: 12px;\n  color: var(--color-text-secondary);\n  padding-left: 2px;\n  margin-top: -8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-search-bar.vue",
    "content": "<script lang=\"ts\" setup>\nimport AbSearchModal from './search/ab-search-modal.vue';\n\nconst { showModal, provider, loading } = storeToRefs(useSearchStore());\nconst { toggleModal, getProviders } = useSearchStore();\n\nonMounted(() => {\n  getProviders();\n});\n</script>\n\n<template>\n  <!-- Search trigger button -->\n  <ab-search\n    :provider=\"provider\"\n    :loading=\"loading\"\n    @click=\"toggleModal\"\n  />\n\n  <!-- Search Modal -->\n  <AbSearchModal @close=\"toggleModal\" />\n</template>\n"
  },
  {
    "path": "webui/src/components/ab-setting.vue",
    "content": "<script lang=\"ts\" setup>\nimport { NDynamicTags } from 'naive-ui';\nimport type { AbSettingProps } from '#/components';\n\nwithDefaults(defineProps<AbSettingProps>(), {\n  css: '',\n  bottomLine: false,\n});\n\n \nconst data = defineModel<any>('data');\n</script>\n\n<template>\n  <div class=\"setting-item\">\n    <ab-label :label=\"label\">\n      <AbSwitch\n        v-if=\"type === 'switch'\"\n        v-model:checked=\"data\"\n        v-bind=\"prop\"\n        :class=\"css\"\n      ></AbSwitch>\n\n      <AbSelect\n        v-else-if=\"type === 'select'\"\n        v-model=\"data\"\n        v-bind=\"prop\"\n        :class=\"css\"\n      ></AbSelect>\n\n      <input\n        v-else-if=\"type === 'input'\"\n        v-model=\"data\"\n        ab-input\n        :class=\"css\"\n        v-bind=\"prop\"\n      />\n\n      <div v-else-if=\"type === 'dynamic-tags'\" class=\"dynamic-tags-wrapper\">\n        <NDynamicTags v-model:value=\"data\" size=\"small\"></NDynamicTags>\n      </div>\n    </ab-label>\n\n    <div v-if=\"bottomLine\" class=\"setting-divider\"></div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.setting-item {\n  width: 100%;\n\n  // Prevent fixed-width inputs from causing horizontal overflow on mobile\n  :deep(input),\n  :deep(select),\n  :deep(.n-select),\n  :deep(.n-input) {\n    max-width: 100%;\n  }\n}\n\n.setting-divider {\n  height: 1px;\n  background: var(--color-border);\n  margin-top: 12px;\n}\n\n.dynamic-tags-wrapper {\n  width: 100%;\n  overflow-x: auto;\n  padding-bottom: 2px;\n\n  @include forTablet {\n    max-width: 220px;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/ab-status-bar.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Menu, MenuButton, MenuItem, MenuItems } from '@headlessui/vue';\nimport { AddOne, International, System } from '@icon-park/vue-next';\n\nwithDefaults(\n  defineProps<{\n    running: boolean;\n    items: {\n      id: number;\n      icon: any;\n      label: string | (() => string);\n      handle?: () => void | Promise<void>;\n    }[];\n  }>(),\n  {\n    running: false,\n  }\n);\n\ndefineEmits<{\n  (e: 'changeLang'): void;\n  (e: 'clickAdd'): void;\n}>();\n\nfunction abLabel(label: string | (() => string)) {\n  if (typeof label === 'function') {\n    return label();\n  } else {\n    return label;\n  }\n}\n</script>\n\n<template>\n  <Menu>\n    <div class=\"status-bar\">\n      <div class=\"status-bar-actions\">\n        <button\n          class=\"status-bar-btn\"\n          aria-label=\"Switch language\"\n          @click=\"() => $emit('changeLang')\"\n        >\n          <International theme=\"outline\" size=\"1em\" />\n        </button>\n\n        <button\n          class=\"status-bar-btn\"\n          aria-label=\"Add RSS subscription\"\n          @click=\"() => $emit('clickAdd')\"\n        >\n          <AddOne theme=\"outline\" size=\"1em\" />\n        </button>\n\n        <MenuButton class=\"status-bar-btn\" aria-label=\"System menu\">\n          <System theme=\"outline\" size=\"1em\" />\n        </MenuButton>\n\n        <ab-status :running=\"running\" />\n      </div>\n\n      <MenuItems class=\"status-menu\">\n        <MenuItem v-for=\"i in items\" :key=\"i.id\" v-slot=\"{ active }\">\n          <div\n            class=\"status-menu-item\"\n            :class=\"[active && 'status-menu-item--active']\"\n            @click=\"() => i.handle && i.handle()\"\n          >\n            <div class=\"status-menu-item-icon\">\n              <Component :is=\"i.icon\" size=\"16\"></Component>\n            </div>\n            <div class=\"status-menu-item-label\">{{ abLabel(i.label) }}</div>\n          </div>\n        </MenuItem>\n      </MenuItems>\n    </div>\n  </Menu>\n</template>\n\n<style lang=\"scss\" scoped>\n.status-bar {\n  position: relative;\n}\n\n.status-bar-actions {\n  display: flex;\n  align-items: center;\n  gap: 2px;\n  font-size: 18px;\n\n  @include forTablet {\n    gap: 6px;\n    font-size: 20px;\n  }\n}\n\n.status-bar-btn {\n  cursor: pointer;\n  user-select: none;\n  color: var(--color-text-secondary);\n  transition: color var(--transition-fast), transform var(--transition-fast);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: transparent;\n  border: none;\n  // Ensure minimum touch target\n  min-width: 36px;\n  min-height: 36px;\n  padding: 6px;\n  border-radius: var(--radius-sm);\n\n  @include forTablet {\n    min-width: var(--touch-target);\n    min-height: var(--touch-target);\n    padding: 8px;\n  }\n\n  &:hover {\n    color: var(--color-primary);\n    transform: scale(1.1);\n  }\n\n  &:active {\n    transform: scale(0.95);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n.status-menu {\n  position: absolute;\n  top: 44px;\n  right: 0;\n  width: 180px;\n  padding: 4px;\n  border-radius: var(--radius-md);\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  box-shadow: var(--shadow-lg);\n  z-index: 50;\n  overflow: hidden;\n  animation: dropdown-in 150ms ease-out;\n  transform-origin: top right;\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n@keyframes dropdown-in {\n  from {\n    opacity: 0;\n    transform: scale(0.95) translateY(-4px);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1) translateY(0);\n  }\n}\n\n.status-menu-item {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  width: 100%;\n  min-height: var(--touch-target);\n  padding: 0 12px;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text);\n  transition: color var(--transition-fast), background-color var(--transition-fast);\n\n  &:hover,\n  &--active {\n    color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n\n  &:active {\n    transform: scale(0.98);\n  }\n}\n\n.status-menu-item-icon {\n  color: var(--color-primary);\n  display: flex;\n  align-items: center;\n}\n\n.status-menu-item-label {\n  font-size: 12px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/__tests__/ab-button.test.ts",
    "content": "/**\n * Tests for AbButton component\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { h, defineComponent } from 'vue';\nimport AbButton from '../ab-button.vue';\n\n// Mock naive-ui NSpin component\nvi.mock('naive-ui', () => ({\n  NSpin: defineComponent({\n    props: ['show', 'size'],\n    setup(props, { slots }) {\n      return () => h('div', { class: 'n-spin-mock' }, slots.default?.());\n    },\n  }),\n}));\n\ndescribe('AbButton', () => {\n  describe('rendering', () => {\n    it('should render as button by default', () => {\n      const wrapper = mount(AbButton, {\n        slots: {\n          default: 'Click me',\n        },\n      });\n\n      expect(wrapper.element.tagName).toBe('BUTTON');\n      expect(wrapper.text()).toContain('Click me');\n    });\n\n    it('should render as anchor when link is provided', () => {\n      const wrapper = mount(AbButton, {\n        props: {\n          link: 'https://example.com',\n        },\n        slots: {\n          default: 'Click me',\n        },\n      });\n\n      expect(wrapper.element.tagName).toBe('A');\n      expect(wrapper.attributes('href')).toBe('https://example.com');\n    });\n\n    it('should render slot content', () => {\n      const wrapper = mount(AbButton, {\n        slots: {\n          default: '<span>Custom Content</span>',\n        },\n      });\n\n      expect(wrapper.html()).toContain('Custom Content');\n    });\n  });\n\n  describe('props', () => {\n    describe('type', () => {\n      it('should have primary type by default', () => {\n        const wrapper = mount(AbButton);\n\n        expect(wrapper.classes()).toContain('btn--primary');\n      });\n\n      it('should apply secondary type class', () => {\n        const wrapper = mount(AbButton, {\n          props: { type: 'secondary' },\n        });\n\n        expect(wrapper.classes()).toContain('btn--secondary');\n      });\n\n      it('should apply warn type class', () => {\n        const wrapper = mount(AbButton, {\n          props: { type: 'warn' },\n        });\n\n        expect(wrapper.classes()).toContain('btn--warn');\n      });\n    });\n\n    describe('size', () => {\n      it('should have normal size by default', () => {\n        const wrapper = mount(AbButton);\n\n        expect(wrapper.classes()).toContain('btn--normal');\n      });\n\n      it('should apply big size class', () => {\n        const wrapper = mount(AbButton, {\n          props: { size: 'big' },\n        });\n\n        expect(wrapper.classes()).toContain('btn--big');\n      });\n\n      it('should apply small size class', () => {\n        const wrapper = mount(AbButton, {\n          props: { size: 'small' },\n        });\n\n        expect(wrapper.classes()).toContain('btn--small');\n      });\n    });\n\n    describe('loading', () => {\n      it('should be false by default', () => {\n        const wrapper = mount(AbButton);\n\n        // Verify component renders with default loading=false\n        expect(wrapper.vm.$props.loading).toBe(false);\n      });\n    });\n  });\n\n  describe('events', () => {\n    it('should emit click event when clicked', async () => {\n      const wrapper = mount(AbButton);\n\n      await wrapper.trigger('click');\n\n      expect(wrapper.emitted('click')).toBeTruthy();\n      expect(wrapper.emitted('click')?.length).toBe(1);\n    });\n\n    it('should emit click event multiple times', async () => {\n      const wrapper = mount(AbButton);\n\n      await wrapper.trigger('click');\n      await wrapper.trigger('click');\n      await wrapper.trigger('click');\n\n      expect(wrapper.emitted('click')?.length).toBe(3);\n    });\n  });\n\n  describe('accessibility', () => {\n    it('should have btn class for styling', () => {\n      const wrapper = mount(AbButton);\n\n      expect(wrapper.classes()).toContain('btn');\n    });\n  });\n\n  describe('combined props', () => {\n    it('should apply multiple props correctly', () => {\n      const wrapper = mount(AbButton, {\n        props: {\n          type: 'warn',\n          size: 'big',\n        },\n        slots: {\n          default: 'Delete',\n        },\n      });\n\n      expect(wrapper.classes()).toContain('btn--warn');\n      expect(wrapper.classes()).toContain('btn--big');\n      expect(wrapper.text()).toContain('Delete');\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/components/basic/__tests__/ab-switch.test.ts",
    "content": "/**\n * Tests for AbSwitch component\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { mount } from '@vue/test-utils';\nimport { h, defineComponent, ref } from 'vue';\nimport AbSwitch from '../ab-switch.vue';\n\n// Mock @headlessui/vue Switch component\nvi.mock('@headlessui/vue', () => ({\n  Switch: defineComponent({\n    props: ['modelValue', 'as'],\n    emits: ['update:modelValue'],\n    setup(props, { emit, slots }) {\n      const toggle = () => {\n        emit('update:modelValue', !props.modelValue);\n      };\n      return () =>\n        h(\n          'div',\n          {\n            class: 'headlessui-switch-mock',\n            onClick: toggle,\n            'data-checked': props.modelValue,\n          },\n          slots.default?.()\n        );\n    },\n  }),\n}));\n\ndescribe('AbSwitch', () => {\n  describe('rendering', () => {\n    it('should render switch track', () => {\n      const wrapper = mount(AbSwitch);\n\n      expect(wrapper.find('.switch-track').exists()).toBe(true);\n    });\n\n    it('should render switch thumb', () => {\n      const wrapper = mount(AbSwitch);\n\n      expect(wrapper.find('.switch-thumb').exists()).toBe(true);\n    });\n  });\n\n  describe('checked state', () => {\n    it('should be unchecked by default', () => {\n      const wrapper = mount(AbSwitch);\n      const track = wrapper.find('.switch-track');\n      const thumb = wrapper.find('.switch-thumb');\n\n      expect(track.classes()).not.toContain('switch-track--checked');\n      expect(thumb.classes()).not.toContain('switch-thumb--checked');\n    });\n\n    it('should apply checked classes when checked is true', async () => {\n      const wrapper = mount(AbSwitch, {\n        props: {\n          checked: true,\n          'onUpdate:checked': (e: boolean) =>\n            wrapper.setProps({ checked: e }),\n        },\n      });\n\n      const track = wrapper.find('.switch-track');\n      const thumb = wrapper.find('.switch-thumb');\n\n      expect(track.classes()).toContain('switch-track--checked');\n      expect(thumb.classes()).toContain('switch-thumb--checked');\n    });\n\n    it('should not have checked classes when checked is false', () => {\n      const wrapper = mount(AbSwitch, {\n        props: {\n          checked: false,\n          'onUpdate:checked': () => {},\n        },\n      });\n\n      const track = wrapper.find('.switch-track');\n      const thumb = wrapper.find('.switch-thumb');\n\n      expect(track.classes()).not.toContain('switch-track--checked');\n      expect(thumb.classes()).not.toContain('switch-thumb--checked');\n    });\n  });\n\n  describe('v-model', () => {\n    it('should emit update:checked when toggled', async () => {\n      const wrapper = mount(AbSwitch, {\n        props: {\n          checked: false,\n          'onUpdate:checked': (e: boolean) =>\n            wrapper.setProps({ checked: e }),\n        },\n      });\n\n      // Find the HeadlessUI Switch mock and click it\n      const switchMock = wrapper.find('.headlessui-switch-mock');\n      await switchMock.trigger('click');\n\n      expect(wrapper.emitted('update:checked')).toBeTruthy();\n    });\n\n    it('should toggle from false to true', async () => {\n      let checked = false;\n      const wrapper = mount(AbSwitch, {\n        props: {\n          checked,\n          'onUpdate:checked': (e: boolean) => {\n            checked = e;\n            wrapper.setProps({ checked: e });\n          },\n        },\n      });\n\n      const switchMock = wrapper.find('.headlessui-switch-mock');\n      await switchMock.trigger('click');\n\n      expect(checked).toBe(true);\n    });\n\n    it('should toggle from true to false', async () => {\n      let checked = true;\n      const wrapper = mount(AbSwitch, {\n        props: {\n          checked,\n          'onUpdate:checked': (e: boolean) => {\n            checked = e;\n            wrapper.setProps({ checked: e });\n          },\n        },\n      });\n\n      const switchMock = wrapper.find('.headlessui-switch-mock');\n      await switchMock.trigger('click');\n\n      expect(checked).toBe(false);\n    });\n  });\n\n  describe('accessibility', () => {\n    it('should use HeadlessUI Switch component', () => {\n      const wrapper = mount(AbSwitch);\n\n      // The mock creates a div with this class\n      expect(wrapper.find('.headlessui-switch-mock').exists()).toBe(true);\n    });\n  });\n\n  describe('styling', () => {\n    it('should have correct track dimensions via CSS class', () => {\n      const wrapper = mount(AbSwitch);\n      const track = wrapper.find('.switch-track');\n\n      expect(track.exists()).toBe(true);\n      expect(track.classes()).toContain('switch-track');\n    });\n\n    it('should have correct thumb styling via CSS class', () => {\n      const wrapper = mount(AbSwitch);\n      const thumb = wrapper.find('.switch-thumb');\n\n      expect(thumb.exists()).toBe(true);\n      expect(thumb.classes()).toContain('switch-thumb');\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/components/basic/ab-adaptive-modal.vue",
    "content": "<script lang=\"ts\" setup>\nconst props = withDefaults(\n  defineProps<{\n    title: string;\n    maskClick?: boolean;\n    css?: string;\n    maxHeight?: string;\n  }>(),\n  {\n    title: '',\n    maskClick: true,\n    css: '',\n    maxHeight: '85dvh',\n  }\n);\n\nconst show = defineModel('show', { default: false });\n\nconst { isMobile } = useBreakpointQuery();\n</script>\n\n<template>\n  <!-- Mobile: Bottom sheet -->\n  <ab-bottom-sheet\n    v-if=\"isMobile\"\n    :show=\"show\"\n    :title=\"title\"\n    :closeable=\"maskClick\"\n    :max-height=\"maxHeight\"\n    @update:show=\"show = $event\"\n  >\n    <slot />\n  </ab-bottom-sheet>\n\n  <!-- Desktop/Tablet: Centered popup -->\n  <ab-popup\n    v-else\n    v-model:show=\"show\"\n    :title=\"title\"\n    :mask-click=\"maskClick\"\n    :css=\"css\"\n  >\n    <slot />\n  </ab-popup>\n</template>\n"
  },
  {
    "path": "webui/src/components/basic/ab-add.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbAdd from './ab-add.vue';\n\nconst meta: Meta<typeof AbAdd> = {\n  title: 'basic/ab-add',\n  component: AbAdd,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbAdd>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbAdd },\n    setup() {\n      return { args };\n    },\n    template: '<ab-add v-bind=\"args\"></ab-add>',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-add.vue",
    "content": "<script lang=\"ts\" setup>\nconst props = withDefaults(\n  defineProps<{\n    round?: boolean;\n    type?: 'large' | 'medium' | 'small';\n  }>(),\n  {\n    round: false,\n    type: 'large',\n  }\n);\n\ndefineEmits(['click']);\n\nconst buttonSize = computed(() => {\n  switch (props.type) {\n    case 'large':\n      return 'add-btn--large';\n    case 'medium':\n      return 'add-btn--medium';\n    case 'small':\n      return 'add-btn--small';\n  }\n});\n</script>\n\n<template>\n  <button\n    class=\"add-btn\"\n    :class=\"[buttonSize, round && 'add-btn--round']\"\n    aria-label=\"Add\"\n    @click=\"$emit('click')\"\n  >\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" class=\"add-btn-icon\">\n      <path d=\"M12 5v14M5 12h14\" stroke=\"currentColor\" stroke-width=\"2.5\" stroke-linecap=\"round\"/>\n    </svg>\n  </button>\n</template>\n\n<style lang=\"scss\" scoped>\n.add-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-primary);\n  color: #fff;\n  border: none;\n  cursor: pointer;\n  transition: background-color var(--transition-fast),\n              transform var(--transition-fast);\n\n  &:hover {\n    background: var(--color-primary-hover);\n  }\n\n  &:active {\n    transform: scale(0.92);\n  }\n\n  &--round {\n    border-radius: 50%;\n  }\n\n  &--large {\n    width: 36px;\n    height: 36px;\n    border-radius: var(--radius-md);\n\n    .add-btn-icon {\n      width: 18px;\n      height: 18px;\n    }\n  }\n\n  &--medium {\n    width: 28px;\n    height: 28px;\n    border-radius: var(--radius-sm);\n\n    .add-btn-icon {\n      width: 14px;\n      height: 14px;\n    }\n  }\n\n  &--small {\n    width: 20px;\n    height: 20px;\n    border-radius: 4px;\n\n    .add-btn-icon {\n      width: 10px;\n      height: 10px;\n    }\n  }\n\n  &--round.add-btn--large {\n    border-radius: 50%;\n  }\n\n  &--round.add-btn--medium {\n    border-radius: 50%;\n  }\n\n  &--round.add-btn--small {\n    border-radius: 50%;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-bottom-sheet.vue",
    "content": "<script lang=\"ts\" setup>\nimport { computed, ref, onMounted, onUnmounted, watch } from 'vue';\nimport { usePointerSwipe } from '@vueuse/core';\nimport {\n  Dialog,\n  DialogPanel,\n  TransitionChild,\n  TransitionRoot,\n} from '@headlessui/vue';\n\nconst props = withDefaults(\n  defineProps<{\n    show: boolean;\n    title?: string;\n    closeable?: boolean;\n    maxHeight?: string;\n  }>(),\n  {\n    closeable: true,\n    maxHeight: '85dvh',\n  }\n);\n\nconst emit = defineEmits<{\n  (e: 'update:show', value: boolean): void;\n  (e: 'close'): void;\n}>();\n\nconst sheetRef = ref<HTMLElement | null>(null);\nconst dragHandleRef = ref<HTMLElement | null>(null);\nconst translateY = ref(0);\nconst isDragging = ref(false);\nconst keyboardHeight = ref(0);\n\n// Handle iOS Safari virtual keyboard using visualViewport API\nfunction handleViewportResize() {\n  if (window.visualViewport) {\n    const viewport = window.visualViewport;\n    // Calculate keyboard height as the difference between window height and viewport height\n    const newKeyboardHeight = window.innerHeight - viewport.height;\n    keyboardHeight.value = Math.max(0, newKeyboardHeight);\n  }\n}\n\n// Set up visualViewport listeners when sheet is shown\nwatch(() => props.show, (isVisible) => {\n  if (isVisible && window.visualViewport) {\n    window.visualViewport.addEventListener('resize', handleViewportResize);\n    window.visualViewport.addEventListener('scroll', handleViewportResize);\n    handleViewportResize();\n  } else if (window.visualViewport) {\n    window.visualViewport.removeEventListener('resize', handleViewportResize);\n    window.visualViewport.removeEventListener('scroll', handleViewportResize);\n    keyboardHeight.value = 0;\n  }\n}, { immediate: true });\n\nonUnmounted(() => {\n  if (window.visualViewport) {\n    window.visualViewport.removeEventListener('resize', handleViewportResize);\n    window.visualViewport.removeEventListener('scroll', handleViewportResize);\n  }\n});\n\nconst sheetStyle = computed(() => {\n  const style: Record<string, string> = {};\n\n  // Apply keyboard offset for iOS Safari\n  if (keyboardHeight.value > 0) {\n    style.transform = `translateY(-${keyboardHeight.value}px)`;\n    style.transition = 'transform 0.25s ease-out';\n  }\n\n  // Apply drag offset\n  if (isDragging.value && translateY.value > 0) {\n    style.transform = `translateY(${translateY.value}px)`;\n    style.transition = 'none';\n  }\n\n  return style;\n});\n\nconst { distanceY } = usePointerSwipe(dragHandleRef, {\n  threshold: 10,\n  onSwipe() {\n    if (distanceY.value < 0) {\n      // Swiping down (distanceY is negative when going down in usePointerSwipe)\n      translateY.value = Math.abs(distanceY.value);\n      isDragging.value = true;\n    }\n  },\n  onSwipeEnd() {\n    isDragging.value = false;\n    if (translateY.value > 100) {\n      close();\n    }\n    translateY.value = 0;\n  },\n});\n\nfunction close() {\n  if (props.closeable) {\n    emit('update:show', false);\n    emit('close');\n  }\n}\n</script>\n\n<template>\n  <TransitionRoot :show=\"show\" as=\"template\">\n    <Dialog class=\"ab-bottom-sheet\" @close=\"close\">\n      <!-- Backdrop -->\n      <TransitionChild\n        enter=\"overlay-enter-active\"\n        enter-from=\"overlay-enter-from\"\n        leave=\"overlay-leave-active\"\n        leave-to=\"overlay-leave-to\"\n      >\n        <div class=\"ab-bottom-sheet__backdrop\" aria-hidden=\"true\" />\n      </TransitionChild>\n\n      <!-- Sheet panel -->\n      <TransitionChild\n        enter=\"sheet-enter-active\"\n        enter-from=\"sheet-enter-from\"\n        leave=\"sheet-leave-active\"\n        leave-to=\"sheet-leave-to\"\n      >\n        <div class=\"ab-bottom-sheet__container\">\n          <DialogPanel\n            ref=\"sheetRef\"\n            class=\"ab-bottom-sheet__panel\"\n            :style=\"[sheetStyle, { maxHeight }]\"\n          >\n            <!-- Drag handle -->\n            <div ref=\"dragHandleRef\" class=\"ab-bottom-sheet__handle\">\n              <div class=\"ab-bottom-sheet__handle-bar\" />\n            </div>\n\n            <!-- Title -->\n            <div v-if=\"title\" class=\"ab-bottom-sheet__header\">\n              <h3 class=\"ab-bottom-sheet__title\">{{ title }}</h3>\n            </div>\n\n            <!-- Content -->\n            <div class=\"ab-bottom-sheet__content\">\n              <slot />\n            </div>\n          </DialogPanel>\n        </div>\n      </TransitionChild>\n    </Dialog>\n  </TransitionRoot>\n</template>\n\n<style lang=\"scss\" scoped>\n.ab-bottom-sheet {\n  position: fixed;\n  inset: 0;\n  z-index: 100;\n  display: flex;\n  align-items: flex-end;\n\n  &__backdrop {\n    position: fixed;\n    inset: 0;\n    z-index: 100;\n    background: rgba(0, 0, 0, 0.4);\n    backdrop-filter: blur(4px);\n  }\n\n  &__container {\n    position: fixed;\n    inset: 0;\n    z-index: 101;\n    display: flex;\n    align-items: flex-end;\n    justify-content: center;\n    pointer-events: none;\n  }\n\n  &__panel {\n    position: relative;\n    z-index: 102;\n    width: 100%;\n    max-width: 640px;\n    max-height: 85dvh; // Use dynamic viewport height for iOS Safari keyboard support\n    background: var(--color-surface);\n    border-radius: var(--radius-xl) var(--radius-xl) 0 0;\n    box-shadow: var(--shadow-lg);\n    overflow: hidden;\n    display: flex;\n    flex-direction: column;\n    pointer-events: auto;\n    @include safeAreaBottom(padding-bottom);\n\n    // Fallback for browsers that don't support dvh\n    @supports not (max-height: 1dvh) {\n      max-height: 85vh;\n    }\n  }\n\n  &__handle {\n    display: flex;\n    justify-content: center;\n    padding: 12px 0 8px;\n    cursor: grab;\n    touch-action: none;\n\n    &:active {\n      cursor: grabbing;\n    }\n  }\n\n  &__handle-bar {\n    width: 36px;\n    height: 4px;\n    border-radius: var(--radius-full);\n    background: var(--color-border);\n  }\n\n  &__header {\n    padding: 0 20px 12px;\n    border-bottom: 1px solid var(--color-border);\n  }\n\n  &__title {\n    font-size: 18px;\n    font-weight: 600;\n    color: var(--color-text);\n    margin: 0;\n  }\n\n  &__content {\n    flex: 1;\n    overflow-y: auto;\n    padding: 16px 20px;\n    -webkit-overflow-scrolling: touch;\n\n    // Ensure inputs scroll into view when focused on iOS Safari\n    :deep(input),\n    :deep(textarea),\n    :deep(select) {\n      scroll-margin-bottom: 20px;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-button-multi.stories.ts",
    "content": "import type {Meta, StoryObj} from '@storybook/vue3';\n\nimport AbButtonMulti from './ab-button-multi.vue';\n\nconst meta: Meta<typeof AbButtonMulti> = {\n    title: 'basic/ab-button-multi',\n    component: AbButtonMulti,\n    tags: ['autodocs'],\n    argTypes: {\n        type: {\n            control: {type: 'select'},\n            options: ['primary', 'warn'],\n        },\n        size: {\n            control: {type: 'select'},\n            options: ['big', 'normal', 'small'],\n        },\n    },\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbButtonMulti>;\n\nexport const Template: Story = {\n    render: (args) => ({\n        components: {AbButtonMulti},\n        setup() {\n            return {args};\n        },\n        template: '<ab-button-multi v-bind=\"args\">button</ab-button-multi>',\n    }),\n};"
  },
  {
    "path": "webui/src/components/basic/ab-button-multi.vue",
    "content": "<script lang=\"ts\" setup>\nimport { NSpin } from 'naive-ui';\nimport { Down } from '@icon-park/vue-next';\n\nconst props = withDefaults(\n  defineProps<{\n    type?: 'primary' | 'warn';\n    size?: 'big' | 'normal' | 'small';\n    link?: string | null;\n    loading?: boolean;\n    selections: string[];\n  }>(),\n  {\n    type: 'primary',\n    size: 'normal',\n    link: null,\n    loading: false,\n  }\n);\n\ndefineEmits(['click']);\n\nconst selected = ref<string>(props.selections[0]);\nconst showSelections = ref<boolean>(false);\n</script>\n\n<template>\n  <div class=\"btn-multi-container\">\n    <div class=\"btn-multi\" :class=\"[`btn-multi--${size}`, `btn-multi--${type}`]\">\n      <Component\n        :is=\"link !== null ? 'a' : 'button'\"\n        :href=\"link\"\n        class=\"btn-multi-main\"\n        @click=\"$emit('click', selected)\"\n      >\n        <NSpin :show=\"loading\" :size=\"size === 'big' ? 'large' : 'small'\">\n          <div class=\"btn-multi-label\">{{ selected }}</div>\n        </NSpin>\n      </Component>\n      <div\n        class=\"btn-multi-arrow\"\n        @click=\"() => (showSelections = !showSelections)\"\n      >\n        <Down fill=\"white\" />\n      </div>\n    </div>\n\n    <div\n      v-if=\"showSelections\"\n      class=\"btn-multi-dropdown\"\n      :class=\"[`btn-multi--${size}`, `btn-multi--${type}`]\"\n    >\n      <div\n        v-for=\"selection in selections\"\n        :key=\"selection\"\n        class=\"btn-multi-option\"\n        @click=\"() => { selected = selection; showSelections = false; }\"\n      >\n        {{ selection }}\n      </div>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.btn-multi-container {\n  position: relative;\n  display: inline-flex;\n}\n\n.btn-multi {\n  display: flex;\n  align-items: center;\n  overflow: hidden;\n  color: #fff;\n\n  &--big {\n    border-radius: var(--radius-md);\n    width: 276px;\n    height: 44px;\n    font-size: 16px;\n  }\n\n  &--normal {\n    border-radius: var(--radius-sm);\n    min-width: 100px;\n    height: 36px;\n    font-size: 14px;\n  }\n\n  &--small {\n    border-radius: var(--radius-sm);\n    min-width: 80px;\n    height: 32px;\n    font-size: 13px;\n  }\n\n  &--primary {\n    .btn-multi-main,\n    .btn-multi-arrow {\n      background: var(--color-primary);\n    }\n    .btn-multi-main:hover,\n    .btn-multi-arrow:hover {\n      background: var(--color-primary-hover);\n    }\n  }\n\n  &--warn {\n    .btn-multi-main,\n    .btn-multi-arrow {\n      background: var(--color-danger);\n    }\n    .btn-multi-main:hover,\n    .btn-multi-arrow:hover {\n      filter: brightness(0.9);\n    }\n  }\n}\n\n.btn-multi-main {\n  flex: 1;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  height: 100%;\n  padding-left: 12px;\n  border: none;\n  outline: none;\n  color: inherit;\n  cursor: pointer;\n  transition: background-color var(--transition-fast);\n}\n\n.btn-multi-label {\n  font-size: inherit;\n}\n\n.btn-multi-arrow {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 0 12px;\n  height: 100%;\n  cursor: pointer;\n  user-select: none;\n  transition: background-color var(--transition-fast);\n}\n\n.btn-multi-dropdown {\n  position: absolute;\n  left: 0;\n  bottom: 100%;\n  margin-bottom: 4px;\n  z-index: 100;\n  overflow: hidden;\n  border-radius: var(--radius-sm);\n  box-shadow: var(--shadow-lg);\n  min-width: 100%;\n\n  &.btn-multi--primary {\n    .btn-multi-option {\n      background: var(--color-primary);\n      &:hover {\n        background: var(--color-primary-hover);\n      }\n    }\n  }\n\n  &.btn-multi--warn {\n    .btn-multi-option {\n      background: var(--color-danger);\n      &:hover {\n        filter: brightness(0.9);\n      }\n    }\n  }\n}\n\n.btn-multi-option {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  padding: 8px 12px;\n  cursor: pointer;\n  user-select: none;\n  color: #fff;\n  font-size: inherit;\n  transition: background-color var(--transition-fast);\n  white-space: nowrap;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-button.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbButton from './ab-button.vue';\n\nconst meta: Meta<typeof AbButton> = {\n  title: 'basic/ab-button',\n  component: AbButton,\n  tags: ['autodocs'],\n  argTypes: {\n    type: {\n      control: { type: 'select' },\n      options: ['primary', 'warn'],\n    },\n    size: {\n      control: { type: 'select' },\n      options: ['big', 'normal', 'small'],\n    },\n  },\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbButton>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbButton },\n    setup() {\n      return { args };\n    },\n    template: '<ab-button v-bind=\"args\">button</ab-button>',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-button.vue",
    "content": "<script lang=\"ts\" setup>\nimport { NSpin } from 'naive-ui';\n\nconst props = withDefaults(\n  defineProps<{\n    type?: 'primary' | 'secondary' | 'warn';\n    size?: 'big' | 'normal' | 'small';\n    link?: string | null;\n    loading?: boolean;\n  }>(),\n  {\n    type: 'primary',\n    size: 'normal',\n    link: null,\n    loading: false,\n  }\n);\n\ndefineEmits<{ click: [] }>();\n\nconst buttonSize = computed(() => {\n  switch (props.size) {\n    case 'big':\n      return 'btn--big';\n    case 'normal':\n      return 'btn--normal';\n    case 'small':\n      return 'btn--small';\n  }\n});\n</script>\n\n<template>\n  <Component\n    :is=\"link !== null ? 'a' : 'button'\"\n    :href=\"link\"\n    class=\"btn\"\n    :class=\"[`btn--${type}`, buttonSize]\"\n    @click=\"$emit('click')\"\n  >\n    <NSpin :show=\"loading\" :size=\"size === 'big' ? 'large' : 'small'\">\n      <span class=\"btn-content\">\n        <slot></slot>\n      </span>\n    </NSpin>\n  </Component>\n</template>\n\n<style lang=\"scss\" scoped>\n.btn {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  border: none;\n  color: var(--color-white);\n  font-weight: 500;\n  cursor: pointer;\n  user-select: none;\n  text-decoration: none;\n  transition: background-color var(--transition-fast),\n              transform var(--transition-fast),\n              box-shadow var(--transition-fast);\n\n  // Focus ring for keyboard navigation\n  &:focus {\n    outline: none;\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n\n  &:active:not(:disabled) {\n    transform: scale(0.97);\n  }\n\n  &:disabled {\n    opacity: 0.6;\n    cursor: not-allowed;\n  }\n\n  // Sizes\n  &--big {\n    border-radius: var(--radius-md);\n    font-size: 16px;\n    width: 100%;\n    max-width: 276px;\n    height: 44px;\n  }\n\n  &--normal {\n    border-radius: var(--radius-sm);\n    font-size: 14px;\n    width: 100%;\n    max-width: 170px;\n    height: 36px;\n  }\n\n  &--small {\n    border-radius: var(--radius-sm);\n    font-size: 13px;\n    min-width: 80px;\n    height: 32px;\n    padding: 0 14px;\n    gap: 6px;\n    white-space: nowrap;\n  }\n\n  // Types\n  &--primary {\n    background: var(--color-primary);\n\n    &:hover:not(:disabled) {\n      background: var(--color-primary-hover);\n      box-shadow: 0 2px 8px color-mix(in srgb, var(--color-primary) 30%, transparent);\n    }\n\n    &:focus-visible {\n      outline-color: var(--color-primary-hover);\n    }\n  }\n\n  &-content {\n    display: inline-flex;\n    align-items: center;\n    gap: 6px;\n  }\n\n  &--secondary {\n    background: var(--color-surface);\n    color: var(--color-primary);\n    border: 1px solid var(--color-border);\n\n    &:hover:not(:disabled) {\n      background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));\n      border-color: var(--color-primary);\n    }\n\n    &:focus-visible {\n      outline-color: var(--color-primary);\n    }\n  }\n\n  &--warn {\n    background: var(--color-danger);\n\n    &:hover:not(:disabled) {\n      filter: brightness(0.9);\n      box-shadow: 0 2px 8px color-mix(in srgb, var(--color-danger) 30%, transparent);\n    }\n\n    &:focus-visible {\n      outline-color: var(--color-danger);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-checkbox.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbCheckbox from './ab-checkbox.vue';\n\nconst meta: Meta<typeof AbCheckbox> = {\n  title: 'basic/ab-checkbox',\n  component: AbCheckbox,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbCheckbox>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbCheckbox },\n    setup() {\n      return { args };\n    },\n    template: '<ab-checkbox v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-checkbox.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Switch } from '@headlessui/vue';\n\nwithDefaults(\n  defineProps<{\n    small?: boolean;\n  }>(),\n  {\n    small: false,\n  }\n);\n\nconst checked = defineModel<boolean>({ default: false });\n</script>\n\n<template>\n  <Switch v-model=\"checked\" as=\"template\">\n    <div class=\"checkbox-wrapper\">\n      <slot name=\"before\"></slot>\n\n      <div\n        class=\"checkbox\"\n        :class=\"[\n          small ? 'checkbox--small' : 'checkbox--normal',\n          checked && 'checkbox--checked',\n        ]\"\n      >\n        <div\n          class=\"checkbox-inner\"\n          :class=\"[\n            small ? 'checkbox-inner--small' : 'checkbox-inner--normal',\n            checked && 'checkbox-inner--checked',\n          ]\"\n        ></div>\n      </div>\n\n      <slot name=\"after\"></slot>\n    </div>\n  </Switch>\n</template>\n\n<style lang=\"scss\" scoped>\n.checkbox-wrapper {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  cursor: pointer;\n  user-select: none;\n}\n\n.checkbox {\n  position: relative;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-surface);\n  border: 2px solid var(--color-primary);\n  transition: border-color var(--transition-fast),\n              background-color var(--transition-fast);\n\n  &--normal {\n    width: 24px;\n    height: 24px;\n    border-radius: var(--radius-sm);\n    border-width: 2px;\n  }\n\n  &--small {\n    width: 16px;\n    height: 16px;\n    border-radius: 4px;\n    border-width: 2px;\n  }\n\n  &--checked {\n    border-color: var(--color-primary);\n  }\n\n  &:hover:not(.checkbox--checked) {\n    .checkbox-inner {\n      background: var(--color-border-hover);\n    }\n  }\n}\n\n.checkbox-inner {\n  border-radius: 2px;\n  transition: background-color var(--transition-fast);\n  background: transparent;\n\n  &--normal {\n    width: 12px;\n    height: 12px;\n  }\n\n  &--small {\n    width: 8px;\n    height: 8px;\n  }\n\n  &--checked {\n    background: var(--color-primary);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-data-list.vue",
    "content": "<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\n\nexport interface DataListColumn {\n  key: string;\n  title: string;\n  render?: (row: Record<string, unknown>) => string;\n  hidden?: boolean;\n}\n\n \ntype DataItem = Record<string, any>;\n\nconst props = withDefaults(\n  defineProps<{\n    items: DataItem[];\n    columns: DataListColumn[];\n    selectable?: boolean;\n    keyField?: string;\n  }>(),\n  {\n    selectable: false,\n    keyField: 'id',\n  }\n);\n\nconst emit = defineEmits<{\n  (e: 'select', keys: unknown[]): void;\n  (e: 'action', action: string, item: DataItem): void;\n  (e: 'item-click', item: DataItem): void;\n}>();\n\nconst selectedKeys = ref<Set<unknown>>(new Set());\n\nconst visibleColumns = computed(() =>\n  props.columns.filter((col) => !col.hidden)\n);\n\nfunction toggleSelect(key: unknown) {\n  if (selectedKeys.value.has(key)) {\n    selectedKeys.value.delete(key);\n  } else {\n    selectedKeys.value.add(key);\n  }\n  emit('select', Array.from(selectedKeys.value));\n}\n\nfunction toggleSelectAll() {\n  if (selectedKeys.value.size === props.items.length) {\n    selectedKeys.value.clear();\n  } else {\n    props.items.forEach((item) => selectedKeys.value.add(item[props.keyField]));\n  }\n  emit('select', Array.from(selectedKeys.value));\n}\n\nfunction getCellValue(item: DataItem, column: DataListColumn): string {\n  if (column.render) {\n    return column.render(item);\n  }\n  return item[column.key] ?? '';\n}\n\ndefineExpose({ selectedKeys, toggleSelectAll });\n</script>\n\n<template>\n  <div class=\"ab-data-list\">\n    <!-- Select all header (when selectable) -->\n    <div v-if=\"selectable && items.length > 0\" class=\"ab-data-list__header\">\n      <label class=\"ab-data-list__select-all\">\n        <input\n          type=\"checkbox\"\n          :checked=\"selectedKeys.size === items.length && items.length > 0\"\n          :indeterminate=\"selectedKeys.size > 0 && selectedKeys.size < items.length\"\n          @change=\"toggleSelectAll\"\n        />\n        <span>{{ $t('common.selectAll') }}</span>\n      </label>\n      <span class=\"ab-data-list__count\">{{ items.length }} {{ $t('common.items') }}</span>\n    </div>\n\n    <!-- Items -->\n    <div\n      v-for=\"item in items\"\n      :key=\"item[keyField]\"\n      class=\"ab-data-list__item\"\n      @click=\"emit('item-click', item)\"\n    >\n      <!-- Checkbox -->\n      <div v-if=\"selectable\" class=\"ab-data-list__checkbox\" @click.stop>\n        <input\n          type=\"checkbox\"\n          :checked=\"selectedKeys.has(item[keyField])\"\n          @change=\"toggleSelect(item[keyField])\"\n        />\n      </div>\n\n      <!-- Card content -->\n      <div class=\"ab-data-list__card\">\n        <slot name=\"item\" :item=\"item\" :columns=\"visibleColumns\">\n          <!-- Default: key-value pairs -->\n          <div\n            v-for=\"col in visibleColumns\"\n            :key=\"col.key\"\n            class=\"ab-data-list__field\"\n          >\n            <span class=\"ab-data-list__label\">{{ col.title }}</span>\n            <span class=\"ab-data-list__value\">{{ getCellValue(item, col) }}</span>\n          </div>\n        </slot>\n      </div>\n    </div>\n\n    <!-- Empty state -->\n    <div v-if=\"items.length === 0\" class=\"ab-data-list__empty\">\n      <slot name=\"empty\">\n        <span>No data</span>\n      </slot>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.ab-data-list {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n\n  &__header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 8px 12px;\n  }\n\n  &__select-all {\n    display: flex;\n    align-items: center;\n    gap: 8px;\n    font-size: 14px;\n    color: var(--color-text-secondary);\n    cursor: pointer;\n\n    input {\n      width: 18px;\n      height: 18px;\n      accent-color: var(--color-primary);\n    }\n  }\n\n  &__count {\n    font-size: 12px;\n    color: var(--color-text-muted);\n  }\n\n  &__item {\n    display: flex;\n    align-items: stretch;\n    background: var(--color-surface);\n    border: 1px solid var(--color-border);\n    border-radius: var(--radius-md);\n    overflow: hidden;\n    cursor: pointer;\n    transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n    &:active {\n      border-color: var(--color-primary);\n      box-shadow: var(--shadow-sm);\n    }\n  }\n\n  &__checkbox {\n    display: flex;\n    align-items: center;\n    padding: 12px;\n    border-right: 1px solid var(--color-border);\n\n    input {\n      width: 18px;\n      height: 18px;\n      accent-color: var(--color-primary);\n    }\n  }\n\n  &__card {\n    flex: 1;\n    padding: 12px 16px;\n    display: flex;\n    flex-direction: column;\n    gap: 6px;\n    min-width: 0;\n  }\n\n  &__field {\n    display: flex;\n    justify-content: space-between;\n    align-items: baseline;\n    gap: 12px;\n  }\n\n  &__label {\n    font-size: 12px;\n    color: var(--color-text-muted);\n    flex-shrink: 0;\n  }\n\n  &__value {\n    font-size: 14px;\n    color: var(--color-text);\n    text-align: right;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n\n  &__empty {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    padding: 48px 16px;\n    color: var(--color-text-muted);\n    font-size: 14px;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-offset-mismatch-dialog.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Caution, Close } from '@icon-park/vue-next';\nimport type { OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';\n\nconst props = withDefaults(\n  defineProps<{\n    bangumiTitle: string;\n    parsedSeason: number;\n    parsedEpisode: number;\n    tmdbInfo: TMDBSummary | null;\n    suggestion: OffsetSuggestionDetail | null;\n  }>(),\n  {\n    tmdbInfo: null,\n    suggestion: null,\n  }\n);\n\nconst emit = defineEmits<{\n  (e: 'apply', offsets: { seasonOffset: number; episodeOffset: number }): void;\n  (e: 'keep'): void;\n  (e: 'cancel'): void;\n}>();\n\nconst { t } = useMyI18n();\n\nconst show = defineModel('show', { default: false });\n\n// Local editable offset values\nconst seasonOffset = ref(props.suggestion?.season_offset ?? 0);\nconst episodeOffset = ref(props.suggestion?.episode_offset ?? 0);\n\n// Watch for suggestion changes\nwatch(\n  () => props.suggestion,\n  (newVal) => {\n    if (newVal) {\n      seasonOffset.value = newVal.season_offset;\n      episodeOffset.value = newVal.episode_offset;\n    }\n  }\n);\n\n// Preview calculation\nconst preview = computed(() => {\n  const newSeason = props.parsedSeason + seasonOffset.value;\n  const newEpisode = props.parsedEpisode + episodeOffset.value;\n  const formatNum = (n: number) => (n < 10 ? `0${n}` : `${n}`);\n  return {\n    from: `S${formatNum(props.parsedSeason)}E${formatNum(props.parsedEpisode)}`,\n    to: `S${formatNum(Math.max(1, newSeason))}E${formatNum(Math.max(1, newEpisode))}`,\n  };\n});\n\n// Confidence badge color\nconst confidenceColor = computed(() => {\n  switch (props.suggestion?.confidence) {\n    case 'high':\n      return 'var(--color-error)';\n    case 'medium':\n      return 'var(--color-warning)';\n    default:\n      return 'var(--color-text-muted)';\n  }\n});\n\nfunction handleApply() {\n  emit('apply', {\n    seasonOffset: seasonOffset.value,\n    episodeOffset: episodeOffset.value,\n  });\n  show.value = false;\n}\n\nfunction handleKeep() {\n  emit('keep');\n  show.value = false;\n}\n\nfunction handleCancel() {\n  emit('cancel');\n  show.value = false;\n}\n</script>\n\n<template>\n  <Teleport to=\"body\">\n    <Transition name=\"modal\">\n      <div v-if=\"show\" class=\"dialog-backdrop\" @click.self=\"handleCancel\">\n        <div class=\"dialog-modal\" role=\"dialog\" aria-modal=\"true\">\n          <!-- Header -->\n          <header class=\"dialog-header\">\n            <div class=\"header-icon\">\n              <Caution theme=\"filled\" size=\"20\" />\n            </div>\n            <h2 class=\"dialog-title\">{{ t('offset.dialog_title') }}</h2>\n            <button class=\"close-btn\" aria-label=\"Close\" @click=\"handleCancel\">\n              <Close theme=\"outline\" size=\"18\" />\n            </button>\n          </header>\n\n          <!-- Content -->\n          <div class=\"dialog-content\">\n            <!-- Bangumi title -->\n            <div class=\"bangumi-title\">{{ bangumiTitle }}</div>\n\n            <!-- Comparison section -->\n            <div class=\"comparison\">\n              <!-- RSS parsed result -->\n              <div class=\"comparison-box\">\n                <div class=\"comparison-label\">{{ t('offset.parsed_result') }}</div>\n                <div class=\"comparison-value\">\n                  <span class=\"value-label\">{{ t('offset.season') }}:</span>\n                  <span class=\"value-num\">{{ parsedSeason }}</span>\n                </div>\n                <div class=\"comparison-value\">\n                  <span class=\"value-label\">{{ t('offset.episode') }}:</span>\n                  <span class=\"value-num\">{{ parsedEpisode }}</span>\n                </div>\n              </div>\n\n              <div class=\"comparison-vs\">&ne;</div>\n\n              <!-- TMDB data -->\n              <div class=\"comparison-box\">\n                <div class=\"comparison-label\">{{ t('offset.tmdb_data') }}</div>\n                <div v-if=\"tmdbInfo\" class=\"comparison-value\">\n                  <span class=\"value-label\">{{ t('offset.total_seasons') }}:</span>\n                  <span class=\"value-num\">{{ tmdbInfo.total_seasons }}</span>\n                </div>\n                <div v-if=\"tmdbInfo && tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)]\" class=\"comparison-value\">\n                  <span class=\"value-label\">S{{ parsedSeason + (suggestion?.season_offset ?? 0) }} {{ t('offset.episode') }}:</span>\n                  <span class=\"value-num\">{{ tmdbInfo.season_episode_counts[parsedSeason + (suggestion?.season_offset ?? 0)] }}</span>\n                </div>\n              </div>\n            </div>\n\n            <!-- Reason -->\n            <div v-if=\"suggestion?.reason\" class=\"reason-section\">\n              <span class=\"reason-badge\" :style=\"{ backgroundColor: confidenceColor }\">\n                {{ suggestion.confidence }}\n              </span>\n              <span class=\"reason-text\">{{ suggestion.reason }}</span>\n            </div>\n\n            <!-- Offset inputs -->\n            <div class=\"offset-section\">\n              <div class=\"offset-title\">{{ t('offset.suggested_offset') }}</div>\n              <div class=\"offset-row\">\n                <label class=\"offset-label\">{{ t('offset.season_offset') }}:</label>\n                <input\n                  v-model.number=\"seasonOffset\"\n                  type=\"number\"\n                  class=\"offset-input\"\n                />\n                <span class=\"offset-hint\">&rarr; S{{ parsedSeason }} {{ t('offset.season') === '季度' ? '变为' : 'becomes' }} S{{ Math.max(1, parsedSeason + seasonOffset) }}</span>\n              </div>\n              <div class=\"offset-row\">\n                <label class=\"offset-label\">{{ t('offset.episode_offset') }}:</label>\n                <input\n                  v-model.number=\"episodeOffset\"\n                  type=\"number\"\n                  class=\"offset-input\"\n                />\n                <span class=\"offset-hint\">&rarr; E{{ parsedEpisode }} {{ t('offset.season') === '季度' ? '保持' : 'stays' }} E{{ Math.max(1, parsedEpisode + episodeOffset) }}</span>\n              </div>\n            </div>\n\n            <!-- Preview -->\n            <div class=\"preview-section\">\n              <span class=\"preview-label\">{{ t('offset.preview') }}:</span>\n              <span class=\"preview-from\">{{ preview.from }}</span>\n              <span class=\"preview-arrow\">&rarr;</span>\n              <span class=\"preview-to\">{{ preview.to }}</span>\n            </div>\n          </div>\n\n          <!-- Footer -->\n          <footer class=\"dialog-footer\">\n            <ab-button size=\"small\" type=\"secondary\" @click=\"handleCancel\">\n              {{ t('offset.cancel') }}\n            </ab-button>\n            <ab-button size=\"small\" @click=\"handleKeep\">\n              {{ t('offset.keep') }}\n            </ab-button>\n            <ab-button size=\"small\" type=\"primary\" @click=\"handleApply\">\n              {{ t('offset.apply') }}\n            </ab-button>\n          </footer>\n        </div>\n      </div>\n    </Transition>\n  </Teleport>\n</template>\n\n<style lang=\"scss\" scoped>\n.dialog-backdrop {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-overlay);\n  z-index: var(--z-modal);\n  padding: 16px;\n}\n\n.dialog-modal {\n  width: 100%;\n  max-width: 480px;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n}\n\n.dialog-header {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--color-border);\n}\n\n.header-icon {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  background: #fef3c7;\n  color: #f59e0b;\n  border-radius: var(--radius-sm);\n}\n\n.dialog-title {\n  flex: 1;\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n}\n\n.close-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  background: transparent;\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n    color: var(--color-text);\n  }\n}\n\n.dialog-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 20px;\n}\n\n.bangumi-title {\n  font-size: 15px;\n  font-weight: 600;\n  color: var(--color-primary);\n  margin-bottom: 16px;\n  text-align: center;\n}\n\n.comparison {\n  display: flex;\n  align-items: stretch;\n  gap: 12px;\n  margin-bottom: 16px;\n}\n\n.comparison-box {\n  flex: 1;\n  padding: 12px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n}\n\n.comparison-label {\n  font-size: 12px;\n  font-weight: 600;\n  color: var(--color-text-secondary);\n  margin-bottom: 8px;\n  text-transform: uppercase;\n}\n\n.comparison-value {\n  display: flex;\n  justify-content: space-between;\n  font-size: 13px;\n  margin-bottom: 4px;\n\n  .value-label {\n    color: var(--color-text-muted);\n  }\n\n  .value-num {\n    font-weight: 600;\n    color: var(--color-text);\n  }\n}\n\n.comparison-vs {\n  display: flex;\n  align-items: center;\n  font-size: 24px;\n  color: var(--color-warning);\n  font-weight: bold;\n}\n\n.reason-section {\n  display: flex;\n  align-items: flex-start;\n  gap: 8px;\n  padding: 12px;\n  background: #fef9ed;\n  border: 1px solid #fde68a;\n  border-radius: var(--radius-md);\n  margin-bottom: 16px;\n}\n\n.reason-badge {\n  flex-shrink: 0;\n  padding: 2px 8px;\n  border-radius: var(--radius-full);\n  font-size: 11px;\n  font-weight: 600;\n  color: white;\n  text-transform: uppercase;\n}\n\n.reason-text {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  line-height: 1.4;\n}\n\n.offset-section {\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-bottom: 16px;\n}\n\n.offset-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 12px;\n}\n\n.offset-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  margin-bottom: 8px;\n\n  &:last-child {\n    margin-bottom: 0;\n  }\n}\n\n.offset-label {\n  flex-shrink: 0;\n  width: 100px;\n  font-size: 13px;\n  color: var(--color-text-secondary);\n}\n\n.offset-input {\n  width: 70px;\n  height: 32px;\n  padding: 0 8px;\n  font-size: 14px;\n  text-align: center;\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-sm);\n  background: var(--color-surface);\n  color: var(--color-text);\n\n  &:focus {\n    outline: none;\n    border-color: var(--color-primary);\n  }\n}\n\n.offset-hint {\n  flex: 1;\n  font-size: 12px;\n  color: var(--color-text-muted);\n}\n\n.preview-section {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 12px;\n  padding: 12px;\n  background: color-mix(in srgb, var(--color-primary) 10%, transparent);\n  border-radius: var(--radius-md);\n}\n\n.preview-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.preview-from {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text-muted);\n  text-decoration: line-through;\n}\n\n.preview-arrow {\n  font-size: 18px;\n  color: var(--color-primary);\n}\n\n.preview-to {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-primary);\n}\n\n.dialog-footer {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  gap: 8px;\n  padding: 16px 20px;\n  border-top: 1px solid var(--color-border);\n}\n\n// Modal transition\n.modal-enter-active,\n.modal-leave-active {\n  transition: opacity 200ms ease;\n\n  .dialog-modal {\n    transition: transform 200ms ease, opacity 200ms ease;\n  }\n}\n\n.modal-enter-from,\n.modal-leave-to {\n  opacity: 0;\n\n  .dialog-modal {\n    transform: scale(0.95) translateY(10px);\n    opacity: 0;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-page-title.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbPageTitle from './ab-page-title.vue';\n\nconst meta: Meta<typeof AbPageTitle> = {\n  title: 'basic/ab-PageTitle',\n  component: AbPageTitle,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbPageTitle>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbPageTitle },\n    setup() {\n      return { args };\n    },\n    template: '<ab-page-title v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-page-title.vue",
    "content": "<script lang=\"ts\" setup>\nwithDefaults(\n  defineProps<{\n    title: string;\n  }>(),\n  {\n    title: 'title',\n  }\n);\n</script>\n\n<template>\n  <div class=\"page-title\">\n    <h1 class=\"page-title-text\">{{ title }}</h1>\n    <div class=\"page-title-accent\"></div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-title {\n  display: none;\n  align-items: center;\n  gap: 12px;\n  flex-shrink: 0;\n\n  @include forTablet {\n    display: flex;\n  }\n}\n\n.page-title-text {\n  font-size: 22px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n  transition: color var(--transition-normal);\n\n  @include forMobile {\n    font-size: 18px;\n  }\n}\n\n.page-title-accent {\n  width: 120px;\n  height: 3px;\n  border-radius: var(--radius-full);\n  background: linear-gradient(90deg, var(--color-primary), var(--color-primary-hover));\n  opacity: 0.6;\n\n  @include forMobile {\n    width: 80px;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-pull-refresh.vue",
    "content": "<script lang=\"ts\" setup>\nimport { computed, ref } from 'vue';\n\nconst props = withDefaults(\n  defineProps<{\n    loading?: boolean;\n    threshold?: number;\n    disabled?: boolean;\n  }>(),\n  {\n    loading: false,\n    threshold: 60,\n    disabled: false,\n  }\n);\n\nconst emit = defineEmits<{\n  (e: 'refresh'): void;\n}>();\n\nconst containerRef = ref<HTMLElement | null>(null);\nconst pullDistance = ref(0);\nconst isPulling = ref(false);\nconst startY = ref(0);\n\nconst pullStyle = computed(() => {\n  if (pullDistance.value > 0) {\n    return {\n      transform: `translateY(${Math.min(pullDistance.value, props.threshold * 1.5)}px)`,\n      transition: isPulling.value ? 'none' : 'transform var(--transition-normal)',\n    };\n  }\n  return {\n    transition: 'transform var(--transition-normal)',\n  };\n});\n\nconst indicatorOpacity = computed(() => {\n  return Math.min(pullDistance.value / props.threshold, 1);\n});\n\nconst indicatorRotation = computed(() => {\n  return Math.min(pullDistance.value / props.threshold, 1) * 180;\n});\n\nconst isTriggered = computed(() => pullDistance.value >= props.threshold);\n\nfunction onTouchStart(e: TouchEvent) {\n  if (props.disabled || props.loading) return;\n  const container = containerRef.value;\n  if (!container || container.scrollTop > 0) return;\n\n  startY.value = e.touches[0].clientY;\n  isPulling.value = true;\n}\n\nfunction onTouchMove(e: TouchEvent) {\n  if (!isPulling.value || props.disabled || props.loading) return;\n  const container = containerRef.value;\n  if (!container || container.scrollTop > 0) {\n    isPulling.value = false;\n    pullDistance.value = 0;\n    return;\n  }\n\n  const currentY = e.touches[0].clientY;\n  const diff = currentY - startY.value;\n\n  if (diff > 0) {\n    // Apply resistance: the further you pull, the harder it gets\n    pullDistance.value = diff * 0.5;\n    e.preventDefault();\n  }\n}\n\nfunction onTouchEnd() {\n  if (!isPulling.value) return;\n  isPulling.value = false;\n\n  if (isTriggered.value && !props.loading) {\n    emit('refresh');\n  }\n  pullDistance.value = 0;\n}\n</script>\n\n<template>\n  <div\n    ref=\"containerRef\"\n    class=\"ab-pull-refresh\"\n    @touchstart.passive=\"onTouchStart\"\n    @touchmove=\"onTouchMove\"\n    @touchend=\"onTouchEnd\"\n    @touchcancel=\"onTouchEnd\"\n  >\n    <!-- Pull indicator -->\n    <div class=\"ab-pull-refresh__indicator\" :style=\"{ opacity: indicatorOpacity }\">\n      <div\n        v-if=\"loading\"\n        class=\"ab-pull-refresh__spinner\"\n      />\n      <svg\n        v-else\n        class=\"ab-pull-refresh__arrow\"\n        :style=\"{ transform: `rotate(${indicatorRotation}deg)` }\"\n        width=\"20\"\n        height=\"20\"\n        viewBox=\"0 0 24 24\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        stroke-width=\"2\"\n      >\n        <path d=\"M12 19V5M5 12l7-7 7 7\" />\n      </svg>\n    </div>\n\n    <!-- Content -->\n    <div class=\"ab-pull-refresh__content\" :style=\"pullStyle\">\n      <slot />\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.ab-pull-refresh {\n  position: relative;\n  overflow-y: auto;\n  -webkit-overflow-scrolling: touch;\n  height: 100%;\n\n  &__indicator {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    display: flex;\n    justify-content: center;\n    align-items: center;\n    height: 40px;\n    color: var(--color-primary);\n    pointer-events: none;\n    z-index: 1;\n  }\n\n  &__arrow {\n    transition: transform var(--transition-fast);\n  }\n\n  &__spinner {\n    width: 20px;\n    height: 20px;\n    border: 2px solid var(--color-border);\n    border-top-color: var(--color-primary);\n    border-radius: 50%;\n    animation: spin 0.6s linear infinite;\n  }\n\n  &__content {\n    min-height: 100%;\n  }\n}\n\n@keyframes spin {\n  to {\n    transform: rotate(360deg);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-search.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbSearch from './ab-search.vue';\n\nconst meta: Meta<typeof AbSearch> = {\n  title: 'basic/ab-search',\n  component: AbSearch,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbSearch>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbSearch },\n    setup() {\n      return { args };\n    },\n    template: '<ab-search v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-search.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Search } from '@icon-park/vue-next';\nimport { NSpin } from 'naive-ui';\n\nwithDefaults(\n  defineProps<{\n    provider: string;\n    loading: boolean;\n  }>(),\n  {\n    provider: '',\n    loading: false,\n  }\n);\n\ndefineEmits<{ click: [] }>();\n</script>\n\n<template>\n  <button\n    class=\"search-trigger\"\n    role=\"search\"\n    aria-label=\"Open search\"\n    @click=\"$emit('click')\"\n  >\n    <NSpin v-if=\"loading\" :size=\"16\" class=\"search-spinner\" />\n    <Search v-else theme=\"outline\" size=\"18\" class=\"search-icon\" />\n\n    <span class=\"search-placeholder\">{{ $t('topbar.search.placeholder') }}</span>\n\n    <span class=\"search-provider\">{{ provider }}</span>\n  </button>\n</template>\n\n<style lang=\"scss\" scoped>\n.search-trigger {\n  display: flex;\n  align-items: center;\n  height: 36px;\n  padding: 0 6px 0 12px;\n  gap: 10px;\n  min-width: 240px;\n  border-radius: var(--radius-md);\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  cursor: pointer;\n  font-family: inherit;\n  transition: border-color var(--transition-fast),\n              background-color var(--transition-normal),\n              box-shadow var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    background: var(--color-surface);\n  }\n\n  &:focus-visible {\n    outline: none;\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 2px var(--color-primary-alpha);\n  }\n\n  @include forDesktop {\n    min-width: 320px;\n  }\n}\n\n.search-icon {\n  flex-shrink: 0;\n  color: var(--color-text-muted);\n  transition: color var(--transition-fast);\n\n  .search-trigger:hover & {\n    color: var(--color-primary);\n  }\n}\n\n.search-spinner {\n  flex-shrink: 0;\n}\n\n.search-placeholder {\n  flex: 1;\n  text-align: left;\n  font-size: 14px;\n  color: var(--color-text-muted);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.search-provider {\n  flex-shrink: 0;\n  padding: 4px 10px;\n  border-radius: var(--radius-sm);\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 12px;\n  font-weight: 500;\n  transition: background-color var(--transition-fast);\n\n  .search-trigger:hover & {\n    background: var(--color-primary-hover);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-select.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbSelect from './ab-select.vue';\n\nconst meta: Meta<typeof AbSelect> = {\n  title: 'basic/ab-select',\n  component: AbSelect,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbSelect>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbSelect },\n    setup() {\n      return { args };\n    },\n    template: '<ab-select v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-select.vue",
    "content": "<script lang=\"ts\" setup>\nimport {\n  Listbox,\n  ListboxButton,\n  ListboxOption,\n  ListboxOptions,\n} from '@headlessui/vue';\nimport { Down, Up } from '@icon-park/vue-next';\nimport { isObject, isString } from 'radash';\nimport type { SelectItem } from '#/components';\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue?: SelectItem | string;\n    items: Array<SelectItem | string>;\n  }>(),\n  {}\n);\n\nconst emit = defineEmits(['update:modelValue']);\n\nconst selected = ref<SelectItem | string>(\n  props.modelValue || (props.items?.[0] ?? '')\n);\n\nconst otherItems = computed(() => {\n  return (\n    props.items.filter((e) => {\n      if (isString(e) && isString(selected.value)) {\n        return e !== selected.value;\n      } else if (isObject(e) && isObject(selected.value)) {\n        return e.id !== selected.value.id;\n      } else {\n        return false;\n      }\n    }) ?? []\n  );\n});\n\nconst label = computed(() => {\n  if (isString(selected.value)) {\n    return selected.value;\n  } else {\n    return selected.value.label ?? selected.value.value;\n  }\n});\n\nfunction getLabel(item: SelectItem | string) {\n  if (isString(item)) {\n    return item;\n  } else {\n    return item.label ?? item.value;\n  }\n}\n\nfunction getDisabled(item: SelectItem | string) {\n  return isString(item) ? false : item.disabled;\n}\n\nwatch(selected, (val) => {\n  emit('update:modelValue', val);\n});\n</script>\n\n<template>\n  <Listbox v-slot=\"{ open }\" v-model=\"selected\">\n    <div class=\"select-wrapper\">\n      <ListboxButton class=\"select-button\">\n        <div class=\"select-value\">{{ label }}</div>\n        <div :class=\"[{ hidden: open }]\">\n          <Down :size=\"14\" />\n        </div>\n      </ListboxButton>\n\n      <ListboxOptions class=\"select-options\">\n        <div class=\"select-options-inner\">\n          <div class=\"select-options-list\">\n            <ListboxOption\n              v-for=\"item in otherItems\"\n              v-slot=\"{ active }\"\n              :key=\"isString(item) ? item : item.id\"\n              :value=\"item\"\n              :disabled=\"getDisabled(item)\"\n            >\n              <div\n                class=\"select-option\"\n                :class=\"[\n                  active && 'select-option--active',\n                  getDisabled(item) && 'select-option--disabled',\n                ]\"\n              >\n                {{ getLabel(item) }}\n              </div>\n            </ListboxOption>\n          </div>\n\n          <div :class=\"[{ hidden: !open }]\"><Up :size=\"14\" /></div>\n        </div>\n      </ListboxOptions>\n    </div>\n  </Listbox>\n</template>\n\n<style lang=\"scss\" scoped>\n.select-wrapper {\n  position: relative;\n  display: inline-flex;\n  flex-direction: column;\n  border-radius: var(--radius-sm);\n  border: 1px solid var(--color-border);\n  font-size: 12px;\n  padding: 4px 12px;\n  transition: border-color var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n}\n\n.select-button {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 16px;\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  color: var(--color-text);\n  padding: 0;\n}\n\n.select-value {\n  color: var(--color-text);\n}\n\n.select-options {\n  margin-top: 8px;\n}\n\n.select-options-inner {\n  display: flex;\n  align-items: flex-end;\n  justify-content: space-between;\n  gap: 16px;\n}\n\n.select-options-list {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.select-option {\n  cursor: pointer;\n  user-select: none;\n  color: var(--color-text-secondary);\n  transition: color var(--transition-fast);\n\n  &--active {\n    color: var(--color-primary);\n  }\n\n  &--disabled {\n    cursor: not-allowed;\n    opacity: 0.5;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-status.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbStatus from './ab-status.vue';\n\nconst meta: Meta<typeof AbStatus> = {\n  title: 'basic/ab-status',\n  component: AbStatus,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbStatus>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbStatus },\n    setup() {\n      return { args };\n    },\n    template: '<ab-status v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-status.vue",
    "content": "<script lang=\"ts\" setup>\nwithDefaults(\n  defineProps<{\n    running: boolean;\n    size?: string;\n  }>(),\n  {\n    running: false,\n    size: '1em',\n  }\n);\n</script>\n\n<template>\n  <div\n    class=\"status-indicator\"\n    :style=\"{ width: size, height: size }\"\n    role=\"status\"\n    :aria-label=\"running ? 'System running' : 'System stopped'\"\n  >\n    <div class=\"status-ring\">\n      <div\n        class=\"status-dot\"\n        :class=\"[running ? 'status-dot--running' : 'status-dot--stopped']\"\n      ></div>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.status-indicator {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.status-ring {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: 50%;\n  border: 2px solid var(--color-border);\n  transition: border-color var(--transition-normal);\n}\n\n.status-dot {\n  width: 10px;\n  height: 10px;\n  border-radius: 50%;\n  transition: background-color var(--transition-fast);\n\n  &--running {\n    background: var(--color-success);\n    box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);\n    animation: pulse 2s ease-in-out infinite;\n  }\n\n  &--stopped {\n    background: var(--color-danger);\n    box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);\n  }\n}\n\n@keyframes pulse {\n  0%, 100% { opacity: 1; }\n  50% { opacity: 0.6; }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-swipe-container.vue",
    "content": "<script lang=\"ts\" setup>\nimport { nextTick, onMounted, ref, watch } from 'vue';\n\nconst props = withDefaults(\n  defineProps<{\n    modelValue?: number;\n    showDots?: boolean;\n    itemCount?: number;\n  }>(),\n  {\n    modelValue: 0,\n    showDots: true,\n    itemCount: 0,\n  }\n);\n\nconst emit = defineEmits<{\n  (e: 'update:modelValue', index: number): void;\n  (e: 'change', index: number): void;\n}>();\n\nconst containerRef = ref<HTMLElement | null>(null);\nconst currentIndex = ref(props.modelValue);\n\nwatch(\n  () => props.modelValue,\n  (val) => {\n    currentIndex.value = val;\n    scrollToIndex(val);\n  }\n);\n\nfunction scrollToIndex(index: number) {\n  const container = containerRef.value;\n  if (!container) return;\n  const children = container.children;\n  if (children[index]) {\n    (children[index] as HTMLElement).scrollIntoView({\n      behavior: 'smooth',\n      block: 'nearest',\n      inline: 'start',\n    });\n  }\n}\n\nfunction onScroll() {\n  const container = containerRef.value;\n  if (!container) return;\n\n  const scrollLeft = container.scrollLeft;\n  const itemWidth = container.clientWidth;\n  const newIndex = Math.round(scrollLeft / itemWidth);\n\n  if (newIndex !== currentIndex.value) {\n    currentIndex.value = newIndex;\n    emit('update:modelValue', newIndex);\n    emit('change', newIndex);\n  }\n}\n\nfunction goTo(index: number) {\n  currentIndex.value = index;\n  emit('update:modelValue', index);\n  emit('change', index);\n  scrollToIndex(index);\n}\n\nonMounted(() => {\n  if (props.modelValue > 0) {\n    nextTick(() => scrollToIndex(props.modelValue));\n  }\n});\n\ndefineExpose({ goTo });\n</script>\n\n<template>\n  <div class=\"ab-swipe-container\">\n    <div\n      ref=\"containerRef\"\n      class=\"ab-swipe-container__track\"\n      @scroll.passive=\"onScroll\"\n    >\n      <slot />\n    </div>\n\n    <!-- Pagination dots -->\n    <div v-if=\"showDots && itemCount > 1\" class=\"ab-swipe-container__dots\">\n      <button\n        v-for=\"i in itemCount\"\n        :key=\"i\"\n        class=\"ab-swipe-container__dot\"\n        :class=\"{ 'ab-swipe-container__dot--active': currentIndex === i - 1 }\"\n        :aria-label=\"`Go to slide ${i}`\"\n        @click=\"goTo(i - 1)\"\n      />\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.ab-swipe-container {\n  position: relative;\n  width: 100%;\n\n  &__track {\n    display: flex;\n    overflow-x: auto;\n    scroll-snap-type: x mandatory;\n    scroll-behavior: smooth;\n    -webkit-overflow-scrolling: touch;\n\n    // Hide scrollbar\n    scrollbar-width: none;\n    &::-webkit-scrollbar {\n      display: none;\n    }\n\n    > * {\n      flex-shrink: 0;\n      width: 100%;\n      scroll-snap-align: start;\n    }\n  }\n\n  &__dots {\n    display: flex;\n    justify-content: center;\n    gap: 6px;\n    padding: 12px 0;\n  }\n\n  &__dot {\n    width: 8px;\n    height: 8px;\n    border-radius: 50%;\n    border: none;\n    background: var(--color-border);\n    cursor: pointer;\n    padding: 0;\n    transition: background var(--transition-fast), transform var(--transition-fast);\n\n    &--active {\n      background: var(--color-primary);\n      transform: scale(1.25);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-switch.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbSwitch from './ab-switch.vue';\n\nconst meta: Meta<typeof AbSwitch> = {\n  title: 'basic/ab-switch',\n  component: AbSwitch,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbSwitch>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbSwitch },\n    setup() {\n      return { args };\n    },\n    template: '<ab-switch v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-switch.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Switch } from '@headlessui/vue';\n\nconst checked = defineModel<boolean>('checked', {\n  default: false,\n});\n</script>\n\n<template>\n  <Switch v-model=\"checked\" as=\"template\">\n    <div class=\"switch-track\" :class=\"{ 'switch-track--checked': checked }\">\n      <div\n        class=\"switch-thumb\"\n        :class=\"{ 'switch-thumb--checked': checked }\"\n      ></div>\n    </div>\n  </Switch>\n</template>\n\n<style lang=\"scss\" scoped>\n.switch-track {\n  width: 44px;\n  height: 24px;\n  border-radius: var(--radius-full);\n  position: relative;\n  display: inline-flex;\n  align-items: center;\n  padding: 2px;\n  cursor: pointer;\n  user-select: none;\n  background: var(--color-border-hover);\n  transition: background-color var(--transition-fast);\n\n  &--checked {\n    background: var(--color-primary);\n  }\n}\n\n.switch-thumb {\n  width: 20px;\n  height: 20px;\n  border-radius: 50%;\n  background: #fff;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);\n  transition: transform var(--transition-fast);\n\n  &--checked {\n    transform: translateX(20px);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/basic/ab-tag.stories.ts",
    "content": "import type { Meta, StoryObj } from '@storybook/vue3';\n\nimport AbTag from './ab-tag.vue';\n\nconst meta: Meta<typeof AbTag> = {\n  title: 'basic/ab-tag',\n  component: AbTag,\n  tags: ['autodocs'],\n};\n\nexport default meta;\ntype Story = StoryObj<typeof AbTag>;\n\nexport const Template: Story = {\n  render: (args) => ({\n    components: { AbTag },\n    setup() {\n      return { args };\n    },\n    template: '<ab-tag v-bind=\"args\" />',\n  }),\n};\n"
  },
  {
    "path": "webui/src/components/basic/ab-tag.vue",
    "content": "<script lang=\"ts\" setup>\nwithDefaults(\n  defineProps<{\n    type: 'primary' | 'warn' | 'inactive' | 'active' | 'notify';\n    title: string;\n  }>(),\n  {\n    type: 'primary',\n    title: 'title',\n  }\n);\n</script>\n\n<template>\n  <span class=\"tag\" :class=\"`tag--${type}`\">\n    {{ title }}\n  </span>\n</template>\n\n<style lang=\"scss\" scoped>\n.tag {\n  display: inline-flex;\n  align-items: center;\n  padding: 2px 8px;\n  border-radius: var(--radius-full);\n  font-size: 10px;\n  font-weight: 500;\n  max-width: 80px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  border: 1px solid;\n  transition: background-color var(--transition-fast),\n              border-color var(--transition-fast),\n              color var(--transition-fast);\n\n  &--primary {\n    background: var(--color-primary-light);\n    border-color: var(--color-primary);\n    color: var(--color-primary);\n  }\n\n  &--warn {\n    background: color-mix(in srgb, var(--color-danger) 10%, transparent);\n    border-color: var(--color-danger);\n    color: var(--color-danger);\n  }\n\n  &--inactive {\n    background: var(--color-surface-hover);\n    border-color: var(--color-border);\n    color: var(--color-text-muted);\n  }\n\n  &--active {\n    background: color-mix(in srgb, var(--color-success) 10%, transparent);\n    border-color: var(--color-success);\n    color: var(--color-success);\n  }\n\n  &--notify {\n    background: color-mix(in srgb, var(--color-warning) 10%, transparent);\n    border-color: var(--color-warning);\n    color: var(--color-warning);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/layout/ab-mobile-nav.vue",
    "content": "<script lang=\"ts\" setup>\nimport {\n  Calendar,\n  Download,\n  Home,\n  Log,\n  Moon,\n  SettingTwo,\n  Sun,\n} from '@icon-park/vue-next';\nimport InlineSvg from 'vue-inline-svg';\n\nconst { t } = useMyI18n();\nconst route = useRoute();\nconst { isDark, toggle: toggleDark } = useDarkMode();\n\nconst RSS = h(\n  'span',\n  { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '20px', height: '20px' } },\n  h(InlineSvg, { src: './images/RSS.svg', width: '14', height: '14' })\n);\n\nconst navItems = [\n  { id: 1, icon: Home, label: () => t('sidebar.homepage'), path: '/bangumi' },\n  { id: 2, icon: Calendar, label: () => t('sidebar.calendar'), path: '/calendar' },\n  { id: 3, icon: RSS, label: () => t('sidebar.rss'), path: '/rss' },\n  { id: 5, icon: Download, label: () => t('sidebar.downloader'), path: '/downloader',\n    hidden: localStorage.getItem('enable_downloader_iframe') !== '1' },\n  { id: 6, icon: Log, label: () => t('sidebar.log'), path: '/log' },\n  { id: 7, icon: SettingTwo, label: () => t('sidebar.config'), path: '/config' },\n];\n\nconst visibleItems = computed(() => navItems.filter((i) => !i.hidden));\n</script>\n\n<template>\n  <nav class=\"mobile-nav\" role=\"navigation\" aria-label=\"Main navigation\">\n    <RouterLink\n      v-for=\"item in visibleItems\"\n      :key=\"item.id\"\n      :to=\"item.path\"\n      replace\n      class=\"mobile-nav__item\"\n      :class=\"{ 'mobile-nav__item--active': route.path === item.path }\"\n      :aria-label=\"item.label()\"\n    >\n      <Component :is=\"item.icon\" :size=\"18\" class=\"mobile-nav__icon\" />\n      <span class=\"mobile-nav__label\">{{ item.label() }}</span>\n    </RouterLink>\n\n    <button\n      class=\"mobile-nav__item\"\n      :aria-label=\"isDark ? 'Switch to light mode' : 'Switch to dark mode'\"\n      @click=\"toggleDark\"\n    >\n      <Moon v-if=\"!isDark\" :size=\"18\" class=\"mobile-nav__icon\" />\n      <Sun v-else :size=\"18\" class=\"mobile-nav__icon\" />\n      <span class=\"mobile-nav__label\">{{ isDark ? t('theme.light') : t('theme.dark') }}</span>\n    </button>\n  </nav>\n</template>\n\n<style lang=\"scss\" scoped>\n.mobile-nav {\n  display: flex;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-sm);\n  overflow-x: auto;\n  scrollbar-width: none;\n  @include safeAreaBottom(padding-bottom);\n\n  &::-webkit-scrollbar {\n    display: none;\n  }\n\n  &__item {\n    flex: 1;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    justify-content: center;\n    gap: 2px;\n    min-width: 0;\n    height: 56px;\n    padding: 6px 4px;\n    cursor: pointer;\n    user-select: none;\n    color: var(--color-text-muted);\n    background: transparent;\n    border: none;\n    border-radius: var(--radius-md);\n    transition: color var(--transition-fast),\n                background-color var(--transition-fast);\n    text-decoration: none;\n    font: inherit;\n    position: relative;\n\n    &:active {\n      transform: scale(0.95);\n    }\n\n    &--active {\n      color: var(--color-primary);\n\n      &::after {\n        content: '';\n        position: absolute;\n        top: 4px;\n        left: 50%;\n        transform: translateX(-50%);\n        width: 20px;\n        height: 3px;\n        border-radius: var(--radius-full);\n        background: var(--color-primary);\n      }\n    }\n  }\n\n  &__icon {\n    flex-shrink: 0;\n  }\n\n  &__label {\n    font-size: 11px;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    max-width: 100%;\n    line-height: 1.2;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/layout/ab-sidebar.vue",
    "content": "<script lang=\"tsx\" setup>\nimport {\n  Calendar,\n  Download,\n  Home,\n  Log,\n  Logout,\n  MenuUnfold,\n  Moon,\n  Play,\n  SettingTwo,\n  Sun,\n} from '@icon-park/vue-next';\nimport InlineSvg from 'vue-inline-svg';\n\nconst props = withDefaults(\n  defineProps<{\n    open?: boolean;\n  }>(),\n  {\n    open: false,\n  }\n);\n\nconst { t } = useMyI18n();\nconst { logout } = useAuth();\nconst route = useRoute();\nconst { isMobile, isTablet, isMobileOrTablet } = useBreakpointQuery();\nconst { isDark, toggle: toggleDark } = useDarkMode();\n\nconst show = ref(props.open);\nconst toggle = () => (show.value = !show.value);\n\nconst RSS = h(\n  'span',\n  { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '20px', height: '20px' } },\n  h(InlineSvg, { src: './images/RSS.svg', width: '16', height: '16' })\n);\n\nconst items = [\n  {\n    id: 1,\n    icon: Home,\n    label: () => t('sidebar.homepage'),\n    path: '/bangumi',\n  },\n  {\n    id: 2,\n    icon: Calendar,\n    label: () => t('sidebar.calendar'),\n    path: '/calendar',\n  },\n  {\n    id: 3,\n    icon: RSS,\n    label: () => t('sidebar.rss'),\n    path: '/rss',\n  },\n  {\n    id: 4,\n    icon: Play,\n    label: () => t('sidebar.player'),\n    path: '/player',\n  },\n  {\n    id: 5,\n    icon: Download,\n    label: () => t('sidebar.downloader'),\n    path: '/downloader',\n    hidden: localStorage.getItem('enable_downloader_iframe') !== '1',\n  },\n  {\n    id: 6,\n    icon: Log,\n    label: () => t('sidebar.log'),\n    path: '/log',\n  },\n  {\n    id: 7,\n    icon: SettingTwo,\n    label: () => t('sidebar.config'),\n    path: '/config',\n  },\n];\n\nfunction Exit() {\n  return (\n    <div\n      title=\"logout\"\n      class={[\n        'sidebar-item sidebar-item--action',\n        isMobileOrTablet.value ? 'h-40' : '',\n      ]}\n      onClick={logout}\n    >\n      <Logout size={20} />\n      {!isMobileOrTablet.value && show.value && <div class=\"sidebar-item-label\">{t('sidebar.logout')}</div>}\n    </div>\n  );\n}\n</script>\n\n<template>\n  <media-query>\n    <div\n      class=\"sidebar\"\n      :class=\"[show ? 'sidebar--expanded' : 'sidebar--collapsed']\"\n    >\n      <div class=\"sidebar-inner\">\n        <!-- Toggle header -->\n        <button\n          class=\"sidebar-header\"\n          :aria-label=\"show ? 'Collapse sidebar' : 'Expand sidebar'\"\n          :aria-expanded=\"show\"\n          @click=\"toggle\"\n        >\n          <div v-show=\"show\" class=\"sidebar-title\">\n            {{ $t('sidebar.title') }}\n          </div>\n          <MenuUnfold\n            theme=\"outline\"\n            size=\"20\"\n            class=\"sidebar-toggle-icon\"\n            :class=\"[show && 'sidebar-toggle-icon--open']\"\n          />\n        </button>\n\n        <!-- Navigation -->\n        <nav class=\"sidebar-nav\">\n          <RouterLink\n            v-for=\"i in items\"\n            :key=\"i.id\"\n            :to=\"i.path\"\n            replace\n            :title=\"i.label()\"\n            class=\"sidebar-item\"\n            :class=\"[\n              route.path === i.path && 'sidebar-item--active',\n              i.hidden && 'hidden',\n            ]\"\n          >\n            <Component :is=\"i.icon\" :size=\"20\" />\n            <div v-show=\"show\" class=\"sidebar-item-label\">{{ i.label() }}</div>\n          </RouterLink>\n        </nav>\n\n        <!-- Bottom actions -->\n        <div class=\"sidebar-footer\">\n          <button\n            class=\"sidebar-item sidebar-item--action sidebar-item--theme\"\n            :title=\"isDark ? 'Light mode' : 'Dark mode'\"\n            :aria-label=\"isDark ? 'Switch to light mode' : 'Switch to dark mode'\"\n            @click=\"toggleDark\"\n          >\n            <Moon v-if=\"!isDark\" :size=\"20\" />\n            <Sun v-else :size=\"20\" />\n            <div v-show=\"show\" class=\"sidebar-item-label\">\n              {{ isDark ? 'Light' : 'Dark' }}\n            </div>\n          </button>\n          <Exit />\n        </div>\n      </div>\n    </div>\n\n    <!-- Tablet: mini sidebar (icons only, no toggle) -->\n    <template #tablet>\n      <div class=\"sidebar sidebar--collapsed sidebar--tablet\">\n        <div class=\"sidebar-inner\">\n          <nav class=\"sidebar-nav\">\n            <RouterLink\n              v-for=\"i in items\"\n              :key=\"i.id\"\n              :to=\"i.path\"\n              replace\n              :title=\"i.label()\"\n              class=\"sidebar-item\"\n              :class=\"[\n                route.path === i.path && 'sidebar-item--active',\n                i.hidden && 'hidden',\n              ]\"\n            >\n              <Component :is=\"i.icon\" :size=\"20\" />\n            </RouterLink>\n          </nav>\n\n          <div class=\"sidebar-footer\">\n            <button\n              class=\"sidebar-item sidebar-item--action sidebar-item--theme\"\n              :title=\"isDark ? 'Light mode' : 'Dark mode'\"\n              @click=\"toggleDark\"\n            >\n              <Moon v-if=\"!isDark\" :size=\"20\" />\n              <Sun v-else :size=\"20\" />\n            </button>\n            <Exit />\n          </div>\n        </div>\n      </div>\n    </template>\n\n    <!-- Mobile: enhanced bottom navigation with labels -->\n    <template #mobile>\n      <ab-mobile-nav />\n    </template>\n  </media-query>\n</template>\n\n<style lang=\"scss\" scoped>\n.sidebar {\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-lg);\n  box-shadow: var(--shadow-sm);\n  transition: width var(--transition-normal),\n              background-color var(--transition-normal),\n              border-color var(--transition-normal);\n  overflow: hidden;\n\n  &--expanded {\n    width: 200px;\n  }\n\n  &--collapsed {\n    width: 64px;\n  }\n}\n\n.sidebar-inner {\n  display: flex;\n  flex-direction: column;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n}\n\n.sidebar-header {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 100%;\n  height: 52px;\n  padding: 0 20px;\n  cursor: pointer;\n  position: relative;\n  border: none;\n  border-bottom: 1px solid var(--color-border);\n  background: transparent;\n  transition: border-color var(--transition-normal),\n              background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n  }\n}\n\n.sidebar-title {\n  font-size: 18px;\n  font-weight: 600;\n  color: var(--color-text);\n  white-space: nowrap;\n  transition: opacity var(--transition-fast);\n}\n\n.sidebar-toggle-icon {\n  position: absolute;\n  left: 20px;\n  color: var(--color-text-secondary);\n  transition: transform var(--transition-normal);\n\n  &--open {\n    transform: rotate(180deg);\n  }\n}\n\n.sidebar-nav {\n  display: flex;\n  flex-direction: column;\n  padding: 8px;\n  gap: 2px;\n  flex: 1;\n  overflow-y: auto;\n}\n\n.sidebar-item {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  min-height: var(--touch-target);\n  padding: 10px 14px;\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  user-select: none;\n  color: var(--color-text-secondary);\n  text-decoration: none;\n  transition: color var(--transition-fast),\n              background-color var(--transition-fast);\n  white-space: nowrap;\n\n  &:hover {\n    color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: -2px;\n  }\n\n  &--active {\n    color: var(--color-primary);\n    background: var(--color-primary-light);\n    font-weight: 500;\n  }\n\n  &--action {\n    color: var(--color-text-muted);\n    border: none;\n    background: transparent;\n    width: 100%;\n    font: inherit;\n\n    &:hover {\n      color: var(--color-danger);\n      background: color-mix(in srgb, var(--color-danger) 8%, transparent);\n    }\n\n    &:focus-visible {\n      outline-color: var(--color-danger);\n    }\n  }\n\n  &--theme:hover {\n    color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n}\n\n.sidebar-item-label {\n  font-size: 14px;\n}\n\n.sidebar-footer {\n  display: flex;\n  flex-direction: column;\n  padding: 8px;\n  gap: 2px;\n  border-top: 1px solid var(--color-border);\n  margin-top: auto;\n  transition: border-color var(--transition-normal);\n}\n\n// Tablet: fixed mini sidebar\n.sidebar--tablet {\n  width: 56px;\n\n  .sidebar-nav {\n    padding: 4px;\n  }\n\n  .sidebar-item {\n    justify-content: center;\n    padding: 10px;\n  }\n\n  .sidebar-footer {\n    padding: 4px;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/layout/ab-topbar.vue",
    "content": "<script lang=\"ts\" setup>\nimport {\n  Format,\n  Me,\n  Pause,\n  PlayOne,\n  Power,\n  Refresh,\n  Search,\n} from '@icon-park/vue-next';\nimport { ruleTemplate } from '#/bangumi';\n\nconst { t, changeLocale } = useMyI18n();\nconst { running, onUpdate, offUpdate } = useAppInfo();\nconst { showAddRss: showAddRSS, closeAddRss } = useAddRss();\nconst { toggleModal: openSearch } = useSearchStore();\nconst { isMobile } = useBreakpointQuery();\n\nconst showAccount = ref(false);\nconst rssRule = ref(ruleTemplate);\n\nconst { start, pause, shutdown, restart, resetRule } = useProgramStore();\nconst { refreshPoster } = useBangumiStore();\n\nconst items = [\n  {\n    id: 1,\n    label: () => t('topbar.start'),\n    icon: PlayOne,\n    handle: start,\n  },\n  {\n    id: 2,\n    label: () => t('topbar.pause'),\n    icon: Pause,\n    handle: pause,\n  },\n  {\n    id: 3,\n    label: () => t('topbar.restart'),\n    icon: Refresh,\n    handle: restart,\n  },\n  {\n    id: 4,\n    label: () => t('topbar.shutdown'),\n    icon: Power,\n    handle: shutdown,\n  },\n  {\n    id: 5,\n    label: () => t('topbar.refresh_poster'),\n    icon: Refresh,\n    handle: refreshPoster,\n  },\n  {\n    id: 6,\n    label: () => t('topbar.reset_rule'),\n    icon: Format,\n    handle: resetRule,\n  },\n  {\n    id: 7,\n    label: () => t('topbar.profile.title'),\n    icon: Me,\n    handle: () => {\n      showAccount.value = true;\n    },\n  },\n];\n\nconst { isDark } = useDarkMode();\nconst onSearchFocus = ref(false);\n\nwatch(showAddRSS, (val) => {\n  if (!val) {\n    rssRule.value = ruleTemplate;\n    closeAddRss();\n  }\n});\n\nonBeforeMount(() => {\n  onUpdate();\n});\n\nonUnmounted(() => {\n  offUpdate();\n});\n</script>\n\n<template>\n  <div class=\"topbar\">\n    <!-- Logo -->\n    <div class=\"topbar-brand\">\n      <img\n        :src=\"isDark ? '/images/logo-light.svg' : '/images/logo.svg'\"\n        alt=\"favicon\"\n        class=\"topbar-logo\"\n      />\n      <img\n        v-if=\"!isMobile\"\n        v-show=\"onSearchFocus === false\"\n        :src=\"isDark ? '/images/AutoBangumi.svg' : '/images/AutoBangumi-dark.svg'\"\n        alt=\"AutoBangumi\"\n        class=\"topbar-wordmark\"\n      />\n    </div>\n\n    <!-- Desktop search bar -->\n    <div class=\"topbar-search\">\n      <ab-search-bar />\n    </div>\n\n    <!-- Mobile search button (fills space) -->\n    <button\n      v-if=\"isMobile\"\n      class=\"topbar-mobile-search\"\n      :aria-label=\"$t('topbar.search.click_to_search')\"\n      @click=\"openSearch\"\n    >\n      <Search theme=\"outline\" size=\"18\" />\n      <span class=\"topbar-mobile-search-text\">{{ $t('topbar.search.click_to_search') }}</span>\n    </button>\n\n    <!-- Right side actions -->\n    <div class=\"topbar-right\">\n      <ab-status-bar\n        :items=\"items\"\n        :running=\"running\"\n        @click-add=\"() => (showAddRSS = true)\"\n        @change-lang=\"changeLocale\"\n      />\n    </div>\n\n    <ab-change-account v-model:show=\"showAccount\"></ab-change-account>\n    <ab-add-rss\n      v-model:show=\"showAddRSS\"\n      v-model:rule=\"rssRule\"\n    ></ab-add-rss>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.topbar {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  height: var(--topbar-height);\n  padding: 0 8px;\n\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  box-shadow: var(--shadow-sm);\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal),\n              box-shadow var(--transition-normal);\n\n  @include forTablet {\n    gap: 12px;\n    padding: 0 12px;\n  }\n\n  @include forDesktop {\n    gap: 16px;\n    padding: 0 20px;\n    border-radius: var(--radius-lg);\n  }\n}\n\n.topbar-brand {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-shrink: 0;\n\n  @include forTablet {\n    gap: 10px;\n  }\n}\n\n.topbar-logo {\n  width: 20px;\n  height: 20px;\n\n  @include forDesktop {\n    width: 24px;\n    height: 24px;\n  }\n}\n\n.topbar-wordmark {\n  height: 16px;\n  position: relative;\n\n  @include forDesktop {\n    height: 20px;\n  }\n}\n\n.topbar-search {\n  display: none;\n\n  @include forTablet {\n    display: block;\n    flex: 1;\n    max-width: 400px;\n  }\n}\n\n.topbar-mobile-search {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex: 1;\n  height: 34px;\n  padding: 0 12px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface-hover);\n  color: var(--color-text-muted);\n  cursor: pointer;\n  transition: color var(--transition-fast),\n              border-color var(--transition-fast),\n              background-color var(--transition-fast);\n\n  &:hover {\n    color: var(--color-primary);\n    border-color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n.topbar-mobile-search-text {\n  font-size: 13px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.topbar-right {\n  flex-shrink: 0;\n}\n\n.topbar-right {\n  margin-left: auto;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/media-query.vue",
    "content": "<script lang=\"ts\" setup>\nconst { isMobile, isTablet } = useBreakpointQuery();\n</script>\n\n<template>\n  <template v-if=\"isMobile\">\n    <slot name=\"mobile\"></slot>\n  </template>\n\n  <template v-else-if=\"isTablet\">\n    <slot name=\"tablet\">\n      <!-- Fallback: if no tablet slot provided, use mobile -->\n      <slot name=\"mobile\"></slot>\n    </slot>\n  </template>\n\n  <template v-else>\n    <slot></slot>\n  </template>\n</template>\n\n<style lang=\"scss\" scoped></style>\n"
  },
  {
    "path": "webui/src/components/search/ab-search-card.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ErrorPicture } from '@icon-park/vue-next';\nimport type { GroupedBangumi } from '@/store/search';\n\nconst props = defineProps<{\n  group: GroupedBangumi;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'select', group: GroupedBangumi): void;\n}>();\n\nconst posterSrc = computed(() => resolvePosterUrl(props.group.poster_link));\n\n// Count of variants\nconst variantCount = computed(() => props.group.variants.length);\n</script>\n\n<template>\n  <div\n    class=\"search-card\"\n    role=\"button\"\n    tabindex=\"0\"\n    :aria-label=\"`View ${group.official_title}`\"\n    @click=\"emit('select', group)\"\n    @keydown.enter=\"emit('select', group)\"\n  >\n    <!-- Poster -->\n    <div class=\"card-poster\">\n      <template v-if=\"group.poster_link\">\n        <img :src=\"posterSrc\" :alt=\"group.official_title\" loading=\"lazy\" />\n      </template>\n      <template v-else>\n        <div class=\"card-placeholder\">\n          <ErrorPicture theme=\"outline\" size=\"32\" />\n        </div>\n      </template>\n      <!-- Variant count badge -->\n      <div v-if=\"variantCount > 1\" class=\"variant-badge\">\n        {{ variantCount }}\n      </div>\n    </div>\n\n    <!-- Info -->\n    <div class=\"card-info\">\n      <h3 class=\"card-title\">{{ group.official_title }}</h3>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.search-card {\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-lg);\n  cursor: pointer;\n  user-select: none;\n  overflow: hidden;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    box-shadow: var(--shadow-md);\n\n    .select-btn {\n      background: var(--color-primary);\n      color: #fff;\n    }\n\n    .card-poster img {\n      transform: scale(1.03);\n    }\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n\n  &:active {\n    transform: scale(0.98);\n  }\n}\n\n.card-poster {\n  position: relative;\n  width: 100%;\n  aspect-ratio: 5 / 7;\n  flex-shrink: 0;\n  overflow: hidden;\n  background: var(--color-surface-hover);\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    transition: transform var(--transition-normal);\n  }\n}\n\n.card-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-text-muted);\n}\n\n.variant-badge {\n  position: absolute;\n  top: 8px;\n  right: 8px;\n  min-width: 24px;\n  height: 24px;\n  padding: 0 8px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 12px;\n  font-weight: 600;\n  border-radius: var(--radius-full);\n  box-shadow: var(--shadow-md);\n}\n\n.card-info {\n  padding: 10px;\n}\n\n.card-title {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 2;\n  -webkit-box-orient: vertical;\n  line-height: 1.4;\n  margin: 0;\n  transition: color var(--transition-normal);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/search/ab-search-confirm.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CheckOne, Close, Copy, Down, ErrorPicture, Right } from '@icon-park/vue-next';\nimport { NDynamicTags, NSpin, useMessage } from 'naive-ui';\nimport type { BangumiRule, DetectOffsetResponse, OffsetSuggestionDetail, TMDBSummary } from '#/bangumi';\n\nconst props = defineProps<{\n  bangumi: BangumiRule;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'confirm', bangumi: BangumiRule): void;\n  (e: 'cancel'): void;\n}>();\n\nconst message = useMessage();\n\n// Local deep copy of bangumi for editing (prevents mutation of original prop)\nconst localBangumi = ref<BangumiRule>(JSON.parse(JSON.stringify(props.bangumi)));\n\n// Sync when props change\nwatch(() => props.bangumi, (newVal) => {\n  localBangumi.value = JSON.parse(JSON.stringify(newVal));\n  // Re-detect offset when bangumi changes\n  detectOffsetMismatch();\n}, { deep: true });\n\nconst posterSrc = computed(() => resolvePosterUrl(localBangumi.value.poster_link));\nconst showAdvanced = ref(false);\nconst copied = ref(false);\nconst offsetLoading = ref(false);\n\n// Offset mismatch detection state\nconst showOffsetDialog = ref(false);\nconst offsetSuggestion = ref<OffsetSuggestionDetail | null>(null);\nconst tmdbInfo = ref<TMDBSummary | null>(null);\n\n// Detect offset mismatch on mount\nasync function detectOffsetMismatch() {\n  if (!localBangumi.value.official_title || !localBangumi.value.season) return;\n\n  try {\n    const result: DetectOffsetResponse = await apiBangumi.detectOffset({\n      title: localBangumi.value.official_title,\n      parsed_season: localBangumi.value.season,\n      parsed_episode: 1, // Use episode 1 as baseline for detection\n    });\n\n    if (result.has_mismatch && result.suggestion) {\n      offsetSuggestion.value = result.suggestion;\n      tmdbInfo.value = result.tmdb_info;\n      showOffsetDialog.value = true;\n    }\n  } catch (e) {\n    console.error('Failed to detect offset mismatch:', e);\n  }\n}\n\n// Handle offset dialog apply\nfunction handleOffsetApply(offsets: { seasonOffset: number; episodeOffset: number }) {\n  localBangumi.value.season_offset = offsets.seasonOffset;\n  localBangumi.value.episode_offset = offsets.episodeOffset;\n  showOffsetDialog.value = false;\n}\n\n// Handle offset dialog keep (no change)\nfunction handleOffsetKeep() {\n  showOffsetDialog.value = false;\n}\n\n// Handle offset dialog cancel\nfunction handleOffsetCancel() {\n  showOffsetDialog.value = false;\n}\n\n// Run detection on mount\nonMounted(() => {\n  detectOffsetMismatch();\n});\n\n// Info tags for display (just values, no labels)\nconst infoTags = computed(() => {\n  const tags: { value: string; type: string }[] = [];\n  const { season, season_raw, dpi, subtitle, group_name } = localBangumi.value;\n\n  if (season || season_raw) {\n    const seasonDisplay = season_raw || (season ? `第${season}季` : '');\n    tags.push({ value: seasonDisplay, type: 'season' });\n  }\n\n  if (dpi) {\n    tags.push({ value: dpi, type: 'resolution' });\n  }\n\n  if (subtitle) {\n    tags.push({ value: subtitle, type: 'subtitle' });\n  }\n\n  if (group_name) {\n    tags.push({ value: group_name, type: 'group' });\n  }\n\n  return tags;\n});\n\n// Copy RSS link\nlet copyTimer: ReturnType<typeof setTimeout> | undefined;\n\nasync function copyRssLink() {\n  const rssLink = localBangumi.value.rss_link?.[0] || '';\n  if (rssLink) {\n    await navigator.clipboard.writeText(rssLink);\n    copied.value = true;\n    clearTimeout(copyTimer);\n    copyTimer = setTimeout(() => {\n      copied.value = false;\n    }, 2000);\n  }\n}\n\nonBeforeUnmount(() => {\n  clearTimeout(copyTimer);\n});\n\n// Auto detect offset\nasync function autoDetectOffset() {\n  if (!localBangumi.value.id) return;\n  offsetLoading.value = true;\n  try {\n    const result = await apiBangumi.suggestOffset(localBangumi.value.id);\n    localBangumi.value.episode_offset = result.suggested_offset;\n  } catch (e) {\n    console.error('Failed to detect offset:', e);\n    message.error('Failed to detect offset');\n  } finally {\n    offsetLoading.value = false;\n  }\n}\n\nfunction handleConfirm() {\n  emit('confirm', localBangumi.value);\n}\n</script>\n\n<template>\n  <div class=\"confirm-backdrop\" @click.self=\"emit('cancel')\">\n    <div class=\"confirm-modal\" role=\"dialog\" aria-modal=\"true\">\n      <!-- Header -->\n      <header class=\"confirm-header\">\n        <h2 class=\"confirm-title\">{{ $t('search.confirm.title') }}</h2>\n        <button class=\"close-btn\" aria-label=\"Close\" @click=\"emit('cancel')\">\n          <Close theme=\"outline\" size=\"18\" />\n        </button>\n      </header>\n\n      <!-- Content -->\n      <div class=\"confirm-content\">\n        <!-- Bangumi Info -->\n        <div class=\"bangumi-info\">\n          <div class=\"bangumi-poster\">\n            <template v-if=\"localBangumi.poster_link\">\n              <img :src=\"posterSrc\" :alt=\"localBangumi.official_title\" />\n            </template>\n            <template v-else>\n              <div class=\"poster-placeholder\">\n                <ErrorPicture theme=\"outline\" size=\"32\" />\n              </div>\n            </template>\n          </div>\n          <div class=\"bangumi-meta\">\n            <h3 class=\"bangumi-title\">{{ localBangumi.official_title }}</h3>\n            <p v-if=\"localBangumi.title_raw\" class=\"bangumi-subtitle\">{{ localBangumi.title_raw }}</p>\n            <p v-if=\"localBangumi.year\" class=\"bangumi-year\">{{ localBangumi.year }}</p>\n          </div>\n        </div>\n\n        <!-- Info Tags -->\n        <div v-if=\"infoTags.length > 0\" class=\"info-tags\">\n          <div\n            v-for=\"tag in infoTags\"\n            :key=\"tag.type\"\n            class=\"info-tag\"\n            :class=\"`info-tag--${tag.type}`\"\n          >\n            {{ tag.value }}\n          </div>\n        </div>\n\n        <!-- RSS Link -->\n        <div class=\"rss-section\">\n          <div class=\"info-row\">\n            <span class=\"info-label\">{{ $t('search.confirm.rss') }}:</span>\n            <span class=\"info-value info-value--link\">\n              {{ localBangumi.rss_link?.[0] || '-' }}\n            </span>\n            <button class=\"copy-btn\" :class=\"{ copied }\" @click=\"copyRssLink\">\n              <CheckOne v-if=\"copied\" theme=\"outline\" size=\"14\" />\n              <Copy v-else theme=\"outline\" size=\"14\" />\n            </button>\n          </div>\n        </div>\n\n        <!-- Advanced settings -->\n        <div class=\"advanced-section\">\n          <button class=\"advanced-toggle\" @click=\"showAdvanced = !showAdvanced\">\n            <component :is=\"showAdvanced ? Down : Right\" theme=\"outline\" size=\"14\" />\n            {{ $t('search.confirm.advanced') }}\n          </button>\n\n          <transition name=\"expand\">\n            <div v-show=\"showAdvanced\" class=\"advanced-content\">\n              <!-- Filter rules row -->\n              <div class=\"advanced-row advanced-row--tags\">\n                <label class=\"advanced-label\">{{ $t('search.confirm.filter') }}</label>\n                <div class=\"advanced-control filter-tags\">\n                  <NDynamicTags v-model:value=\"localBangumi.filter\" size=\"small\" />\n                </div>\n              </div>\n\n              <!-- Season Offset row -->\n              <div class=\"advanced-row\">\n                <label class=\"advanced-label\">{{ $t('homepage.rule.season_offset') }}</label>\n                <div class=\"advanced-control offset-controls\">\n                  <input\n                    v-model.number=\"localBangumi.season_offset\"\n                    type=\"number\"\n                    ab-input\n                    class=\"offset-input\"\n                  />\n                </div>\n              </div>\n\n              <!-- Episode Offset row -->\n              <div class=\"advanced-row\">\n                <label class=\"advanced-label\">{{ $t('homepage.rule.episode_offset') }}</label>\n                <div class=\"advanced-control offset-controls\">\n                  <input\n                    v-model.number=\"localBangumi.episode_offset\"\n                    type=\"number\"\n                    ab-input\n                    class=\"offset-input\"\n                  />\n                  <button\n                    class=\"detect-btn\"\n                    :disabled=\"offsetLoading\"\n                    @click=\"autoDetectOffset\"\n                  >\n                    <NSpin v-if=\"offsetLoading\" :size=\"14\" />\n                    <span v-else>{{ $t('homepage.rule.auto_detect') }}</span>\n                  </button>\n                </div>\n              </div>\n            </div>\n          </transition>\n        </div>\n      </div>\n\n      <!-- Footer -->\n      <footer class=\"confirm-footer\">\n        <button class=\"btn btn-secondary\" @click=\"emit('cancel')\">\n          {{ $t('common.cancel') }}\n        </button>\n        <button class=\"btn btn-primary\" @click=\"handleConfirm\">\n          {{ $t('search.confirm.subscribe') }}\n        </button>\n      </footer>\n    </div>\n\n    <!-- Offset Mismatch Dialog -->\n    <ab-offset-mismatch-dialog\n      v-model:show=\"showOffsetDialog\"\n      :bangumi-title=\"localBangumi.official_title\"\n      :parsed-season=\"localBangumi.season\"\n      :parsed-episode=\"1\"\n      :tmdb-info=\"tmdbInfo\"\n      :suggestion=\"offsetSuggestion\"\n      @apply=\"handleOffsetApply\"\n      @keep=\"handleOffsetKeep\"\n      @cancel=\"handleOffsetCancel\"\n    />\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.confirm-backdrop {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-overlay);\n  z-index: calc(var(--z-modal) + 10);\n  padding: 16px;\n}\n\n.confirm-modal {\n  width: 100%;\n  max-width: 480px;\n  max-height: 90vh;\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n  animation: modal-in 200ms ease-out;\n}\n\n@keyframes modal-in {\n  from {\n    opacity: 0;\n    transform: scale(0.95) translateY(10px);\n  }\n  to {\n    opacity: 1;\n    transform: scale(1) translateY(0);\n  }\n}\n\n.confirm-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  padding: 16px 20px;\n  border-bottom: 1px solid var(--color-border);\n}\n\n.confirm-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n}\n\n.close-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 32px;\n  height: 32px;\n  background: transparent;\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n    color: var(--color-text);\n  }\n}\n\n.confirm-content {\n  flex: 1;\n  overflow-y: auto;\n  padding: 20px;\n}\n\n// Bangumi info section\n.bangumi-info {\n  display: flex;\n  gap: 16px;\n  margin-bottom: 20px;\n}\n\n.bangumi-poster {\n  width: 80px;\n  height: 112px;\n  flex-shrink: 0;\n  border-radius: var(--radius-md);\n  overflow: hidden;\n  background: var(--color-surface-hover);\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n  }\n}\n\n.poster-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-text-muted);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n}\n\n.bangumi-meta {\n  flex: 1;\n  min-width: 0;\n}\n\n.bangumi-title {\n  font-size: 16px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0 0 4px;\n  line-height: 1.4;\n}\n\n.bangumi-subtitle {\n  font-size: 13px;\n  color: var(--color-text-muted);\n  margin: 0 0 8px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.bangumi-year {\n  font-size: 13px;\n  color: var(--color-text-muted);\n  margin: 4px 0 0;\n}\n\n// Info Tags\n.info-tags {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  margin-bottom: 16px;\n}\n\n.info-tag {\n  display: inline-flex;\n  align-items: center;\n  padding: 6px 14px;\n  border-radius: var(--radius-full);\n  font-size: 13px;\n  font-weight: 600;\n\n  &--season {\n    background: color-mix(in srgb, var(--color-primary) 12%, transparent);\n    color: var(--color-primary);\n  }\n\n  &--resolution {\n    background: color-mix(in srgb, var(--color-accent) 12%, transparent);\n    color: var(--color-accent);\n  }\n\n  &--subtitle {\n    background: color-mix(in srgb, var(--color-success) 12%, transparent);\n    color: var(--color-success);\n  }\n\n  &--group {\n    background: color-mix(in srgb, var(--color-warning) 12%, transparent);\n    color: var(--color-warning);\n  }\n}\n\n// RSS section\n.rss-section {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-bottom: 16px;\n}\n\n.info-row {\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n}\n\n.info-label {\n  flex-shrink: 0;\n  width: 70px;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.info-value {\n  flex: 1;\n  min-width: 0;\n  font-size: 13px;\n  color: var(--color-text);\n  word-break: break-all;\n\n  &--link {\n    color: var(--color-primary);\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n  }\n}\n\n.copy-btn {\n  flex-shrink: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 28px;\n  height: 28px;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: all var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    color: var(--color-primary);\n  }\n\n  &.copied {\n    background: var(--color-success);\n    border-color: var(--color-success);\n    color: #fff;\n  }\n}\n\n// Advanced section\n.advanced-section {\n  margin-bottom: 8px;\n}\n\n.advanced-toggle {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 8px 0;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  transition: color var(--transition-fast);\n\n  &:hover {\n    color: var(--color-text);\n  }\n}\n\n.advanced-content {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  padding: 16px;\n  background: var(--color-surface-hover);\n  border-radius: var(--radius-md);\n  margin-top: 8px;\n}\n\n.advanced-row {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  min-height: 32px;\n\n  &--tags {\n    align-items: flex-start;\n  }\n}\n\n.advanced-label {\n  flex-shrink: 0;\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  line-height: 32px;\n}\n\n.advanced-control {\n  display: flex;\n  justify-content: flex-end;\n\n  :deep(.n-dynamic-tags) {\n    justify-content: flex-end;\n    min-height: 32px;\n\n    .n-tag {\n      height: 28px;\n      margin: 2px 0 2px 6px !important;\n    }\n\n    .n-button {\n      height: 28px;\n    }\n  }\n}\n\n.offset-controls {\n  display: flex;\n  align-items: center;\n  justify-content: flex-end;\n  gap: 8px;\n  height: 32px;\n}\n\n.offset-input {\n  width: 70px;\n  height: 32px;\n  text-align: center;\n}\n\n.detect-btn {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 80px;\n  height: 32px;\n  padding: 0 14px;\n  font-size: 13px;\n  font-family: inherit;\n  font-weight: 500;\n  color: #fff;\n  background: var(--color-primary);\n  border: none;\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  white-space: nowrap;\n  transition: background-color var(--transition-fast);\n\n  &:hover:not(:disabled) {\n    background: var(--color-primary-hover);\n  }\n\n  &:disabled {\n    cursor: wait;\n  }\n}\n\n// Expand transition\n.expand-enter-active,\n.expand-leave-active {\n  transition: all var(--transition-normal);\n  overflow: hidden;\n}\n\n.expand-enter-from,\n.expand-leave-to {\n  opacity: 0;\n  max-height: 0;\n  margin-top: 0;\n  padding-top: 0;\n  padding-bottom: 0;\n}\n\n// Footer\n.confirm-footer {\n  display: flex;\n  justify-content: flex-end;\n  gap: 12px;\n  padding: 16px 20px;\n  border-top: 1px solid var(--color-border);\n}\n\n.btn {\n  height: 40px;\n  padding: 0 20px;\n  font-size: 14px;\n  font-family: inherit;\n  font-weight: 500;\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &-secondary {\n    background: var(--color-surface-hover);\n    border: 1px solid var(--color-border);\n    color: var(--color-text);\n\n    &:hover {\n      background: var(--color-surface);\n      border-color: var(--color-text-muted);\n    }\n  }\n\n  &-primary {\n    background: var(--color-primary);\n    border: 1px solid var(--color-primary);\n    color: #fff;\n\n    &:hover {\n      background: var(--color-primary-hover);\n      border-color: var(--color-primary-hover);\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/search/ab-search-filters.vue",
    "content": "<script lang=\"ts\" setup>\nimport { CloseSmall } from '@icon-park/vue-next';\nimport type { FilterOptions, SearchFilters } from '@/store/search';\n\nconst props = defineProps<{\n  filters: SearchFilters;\n  filterOptions: FilterOptions;\n  filteredCount: number;\n  totalCount: number;\n}>();\n\nconst emit = defineEmits<{\n  (e: 'toggle-filter', category: keyof SearchFilters, value: string): void;\n  (e: 'clear-filters'): void;\n}>();\n\nconst { t } = useMyI18n();\n\nconst categories = [\n  { key: 'group' as const, label: () => t('search.filter.group') },\n  { key: 'resolution' as const, label: () => t('search.filter.resolution') },\n  { key: 'subtitleType' as const, label: () => t('search.filter.subtitle_type') },\n  { key: 'season' as const, label: () => t('search.filter.season') },\n];\n\nconst hasActiveFilters = computed(() => {\n  return Object.values(props.filters).some((arr) => arr.length > 0);\n});\n\nfunction isActive(category: keyof SearchFilters, value: string): boolean {\n  return props.filters[category].includes(value);\n}\n\n// Limit displayed chips, show \"+N more\" for overflow\nconst MAX_VISIBLE_CHIPS = 8;\n\nfunction getVisibleOptions(options: string[]) {\n  return options.slice(0, MAX_VISIBLE_CHIPS);\n}\n\nfunction getOverflowCount(options: string[]) {\n  return Math.max(0, options.length - MAX_VISIBLE_CHIPS);\n}\n</script>\n\n<template>\n  <div v-if=\"Object.values(filterOptions).some(arr => arr.length > 0)\" class=\"filters-section\">\n    <!-- Filter rows -->\n    <div v-for=\"cat in categories\" :key=\"cat.key\" class=\"filter-row\">\n      <template v-if=\"filterOptions[cat.key].length > 0\">\n        <span class=\"filter-label\">{{ cat.label() }}:</span>\n        <div class=\"filter-chips\">\n          <button\n            v-for=\"option in getVisibleOptions(filterOptions[cat.key])\"\n            :key=\"option\"\n            class=\"filter-chip\"\n            :class=\"{ active: isActive(cat.key, option) }\"\n            @click=\"emit('toggle-filter', cat.key, option)\"\n          >\n            {{ option }}\n          </button>\n          <span\n            v-if=\"getOverflowCount(filterOptions[cat.key]) > 0\"\n            class=\"filter-overflow\"\n          >\n            +{{ getOverflowCount(filterOptions[cat.key]) }}\n          </span>\n        </div>\n      </template>\n    </div>\n\n    <!-- Footer: Clear + Count -->\n    <div class=\"filters-footer\">\n      <button\n        v-if=\"hasActiveFilters\"\n        class=\"clear-btn\"\n        @click=\"emit('clear-filters')\"\n      >\n        <CloseSmall :size=\"14\" />\n        {{ $t('search.filter.clear') }}\n      </button>\n      <span class=\"results-count\">\n        <template v-if=\"hasActiveFilters\">\n          {{ filteredCount }} / {{ totalCount }} {{ $t('search.filter.results') }}\n        </template>\n        <template v-else>\n          {{ totalCount }} {{ $t('search.filter.results') }}\n        </template>\n      </span>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.filters-section {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  padding: 12px 16px;\n  border-bottom: 1px solid var(--color-border);\n  background: var(--color-surface);\n  transition: background-color var(--transition-normal), border-color var(--transition-normal);\n}\n\n.filter-row {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n}\n\n.filter-label {\n  font-size: 12px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  min-width: 60px;\n  flex-shrink: 0;\n}\n\n.filter-chips {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n  align-items: center;\n}\n\n.filter-chip {\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  font-family: inherit;\n  border-radius: var(--radius-full);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface-hover);\n  color: var(--color-text-secondary);\n  cursor: pointer;\n  user-select: none;\n  transition: all var(--transition-fast);\n\n  &:hover:not(.active) {\n    border-color: var(--color-primary);\n    color: var(--color-primary);\n  }\n\n  &.active {\n    background: var(--color-primary);\n    border-color: var(--color-primary);\n    color: #fff;\n  }\n}\n\n.filter-overflow {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  padding: 0 8px;\n}\n\n.filters-footer {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-top: 4px;\n}\n\n.clear-btn {\n  display: flex;\n  align-items: center;\n  gap: 4px;\n  padding: 4px 10px;\n  font-size: 12px;\n  font-family: inherit;\n  color: var(--color-danger);\n  background: transparent;\n  border: 1px solid var(--color-danger);\n  border-radius: var(--radius-full);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-danger);\n    color: #fff;\n  }\n}\n\n.results-count {\n  font-size: 12px;\n  color: var(--color-text-muted);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/search/ab-search-modal.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Calendar, Down, Monitor, PeoplesTwo, Search, Translate } from '@icon-park/vue-next';\nimport { NSpin } from 'naive-ui';\nimport { onKeyStroke } from '@vueuse/core';\nimport AbSearchConfirm from './ab-search-confirm.vue';\nimport type { BangumiRule } from '#/bangumi';\nimport type { GroupedBangumi } from '@/store/search';\n\nconst emit = defineEmits<{\n  (e: 'close'): void;\n}>();\n\nconst message = useMessage();\nconst { getAll } = useBangumiStore();\n\nconst {\n  providers,\n  provider,\n  loading,\n  inputValue,\n  groupedResults,\n  showModal,\n  selectedResult,\n} = storeToRefs(useSearchStore());\n\nconst {\n  getProviders,\n  onSearch,\n  clearSearch,\n  closeSearch,\n  selectResult,\n  clearSelectedResult,\n} = useSearchStore();\n\nconst subscribing = ref(false);\n\nconst showProvider = ref(false);\nconst searchInputRef = ref<HTMLInputElement | null>(null);\n\n// Multi-select filter state\nconst activeFilters = ref<{\n  group: string[];\n  resolution: string[];\n  subtitle: string[];\n  season: string[];\n}>({\n  group: [],\n  resolution: [],\n  subtitle: [],\n  season: [],\n});\n\n// Max visible chips per category\nconst MAX_VISIBLE_CHIPS = 6;\n\n// Max visible variants per bangumi (fits ~4 rows, ~3 items per row)\nconst MAX_VISIBLE_VARIANTS = 12;\n\n// Track which categories are expanded\nconst expandedCategories = ref<Set<'group' | 'resolution' | 'subtitle' | 'season'>>(new Set());\n\n// Track which bangumi groups have expanded variants\nconst expandedVariants = ref<Set<string>>(new Set());\n\n// Close EventSource on unmount (prevents leak if navigating away mid-search)\nonBeforeUnmount(() => {\n  closeSearch();\n});\n\n// Close on Escape\nonKeyStroke('Escape', () => {\n  if (selectedResult.value) {\n    clearSelectedResult();\n  } else {\n    emit('close');\n  }\n});\n\n// Focus input on mount\nonMounted(() => {\n  getProviders();\n  nextTick(() => {\n    searchInputRef.value?.focus();\n  });\n});\n\n// Clear filters when search changes\nwatch(inputValue, () => {\n  activeFilters.value = { group: [], resolution: [], subtitle: [], season: [] };\n  expandedCategories.value.clear();\n  expandedVariants.value.clear();\n});\n\nfunction onSelectProvider(site: string) {\n  provider.value = site;\n  showProvider.value = false;\n}\n\nfunction handleVariantSelect(bangumi: BangumiRule) {\n  selectResult(bangumi);\n}\n\nasync function handleConfirm(bangumi: BangumiRule) {\n  subscribing.value = true;\n  try {\n    // Create RSS object from bangumi data\n    const rss = {\n      id: 0,\n      name: bangumi.official_title,\n      url: bangumi.rss_link?.[0] || '',\n      aggregate: false,\n      parser: 'mikan',\n      enabled: true,\n      connection_status: null,\n      last_checked_at: null,\n      last_error: null,\n    };\n    await apiDownload.subscribe(bangumi, rss);\n    message.success('订阅成功');\n    getAll();\n    clearSelectedResult();\n    emit('close');\n  } catch (e) {\n    console.error('Subscribe failed:', e);\n    message.error('订阅失败');\n  } finally {\n    subscribing.value = false;\n  }\n}\n\nfunction handleClose() {\n  clearSearch();\n  activeFilters.value = { group: [], resolution: [], subtitle: [], season: [] };\n  emit('close');\n}\n\n// Normalize resolution to standard format\nfunction normalizeResolution(raw: string): string {\n  if (!raw) return '';\n  const lower = raw.toLowerCase();\n\n  // 4K variants\n  if (lower.includes('4k') || lower.includes('2160') || lower.includes('uhd')) {\n    return '4K';\n  }\n  // 1080p variants\n  if (lower.includes('1080') || lower.includes('fhd') || lower.includes('1920')) {\n    return 'FHD';\n  }\n  // 720p variants\n  if (lower.includes('720') || lower === 'hd') {\n    return 'HD';\n  }\n  // 480p/SD\n  if (lower.includes('480') || lower === 'sd') {\n    return 'SD';\n  }\n\n  return raw; // Return original if no match\n}\n\n// Normalize subtitle to standard format\nfunction normalizeSubtitle(raw: string): string {\n  if (!raw) return '';\n  const lower = raw.toLowerCase();\n\n  // Check for dual/bilingual first\n  if (lower.includes('双语') || lower.includes('dual') ||\n      (lower.includes('简') && lower.includes('繁')) ||\n      (lower.includes('chs') && lower.includes('cht'))) {\n    return '双语';\n  }\n\n  // Simplified Chinese\n  if (lower.includes('简') || lower.includes('chs') || lower === 'sc') {\n    if (lower.includes('内嵌') || lower.includes('内封')) {\n      return '简/内嵌';\n    }\n    return '简';\n  }\n\n  // Traditional Chinese\n  if (lower.includes('繁') || lower.includes('cht') || lower === 'tc') {\n    if (lower.includes('内嵌') || lower.includes('内封')) {\n      return '繁/内嵌';\n    }\n    return '繁';\n  }\n\n  // Japanese\n  if (lower.includes('日') || lower.includes('jp') || lower.includes('ja')) {\n    return '日';\n  }\n\n  // Embedded/Internal subs\n  if (lower.includes('内嵌') || lower.includes('内封')) {\n    return '内嵌';\n  }\n\n  // External subs\n  if (lower.includes('外挂') || lower.includes('ass') || lower.includes('srt')) {\n    return '外挂';\n  }\n\n  return raw; // Return original if no match\n}\n\n// Normalize season to standard format\nfunction normalizeSeason(raw: string): string {\n  if (!raw) return '';\n\n  // Already in S1/S2 format\n  if (/^S\\d+$/i.test(raw)) {\n    return raw.toUpperCase();\n  }\n\n  // Extract season number\n  const match = raw.match(/(\\d+)/);\n  if (match) {\n    return `S${match[1]}`;\n  }\n\n  // Special types\n  const lower = raw.toLowerCase();\n  if (lower.includes('剧场') || lower.includes('movie') || lower.includes('劇場')) {\n    return '剧场版';\n  }\n  if (lower.includes('ova')) {\n    return 'OVA';\n  }\n  if (lower.includes('sp') || lower.includes('special')) {\n    return 'SP';\n  }\n\n  return raw;\n}\n\n// Get resolution display for variant (normalized)\nfunction getResolution(bangumi: BangumiRule): string {\n  return normalizeResolution(bangumi.dpi || '');\n}\n\n// Get subtitle display for variant (normalized)\nfunction getSubtitle(bangumi: BangumiRule): string {\n  return normalizeSubtitle(bangumi.subtitle || '');\n}\n\n// Get season display for variant (normalized)\nfunction getSeason(bangumi: BangumiRule): string {\n  if (bangumi.season_raw) return normalizeSeason(bangumi.season_raw);\n  if (bangumi.season) return `S${bangumi.season}`;\n  return '';\n}\n\n// Resolve poster URL for template\nfunction getPosterUrl(link: string | null | undefined): string {\n  return resolvePosterUrl(link);\n}\n\n// Extract all filter options from grouped results\nconst filterOptions = computed(() => {\n  const groups = new Set<string>();\n  const resolutions = new Set<string>();\n  const subtitles = new Set<string>();\n  const seasons = new Set<string>();\n\n  for (const group of groupedResults.value) {\n    for (const variant of group.variants) {\n      if (variant.group_name) groups.add(variant.group_name);\n      const res = getResolution(variant);\n      if (res) resolutions.add(res);\n      const sub = getSubtitle(variant);\n      if (sub) subtitles.add(sub);\n      const season = getSeason(variant);\n      if (season) seasons.add(season);\n    }\n  }\n\n  return {\n    group: Array.from(groups).sort(),\n    resolution: Array.from(resolutions).sort((a, b) => {\n      const order = ['4K', 'FHD', 'HD', 'SD'];\n      const aIndex = order.indexOf(a);\n      const bIndex = order.indexOf(b);\n      // Put unknown resolutions at the end\n      if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);\n      if (aIndex === -1) return 1;\n      if (bIndex === -1) return -1;\n      return aIndex - bIndex;\n    }),\n    subtitle: Array.from(subtitles).sort((a, b) => {\n      const order = ['简', '繁', '双语', '简/内嵌', '繁/内嵌', '内嵌', '外挂', '日'];\n      const aIndex = order.indexOf(a);\n      const bIndex = order.indexOf(b);\n      if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);\n      if (aIndex === -1) return 1;\n      if (bIndex === -1) return -1;\n      return aIndex - bIndex;\n    }),\n    season: Array.from(seasons).sort((a, b) => {\n      // Sort S1, S2, S3... then special types\n      const aMatch = a.match(/^S(\\d+)$/);\n      const bMatch = b.match(/^S(\\d+)$/);\n      if (aMatch && bMatch) {\n        return parseInt(aMatch[1]) - parseInt(bMatch[1]);\n      }\n      if (aMatch) return -1;\n      if (bMatch) return 1;\n      return a.localeCompare(b);\n    }),\n  };\n});\n\n// Check if filter section should be visible\nconst showFilters = computed(() => {\n  return groupedResults.value.length > 0 && (\n    filterOptions.value.group.length > 0 ||\n    filterOptions.value.resolution.length > 0 ||\n    filterOptions.value.subtitle.length > 0 ||\n    filterOptions.value.season.length > 0\n  );\n});\n\n// Toggle filter (multi-select)\nfunction toggleFilter(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string) {\n  const index = activeFilters.value[type].indexOf(value);\n  if (index === -1) {\n    activeFilters.value[type].push(value);\n  } else {\n    activeFilters.value[type].splice(index, 1);\n  }\n}\n\n// Check if filter chip is active\nfunction isFilterActive(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string): boolean {\n  return activeFilters.value[type].includes(value);\n}\n\n// Check if variant matches active filters\nfunction variantMatchesFilters(variant: BangumiRule): boolean {\n  const { group, resolution, subtitle, season } = activeFilters.value;\n\n  if (group.length > 0 && (!variant.group_name || !group.includes(variant.group_name))) {\n    return false;\n  }\n  if (resolution.length > 0) {\n    const res = getResolution(variant);\n    if (!res || !resolution.includes(res)) return false;\n  }\n  if (subtitle.length > 0) {\n    const sub = getSubtitle(variant);\n    if (!sub || !subtitle.includes(sub)) return false;\n  }\n  if (season.length > 0) {\n    const s = getSeason(variant);\n    if (!s || !season.includes(s)) return false;\n  }\n\n  return true;\n}\n\n// Get filtered variants for a group\nfunction getFilteredVariants(group: GroupedBangumi): BangumiRule[] {\n  const hasActiveFilter = Object.values(activeFilters.value).some(arr => arr.length > 0);\n  if (!hasActiveFilter) return group.variants;\n  return group.variants.filter(variantMatchesFilters);\n}\n\n// Check if any filter is active\nconst hasActiveFilters = computed(() => Object.values(activeFilters.value).some(arr => arr.length > 0));\n\n// Get all selected filter tags for display\nconst selectedFilterTags = computed(() => {\n  const tags: { type: 'group' | 'resolution' | 'subtitle' | 'season'; value: string }[] = [];\n  for (const value of activeFilters.value.group) {\n    tags.push({ type: 'group', value });\n  }\n  for (const value of activeFilters.value.resolution) {\n    tags.push({ type: 'resolution', value });\n  }\n  for (const value of activeFilters.value.subtitle) {\n    tags.push({ type: 'subtitle', value });\n  }\n  for (const value of activeFilters.value.season) {\n    tags.push({ type: 'season', value });\n  }\n  return tags;\n});\n\n// Clear all filters\nfunction clearFilters() {\n  activeFilters.value = { group: [], resolution: [], subtitle: [], season: [] };\n}\n\n// Count total and filtered results\nconst totalVariantCount = computed(() => {\n  return groupedResults.value.reduce((sum, group) => sum + group.variants.length, 0);\n});\n\nconst filteredVariantCount = computed(() => {\n  if (!hasActiveFilters.value) return totalVariantCount.value;\n  return groupedResults.value.reduce((sum, group) => sum + getFilteredVariants(group).length, 0);\n});\n\n// Get visible options (limited or all if expanded)\nfunction getVisibleOptions(category: 'group' | 'resolution' | 'subtitle' | 'season', options: string[]) {\n  if (expandedCategories.value.has(category)) {\n    return options;\n  }\n  return options.slice(0, MAX_VISIBLE_CHIPS);\n}\n\nfunction getOverflowCount(options: string[]) {\n  return Math.max(0, options.length - MAX_VISIBLE_CHIPS);\n}\n\nfunction hasOverflow(options: string[]) {\n  return options.length > MAX_VISIBLE_CHIPS;\n}\n\nfunction isExpanded(category: 'group' | 'resolution' | 'subtitle' | 'season') {\n  return expandedCategories.value.has(category);\n}\n\nfunction toggleExpand(category: 'group' | 'resolution' | 'subtitle' | 'season') {\n  if (expandedCategories.value.has(category)) {\n    expandedCategories.value.delete(category);\n  } else {\n    expandedCategories.value.add(category);\n  }\n}\n\n// Variant expansion functions\nfunction getVisibleVariants(group: GroupedBangumi): BangumiRule[] {\n  const filtered = getFilteredVariants(group);\n  if (expandedVariants.value.has(group.key)) {\n    return filtered;\n  }\n  return filtered.slice(0, MAX_VISIBLE_VARIANTS);\n}\n\nfunction getVariantOverflowCount(group: GroupedBangumi): number {\n  const filtered = getFilteredVariants(group);\n  return Math.max(0, filtered.length - MAX_VISIBLE_VARIANTS);\n}\n\nfunction hasVariantOverflow(group: GroupedBangumi): boolean {\n  return getFilteredVariants(group).length > MAX_VISIBLE_VARIANTS;\n}\n\nfunction isVariantsExpanded(groupKey: string): boolean {\n  return expandedVariants.value.has(groupKey);\n}\n\nfunction toggleVariantsExpand(groupKey: string) {\n  if (expandedVariants.value.has(groupKey)) {\n    expandedVariants.value.delete(groupKey);\n  } else {\n    expandedVariants.value.add(groupKey);\n  }\n}\n\n// Get all variants as a flat list\nconst allVariants = computed(() => {\n  const variants: BangumiRule[] = [];\n  for (const group of groupedResults.value) {\n    variants.push(...group.variants);\n  }\n  return variants;\n});\n\n// Check if adding a filter value would produce any results\n// This checks if the value is compatible with current selections in OTHER categories\nfunction wouldProduceResults(\n  type: 'group' | 'resolution' | 'subtitle' | 'season',\n  value: string\n): boolean {\n  const { group, resolution, subtitle, season } = activeFilters.value;\n\n  // If this filter is already active, it's selectable (to allow deselection)\n  if (activeFilters.value[type].includes(value)) {\n    return true;\n  }\n\n  // Check if any variant matches the hypothetical filter combination\n  return allVariants.value.some((variant) => {\n    // Check group constraint\n    const groupMatch = type === 'group'\n      ? variant.group_name === value\n      : group.length === 0 || (variant.group_name && group.includes(variant.group_name));\n\n    if (!groupMatch) return false;\n\n    // Check resolution constraint\n    const res = getResolution(variant);\n    const resMatch = type === 'resolution'\n      ? res === value\n      : resolution.length === 0 || (res && resolution.includes(res));\n\n    if (!resMatch) return false;\n\n    // Check subtitle constraint\n    const sub = getSubtitle(variant);\n    const subMatch = type === 'subtitle'\n      ? sub === value\n      : subtitle.length === 0 || (sub && subtitle.includes(sub));\n\n    if (!subMatch) return false;\n\n    // Check season constraint\n    const s = getSeason(variant);\n    const seasonMatch = type === 'season'\n      ? s === value\n      : season.length === 0 || (s && season.includes(s));\n\n    if (!seasonMatch) return false;\n\n    return true;\n  });\n}\n\n// Check if a filter option is disabled (would produce no results)\nfunction isFilterDisabled(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string): boolean {\n  // Only disable when there are active filters\n  if (!hasActiveFilters.value) return false;\n  return !wouldProduceResults(type, value);\n}\n\n// Handle filter click - only toggle if not disabled\nfunction handleFilterClick(type: 'group' | 'resolution' | 'subtitle' | 'season', value: string) {\n  if (isFilterDisabled(type, value)) return;\n  toggleFilter(type, value);\n}\n</script>\n\n<template>\n  <Teleport to=\"body\">\n    <!-- Backdrop -->\n    <transition name=\"overlay\">\n      <div v-if=\"showModal\" class=\"modal-backdrop\" />\n    </transition>\n\n    <!-- Modal -->\n    <transition name=\"modal\">\n      <div\n        v-if=\"showModal\"\n        class=\"modal-container\"\n        role=\"dialog\"\n        aria-modal=\"true\"\n        @mousedown.self=\"handleClose\"\n      >\n        <div class=\"modal-content\">\n          <!-- Header -->\n          <header class=\"modal-header\">\n            <div class=\"search-input-wrapper\">\n              <button\n                v-if=\"!loading\"\n                class=\"search-icon-btn\"\n                aria-label=\"Search\"\n                @click=\"onSearch\"\n              >\n                <Search theme=\"outline\" size=\"20\" />\n              </button>\n              <NSpin v-else :size=\"18\" />\n\n              <input\n                ref=\"searchInputRef\"\n                v-model=\"inputValue\"\n                type=\"text\"\n                :placeholder=\"$t('topbar.search.placeholder')\"\n                class=\"search-input\"\n                aria-label=\"Search anime\"\n                @keyup.enter=\"onSearch\"\n              />\n\n              <div class=\"provider-select\">\n                <button\n                  class=\"provider-btn\"\n                  aria-label=\"Select search provider\"\n                  @click=\"showProvider = !showProvider\"\n                >\n                  <span class=\"provider-label\">{{ provider }}</span>\n                  <Down :size=\"14\" />\n                </button>\n\n                <transition name=\"dropdown\">\n                  <div v-show=\"showProvider\" class=\"provider-dropdown\">\n                    <button\n                      v-for=\"site in providers\"\n                      :key=\"site\"\n                      class=\"provider-item\"\n                      :class=\"{ active: site === provider }\"\n                      @click=\"onSelectProvider(site)\"\n                    >\n                      {{ site }}\n                    </button>\n                  </div>\n                </transition>\n              </div>\n            </div>\n          </header>\n\n          <!-- Filter Section - Chip Cloud -->\n          <section v-if=\"showFilters\" class=\"filter-section\">\n            <!-- Group Filters -->\n            <div v-if=\"filterOptions.group.length > 0\" class=\"filter-category\">\n              <span class=\"category-icon\">\n                <PeoplesTwo theme=\"outline\" :size=\"16\" />\n              </span>\n              <div class=\"filter-chips\">\n                <button\n                  v-for=\"option in getVisibleOptions('group', filterOptions.group)\"\n                  :key=\"option\"\n                  class=\"filter-chip chip-group\"\n                  :class=\"{\n                    active: isFilterActive('group', option),\n                    disabled: isFilterDisabled('group', option)\n                  }\"\n                  :disabled=\"isFilterDisabled('group', option)\"\n                  @click=\"handleFilterClick('group', option)\"\n                >\n                  {{ option }}\n                </button>\n                <button\n                  v-if=\"hasOverflow(filterOptions.group)\"\n                  class=\"expand-btn\"\n                  @click=\"toggleExpand('group')\"\n                >\n                  {{ isExpanded('group') ? $t('search.filter.collapse') : `+${getOverflowCount(filterOptions.group)}` }}\n                </button>\n              </div>\n            </div>\n\n            <!-- Resolution Filters -->\n            <div v-if=\"filterOptions.resolution.length > 0\" class=\"filter-category\">\n              <span class=\"category-icon\">\n                <Monitor theme=\"outline\" :size=\"16\" />\n              </span>\n              <div class=\"filter-chips\">\n                <button\n                  v-for=\"option in getVisibleOptions('resolution', filterOptions.resolution)\"\n                  :key=\"option\"\n                  class=\"filter-chip chip-resolution\"\n                  :class=\"{\n                    active: isFilterActive('resolution', option),\n                    disabled: isFilterDisabled('resolution', option)\n                  }\"\n                  :disabled=\"isFilterDisabled('resolution', option)\"\n                  @click=\"handleFilterClick('resolution', option)\"\n                >\n                  {{ option }}\n                </button>\n                <button\n                  v-if=\"hasOverflow(filterOptions.resolution)\"\n                  class=\"expand-btn\"\n                  @click=\"toggleExpand('resolution')\"\n                >\n                  {{ isExpanded('resolution') ? $t('search.filter.collapse') : `+${getOverflowCount(filterOptions.resolution)}` }}\n                </button>\n              </div>\n            </div>\n\n            <!-- Subtitle Filters -->\n            <div v-if=\"filterOptions.subtitle.length > 0\" class=\"filter-category\">\n              <span class=\"category-icon\">\n                <Translate theme=\"outline\" :size=\"16\" />\n              </span>\n              <div class=\"filter-chips\">\n                <button\n                  v-for=\"option in getVisibleOptions('subtitle', filterOptions.subtitle)\"\n                  :key=\"option\"\n                  class=\"filter-chip chip-subtitle\"\n                  :class=\"{\n                    active: isFilterActive('subtitle', option),\n                    disabled: isFilterDisabled('subtitle', option)\n                  }\"\n                  :disabled=\"isFilterDisabled('subtitle', option)\"\n                  @click=\"handleFilterClick('subtitle', option)\"\n                >\n                  {{ option }}\n                </button>\n                <button\n                  v-if=\"hasOverflow(filterOptions.subtitle)\"\n                  class=\"expand-btn\"\n                  @click=\"toggleExpand('subtitle')\"\n                >\n                  {{ isExpanded('subtitle') ? $t('search.filter.collapse') : `+${getOverflowCount(filterOptions.subtitle)}` }}\n                </button>\n              </div>\n            </div>\n\n            <!-- Season Filters -->\n            <div v-if=\"filterOptions.season.length > 0\" class=\"filter-category\">\n              <span class=\"category-icon\">\n                <Calendar theme=\"outline\" :size=\"16\" />\n              </span>\n              <div class=\"filter-chips\">\n                <button\n                  v-for=\"option in getVisibleOptions('season', filterOptions.season)\"\n                  :key=\"option\"\n                  class=\"filter-chip chip-season\"\n                  :class=\"{\n                    active: isFilterActive('season', option),\n                    disabled: isFilterDisabled('season', option)\n                  }\"\n                  :disabled=\"isFilterDisabled('season', option)\"\n                  @click=\"handleFilterClick('season', option)\"\n                >\n                  {{ option }}\n                </button>\n                <button\n                  v-if=\"hasOverflow(filterOptions.season)\"\n                  class=\"expand-btn\"\n                  @click=\"toggleExpand('season')\"\n                >\n                  {{ isExpanded('season') ? $t('search.filter.collapse') : `+${getOverflowCount(filterOptions.season)}` }}\n                </button>\n              </div>\n            </div>\n\n            <!-- Selected Filters Summary -->\n            <div class=\"filter-summary\">\n              <div v-if=\"hasActiveFilters\" class=\"selected-filters\">\n                <span class=\"selected-label\">{{ $t('search.filter.active') }}:</span>\n                <div class=\"selected-chips\">\n                  <span\n                    v-for=\"tag in selectedFilterTags\"\n                    :key=\"`${tag.type}-${tag.value}`\"\n                    class=\"selected-chip\"\n                    :class=\"`chip-${tag.type}`\"\n                    @click=\"toggleFilter(tag.type, tag.value)\"\n                  >\n                    {{ tag.value }} &times;\n                  </span>\n                </div>\n                <button class=\"clear-all-btn\" @click=\"clearFilters\">\n                  {{ $t('search.filter.clear') }}\n                </button>\n              </div>\n              <span class=\"results-count\">\n                <template v-if=\"hasActiveFilters\">\n                  {{ filteredVariantCount }} / {{ totalVariantCount }} {{ $t('search.filter.results') }}\n                </template>\n                <template v-else>\n                  {{ totalVariantCount }} {{ $t('search.filter.results') }}\n                </template>\n              </span>\n            </div>\n          </section>\n\n          <!-- Results List -->\n          <div class=\"results-container\">\n            <!-- Empty state -->\n            <div v-if=\"!loading && groupedResults.length === 0 && inputValue\" class=\"empty-state\">\n              <p>{{ $t('search.no_results') }}</p>\n            </div>\n\n            <!-- Initial state -->\n            <div v-else-if=\"!inputValue && groupedResults.length === 0\" class=\"empty-state\">\n              <p>{{ $t('search.start_typing') }}</p>\n            </div>\n\n            <!-- Bangumi list -->\n            <div v-else class=\"bangumi-list\">\n              <template v-for=\"group in groupedResults\" :key=\"group.key\">\n                <div\n                  v-if=\"getFilteredVariants(group).length > 0\"\n                  class=\"bangumi-row\"\n                >\n                  <!-- Left: Poster -->\n                  <div class=\"bangumi-poster\">\n                    <img\n                      v-if=\"group.poster_link\"\n                      :src=\"getPosterUrl(group.poster_link)\"\n                      :alt=\"group.official_title\"\n                    />\n                    <div v-else class=\"bangumi-poster-placeholder\">\n                      <span class=\"placeholder-title\">{{ group.official_title }}</span>\n                    </div>\n                  </div>\n\n                  <!-- Right: Variant Chips (Original Prototype 4) -->\n                  <div class=\"bangumi-variants\">\n                    <div\n                      v-for=\"variant in getVisibleVariants(group)\"\n                      :key=\"variant.rss_link?.[0] || variant.title_raw\"\n                      class=\"variant-chip\"\n                      @click=\"handleVariantSelect(variant)\"\n                    >\n                      <span class=\"tag tag-group\">{{ variant.group_name || 'Unknown' }}</span>\n                      <span v-if=\"getResolution(variant)\" class=\"tag tag-res\">\n                        {{ getResolution(variant) }}\n                      </span>\n                      <span v-if=\"getSubtitle(variant)\" class=\"tag tag-sub\">\n                        {{ getSubtitle(variant) }}\n                      </span>\n                      <span v-if=\"getSeason(variant)\" class=\"tag tag-season\">\n                        {{ getSeason(variant) }}\n                      </span>\n                    </div>\n                    <button\n                      v-if=\"hasVariantOverflow(group)\"\n                      class=\"variant-expand-btn\"\n                      @click=\"toggleVariantsExpand(group.key)\"\n                    >\n                      {{ isVariantsExpanded(group.key) ? $t('search.filter.collapse') : `+${getVariantOverflowCount(group)}` }}\n                    </button>\n                  </div>\n                </div>\n              </template>\n            </div>\n          </div>\n        </div>\n\n        <!-- Confirmation Modal -->\n        <AbSearchConfirm\n          v-if=\"selectedResult\"\n          :bangumi=\"selectedResult\"\n          @confirm=\"handleConfirm\"\n          @cancel=\"clearSelectedResult\"\n        />\n      </div>\n    </transition>\n  </Teleport>\n</template>\n\n<style lang=\"scss\" scoped>\n.modal-backdrop {\n  position: fixed;\n  inset: 0;\n  background: var(--color-overlay);\n  z-index: var(--z-modal-backdrop);\n}\n\n.modal-container {\n  position: fixed;\n  inset: 0;\n  display: flex;\n  align-items: flex-start;\n  justify-content: center;\n  padding: 60px 16px 16px;\n  z-index: var(--z-modal);\n  overflow-y: auto;\n\n  @include forDesktop {\n    padding: 80px 24px 24px;\n  }\n}\n\n.modal-content {\n  width: 100%;\n  max-width: 1100px;\n  max-height: calc(100dvh - 100px);\n  display: flex;\n  flex-direction: column;\n  background: var(--color-surface);\n  border-radius: var(--radius-xl);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n  transition: background-color var(--transition-normal);\n\n  @supports not (max-height: 1dvh) {\n    max-height: calc(100vh - 100px);\n  }\n\n  @include forDesktop {\n    max-height: calc(100dvh - 120px);\n\n    @supports not (max-height: 1dvh) {\n      max-height: calc(100vh - 120px);\n    }\n  }\n}\n\n// Header\n.modal-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 12px;\n  border-bottom: 1px solid var(--color-border);\n  flex-shrink: 0;\n  transition: border-color var(--transition-normal);\n\n  @include forTablet {\n    gap: 12px;\n    padding: 16px;\n  }\n}\n\n.search-input-wrapper {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  height: 40px;\n  padding-left: 12px;\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  transition: border-color var(--transition-fast), background-color var(--transition-normal);\n\n  @include forTablet {\n    gap: 10px;\n    height: 44px;\n    padding-left: 14px;\n  }\n\n  &:focus-within {\n    border-color: var(--color-primary);\n    background: var(--color-surface);\n  }\n}\n\n.search-icon-btn {\n  display: flex;\n  align-items: center;\n  background: transparent;\n  border: none;\n  padding: 0;\n  cursor: pointer;\n  color: var(--color-text-muted);\n  transition: color var(--transition-fast);\n\n  &:hover {\n    color: var(--color-primary);\n  }\n}\n\n.search-input {\n  flex: 1;\n  min-width: 0;\n  background: transparent;\n  border: none;\n  outline: none;\n  font-size: 15px;\n  color: var(--color-text);\n\n  &::placeholder {\n    color: var(--color-text-muted);\n  }\n}\n\n.provider-select {\n  position: relative;\n  height: 100%;\n}\n\n.provider-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 4px;\n  height: 100%;\n  padding: 0 10px;\n  min-width: 70px;\n  background: var(--color-primary);\n  color: #fff;\n  border: none;\n  border-radius: 0 var(--radius-md) var(--radius-md) 0;\n  cursor: pointer;\n  font-size: 12px;\n  font-family: inherit;\n  transition: background-color var(--transition-fast);\n\n  @include forTablet {\n    gap: 6px;\n    padding: 0 14px;\n    min-width: 90px;\n    font-size: 13px;\n  }\n\n  &:hover {\n    background: var(--color-primary-hover);\n  }\n}\n\n.provider-label {\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.provider-dropdown {\n  position: absolute;\n  top: calc(100% + 4px);\n  right: 0;\n  min-width: 120px;\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  box-shadow: var(--shadow-lg);\n  overflow: hidden;\n  z-index: 10;\n}\n\n.provider-item {\n  display: block;\n  width: 100%;\n  padding: 10px 14px;\n  font-size: 14px;\n  color: var(--color-text);\n  background: transparent;\n  border: none;\n  cursor: pointer;\n  text-align: left;\n  transition: background-color var(--transition-fast), color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-primary);\n    color: #fff;\n  }\n\n  &.active {\n    background: var(--color-primary-light);\n    color: var(--color-primary);\n  }\n}\n\n// Filter Section - Chip Cloud\n.filter-section {\n  padding: 14px 16px;\n  border-bottom: 1px solid var(--color-border);\n  background: var(--color-surface-hover);\n  flex-shrink: 0;\n  transition: background-color var(--transition-normal), border-color var(--transition-normal);\n}\n\n.filter-category {\n  display: flex;\n  align-items: flex-start;\n  gap: 10px;\n  margin-bottom: 10px;\n\n  &:last-of-type {\n    margin-bottom: 0;\n  }\n}\n\n.category-icon {\n  width: 24px;\n  height: 28px;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  color: var(--color-text-muted);\n  flex-shrink: 0;\n}\n\n.filter-chips {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  align-items: center;\n}\n\n.filter-chip {\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  font-weight: 500;\n  font-family: inherit;\n  border-radius: var(--radius-full);\n  border: 1px solid transparent;\n  cursor: pointer;\n  user-select: none;\n  transition: all var(--transition-fast);\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n// Group chips - Blue/Primary\n.chip-group {\n  background: var(--color-primary-light);\n  color: var(--color-primary);\n\n  &:hover:not(.disabled),\n  &.active {\n    background: var(--color-primary);\n    color: #fff;\n  }\n\n  &.disabled {\n    background: var(--color-surface-hover);\n    color: var(--color-text-muted);\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\n// Resolution chips - Green\n.chip-resolution {\n  background: rgba(34, 197, 94, 0.15);\n  color: var(--color-success);\n\n  &:hover:not(.disabled),\n  &.active {\n    background: var(--color-success);\n    color: #fff;\n  }\n\n  &.disabled {\n    background: var(--color-surface-hover);\n    color: var(--color-text-muted);\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\n// Subtitle chips - Orange\n.chip-subtitle {\n  background: rgba(249, 115, 22, 0.15);\n  color: var(--color-accent);\n\n  &:hover:not(.disabled),\n  &.active {\n    background: var(--color-accent);\n    color: #fff;\n  }\n\n  &.disabled {\n    background: var(--color-surface-hover);\n    color: var(--color-text-muted);\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\n// Season chips - Purple\n.chip-season {\n  background: rgba(139, 92, 246, 0.15);\n  color: rgb(139, 92, 246);\n\n  &:hover:not(.disabled),\n  &.active {\n    background: rgb(139, 92, 246);\n    color: #fff;\n  }\n\n  &.disabled {\n    background: var(--color-surface-hover);\n    color: var(--color-text-muted);\n    opacity: 0.5;\n    cursor: not-allowed;\n  }\n}\n\n.expand-btn {\n  height: 28px;\n  padding: 0 10px;\n  font-size: 12px;\n  font-weight: 500;\n  font-family: inherit;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: 1px dashed var(--color-border);\n  border-radius: var(--radius-full);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    color: var(--color-primary);\n    border-color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n}\n\n// Filter Summary\n.filter-summary {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  margin-top: 12px;\n  padding-top: 12px;\n  border-top: 1px solid var(--color-border);\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.selected-filters {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-wrap: wrap;\n  flex: 1;\n  min-width: 0;\n}\n\n.selected-label {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  flex-shrink: 0;\n}\n\n.selected-chips {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.selected-chip {\n  height: 24px;\n  padding: 0 8px;\n  font-size: 11px;\n  font-weight: 500;\n  border-radius: var(--radius-sm);\n  display: inline-flex;\n  align-items: center;\n  cursor: pointer;\n  color: #fff;\n  transition: opacity var(--transition-fast);\n\n  &:hover {\n    opacity: 0.8;\n  }\n\n  &.chip-group {\n    background: var(--color-primary);\n  }\n\n  &.chip-resolution {\n    background: var(--color-success);\n  }\n\n  &.chip-subtitle {\n    background: var(--color-accent);\n  }\n\n  &.chip-season {\n    background: rgb(139, 92, 246);\n  }\n}\n\n.clear-all-btn {\n  padding: 4px 10px;\n  font-size: 12px;\n  font-family: inherit;\n  color: var(--color-text-muted);\n  background: transparent;\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-sm);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n  flex-shrink: 0;\n\n  &:hover {\n    border-color: var(--color-danger);\n    color: var(--color-danger);\n  }\n}\n\n.results-count {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  flex-shrink: 0;\n}\n\n// Results\n.results-container {\n  flex: 1;\n  overflow-y: auto;\n  padding: 16px;\n}\n\n.empty-state {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  min-height: 200px;\n  color: var(--color-text-muted);\n  font-size: 15px;\n}\n\n// Modal transition\n.modal-enter-active {\n  transition: opacity var(--transition-normal), transform var(--transition-normal);\n}\n\n.modal-leave-active {\n  transition: opacity 150ms ease-in, transform 150ms ease-in;\n}\n\n.modal-enter-from {\n  opacity: 0;\n  transform: scale(0.95) translateY(-10px);\n}\n\n.modal-leave-to {\n  opacity: 0;\n  transform: scale(0.95) translateY(-10px);\n}\n\n// Bangumi list - Compact\n.bangumi-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.bangumi-row {\n  display: flex;\n  gap: 12px;\n  padding: 12px;\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  background: var(--color-surface);\n  transition: border-color var(--transition-fast);\n}\n\n.bangumi-poster {\n  // Height = 4 rows: 4 * 36px + 3 * 8px = 168px\n  // Width = 168px * 5/7 = 120px\n  width: 120px;\n  height: 168px;\n  flex-shrink: 0;\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n    border-radius: var(--radius-sm);\n    background: var(--color-surface-hover);\n  }\n}\n\n.bangumi-poster-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  padding: 6px;\n  border-radius: var(--radius-sm);\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n}\n\n.placeholder-title {\n  font-size: 10px;\n  font-weight: 500;\n  color: var(--color-text-muted);\n  text-align: center;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  display: -webkit-box;\n  -webkit-line-clamp: 3;\n  -webkit-box-orient: vertical;\n  line-height: 1.3;\n}\n\n// Variant chips - flex wrap flow layout\n.bangumi-variants {\n  flex: 1;\n  min-width: 0;\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n  align-content: flex-start;\n}\n\n.variant-chip {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  height: 36px;\n  padding: 0 6px;\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-full);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n    background: var(--color-primary);\n\n    .tag {\n      background: rgba(255, 255, 255, 0.2);\n      color: #fff;\n    }\n  }\n}\n\n// Display-only tags (non-clickable) - unified with filter chips\n.tag {\n  display: inline-flex;\n  align-items: center;\n  height: 24px;\n  padding: 0 10px;\n  font-size: 12px;\n  font-weight: 500;\n  border-radius: var(--radius-full);\n  pointer-events: none;\n  transition: all var(--transition-fast);\n}\n\n.tag-group {\n  background: var(--color-primary-light);\n  color: var(--color-primary);\n}\n\n.tag-res {\n  background: rgba(34, 197, 94, 0.15);\n  color: var(--color-success);\n}\n\n.tag-sub {\n  background: rgba(249, 115, 22, 0.15);\n  color: var(--color-accent);\n}\n\n.tag-season {\n  background: rgba(139, 92, 246, 0.15);\n  color: rgb(139, 92, 246);\n}\n\n.variant-expand-btn {\n  height: 36px;\n  padding: 0 14px;\n  font-size: 12px;\n  font-weight: 500;\n  font-family: inherit;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: 1px dashed var(--color-border);\n  border-radius: var(--radius-full);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    color: var(--color-primary);\n    border-color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setting/config-download.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Downloader, DownloaderType } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst downloader = getSettingGroup('downloader');\nconst downloaderType: DownloaderType = ['qbittorrent'];\n\nconst items: SettingItem<Downloader>[] = [\n  {\n    configKey: 'type',\n    label: () => t('config.downloader_set.type'),\n    type: 'select',\n    css: 'w-115',\n    prop: {\n      items: downloaderType,\n    },\n  },\n  {\n    configKey: 'host',\n    label: () => t('config.downloader_set.host'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: '127.0.0.1:8989',\n    },\n  },\n  {\n    configKey: 'username',\n    label: () => t('config.downloader_set.username'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: 'admin',\n    },\n  },\n  {\n    configKey: 'password',\n    label: () => t('config.downloader_set.password'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: 'admindmin',\n    },\n    bottomLine: true,\n  },\n  {\n    configKey: 'path',\n    label: () => t('config.downloader_set.path'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: '/downloads/Bangumi',\n    },\n  },\n  {\n    configKey: 'ssl',\n    label: () => t('config.downloader_set.ssl'),\n    type: 'switch',\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.downloader_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in items\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"downloader[i.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-manage.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { BangumiManage, RenameMethod } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst manage = getSettingGroup('bangumi_manage');\nconst renameMethod: RenameMethod = ['normal', 'pn', 'advance', 'none'];\n\nconst items: SettingItem<BangumiManage>[] = [\n  {\n    configKey: 'enable',\n    label: () => t('config.manage_set.enable'),\n    type: 'switch',\n  },\n  {\n    configKey: 'rename_method',\n    label: () => t('config.manage_set.method'),\n    type: 'select',\n    prop: {\n      items: renameMethod,\n    },\n    bottomLine: true,\n  },\n  {\n    configKey: 'eps_complete',\n    label: () => t('config.manage_set.eps'),\n    type: 'switch',\n  },\n  {\n    configKey: 'group_tag',\n    label: () => t('config.manage_set.group_tag'),\n    type: 'switch',\n  },\n  {\n    configKey: 'remove_bad_torrent',\n    label: () => t('config.manage_set.delete_bad_torrent'),\n    type: 'switch',\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.manage_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in items\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"manage[i.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-normal.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Log, Program } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst program = getSettingGroup('program');\nconst log = getSettingGroup('log');\n\nconst programItems: SettingItem<Program>[] = [\n  {\n    configKey: 'rss_time',\n    label: () => t('config.normal_set.rss_interval'),\n    type: 'input',\n    css: 'w-72',\n    prop: {\n      type: 'number',\n      placeholder: 'port',\n    },\n  },\n  {\n    configKey: 'rename_time',\n    label: () => t('config.normal_set.rename_interval'),\n    type: 'input',\n    css: 'w-72',\n    prop: {\n      type: 'number',\n      placeholder: 'port',\n    },\n  },\n  {\n    configKey: 'webui_port',\n    label: () => t('config.normal_set.web_port'),\n    type: 'input',\n    css: 'w-72',\n    prop: {\n      type: 'number',\n      placeholder: 'port',\n    },\n    bottomLine: true,\n  },\n];\n\nconst logItems: SettingItem<Log> = {\n  configKey: 'debug_enable',\n  label: () => t('config.normal_set.debug'),\n  type: 'switch',\n};\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.normal_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in programItems\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"program[i.configKey]\"\n      ></ab-setting>\n\n      <ab-setting\n        v-bind=\"logItems\"\n        v-model:data=\"log[logItems.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-notification.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { NotificationProviderConfig, NotificationType } from '#/config';\nimport type { TupleToUnion } from '#/utils';\nimport { apiNotification } from '@/api/notification';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst notificationRef = getSettingGroup('notification');\n\n// Provider types with display names\nconst providerTypes: { value: TupleToUnion<NotificationType>; label: string }[] = [\n  { value: 'telegram', label: 'Telegram' },\n  { value: 'discord', label: 'Discord' },\n  { value: 'bark', label: 'Bark' },\n  { value: 'server-chan', label: 'Server Chan' },\n  { value: 'wecom', label: 'WeChat Work' },\n  { value: 'gotify', label: 'Gotify' },\n  { value: 'pushover', label: 'Pushover' },\n  { value: 'webhook', label: 'Webhook' },\n];\n\n// Provider field configurations\nconst providerFields: Record<\n  string,\n  { key: keyof NotificationProviderConfig; label: string; placeholder: string }[]\n> = {\n  telegram: [\n    { key: 'token', label: 'Bot Token', placeholder: 'bot token' },\n    { key: 'chat_id', label: 'Chat ID', placeholder: 'chat id' },\n  ],\n  discord: [\n    {\n      key: 'webhook_url',\n      label: 'Webhook URL',\n      placeholder: 'https://discord.com/api/webhooks/...',\n    },\n  ],\n  bark: [\n    { key: 'device_key', label: 'Device Key', placeholder: 'device key' },\n    {\n      key: 'server_url',\n      label: 'Server URL (optional)',\n      placeholder: 'https://api.day.app',\n    },\n  ],\n  'server-chan': [{ key: 'token', label: 'SendKey', placeholder: 'sendkey' }],\n  wecom: [\n    { key: 'webhook_url', label: 'Webhook URL', placeholder: 'webhook url' },\n    { key: 'token', label: 'Key', placeholder: 'key' },\n  ],\n  gotify: [\n    {\n      key: 'server_url',\n      label: 'Server URL',\n      placeholder: 'https://gotify.example.com',\n    },\n    { key: 'token', label: 'App Token', placeholder: 'app token' },\n  ],\n  pushover: [\n    { key: 'user_key', label: 'User Key', placeholder: 'user key' },\n    { key: 'api_token', label: 'API Token', placeholder: 'api token' },\n  ],\n  webhook: [\n    {\n      key: 'url',\n      label: 'Webhook URL',\n      placeholder: 'https://example.com/webhook',\n    },\n    {\n      key: 'template',\n      label: 'Template (JSON)',\n      placeholder: '{\"title\": \"{{title}}\", \"episode\": {{episode}}}',\n    },\n  ],\n};\n\n// Dialog state\nconst showAddDialog = ref(false);\nconst showEditDialog = ref(false);\nconst editingIndex = ref(-1);\nconst newProvider = ref<NotificationProviderConfig>({\n  type: 'telegram',\n  enabled: true,\n});\n\n// Testing state\nconst testingIndex = ref(-1);\nconst testResult = ref<{ success: boolean; message: string } | null>(null);\n\n// Computed properties to access notification settings\nconst notificationEnabled = computed({\n  get: () => notificationRef.value.enable,\n  set: (val) => {\n    notificationRef.value.enable = val;\n  },\n});\n\nconst providers = computed({\n  get: () => notificationRef.value.providers || [],\n  set: (val) => {\n    notificationRef.value.providers = val;\n  },\n});\n\n// Initialize providers array if not exists\nif (!notificationRef.value.providers) {\n  notificationRef.value.providers = [];\n}\n\nfunction getProviderLabel(type: string): string {\n  return providerTypes.find((p) => p.value === type)?.label || type;\n}\n\nfunction getProviderIcon(type: string): string {\n  const icons: Record<string, string> = {\n    telegram: 'i-simple-icons-telegram',\n    discord: 'i-simple-icons-discord',\n    bark: 'i-carbon-notification',\n    'server-chan': 'i-simple-icons-wechat',\n    wecom: 'i-simple-icons-wechat',\n    gotify: 'i-carbon-notification-filled',\n    pushover: 'i-carbon-mobile',\n    webhook: 'i-carbon-webhook',\n  };\n  return icons[type] || 'i-carbon-notification';\n}\n\nfunction openAddDialog() {\n  newProvider.value = {\n    type: 'telegram',\n    enabled: true,\n  };\n  testResult.value = null;\n  showAddDialog.value = true;\n}\n\nfunction openEditDialog(index: number) {\n  editingIndex.value = index;\n  newProvider.value = { ...providers.value[index] };\n  testResult.value = null;\n  showEditDialog.value = true;\n}\n\nfunction addProvider() {\n  const newProviders = [...providers.value, { ...newProvider.value }];\n  providers.value = newProviders;\n  showAddDialog.value = false;\n}\n\nfunction saveProvider() {\n  if (editingIndex.value >= 0) {\n    const newProviders = [...providers.value];\n    newProviders[editingIndex.value] = { ...newProvider.value };\n    providers.value = newProviders;\n  }\n  showEditDialog.value = false;\n  editingIndex.value = -1;\n}\n\nfunction removeProvider(index: number) {\n  const newProviders = providers.value.filter((_, i) => i !== index);\n  providers.value = newProviders;\n}\n\nfunction toggleProvider(index: number) {\n  const newProviders = [...providers.value];\n  newProviders[index] = {\n    ...newProviders[index],\n    enabled: !newProviders[index].enabled,\n  };\n  providers.value = newProviders;\n}\n\nasync function testProvider(index: number) {\n  testingIndex.value = index;\n  testResult.value = null;\n  try {\n    const response = await apiNotification.testProvider({ provider_index: index });\n    testResult.value = {\n      success: response.data.success,\n      message: response.data.message_zh || response.data.message,\n    };\n  } catch (error: any) {\n    testResult.value = {\n      success: false,\n      message: error.message || 'Test failed',\n    };\n  } finally {\n    testingIndex.value = -1;\n  }\n}\n\nasync function testNewProvider() {\n  testingIndex.value = -999; // Special index for new provider\n  testResult.value = null;\n  try {\n    const response = await apiNotification.testProviderConfig(\n      newProvider.value as any\n    );\n    testResult.value = {\n      success: response.data.success,\n      message: response.data.message_zh || response.data.message,\n    };\n  } catch (error: any) {\n    testResult.value = {\n      success: false,\n      message: error.message || 'Test failed',\n    };\n  } finally {\n    testingIndex.value = -1;\n  }\n}\n\nfunction getFieldsForType(type: string) {\n  return providerFields[type] || [];\n}\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.notification_set.title')\">\n    <div space-y-8>\n      <!-- Global enable switch -->\n      <ab-setting\n        config-key=\"enable\"\n        :label=\"() => t('config.notification_set.enable')\"\n        type=\"switch\"\n        v-model:data=\"notificationEnabled\"\n        bottom-line\n      />\n\n      <!-- Provider list -->\n      <div v-if=\"notificationEnabled\" space-y-8>\n        <div\n          v-for=\"(provider, index) in providers\"\n          :key=\"index\"\n          class=\"provider-item\"\n          :class=\"{ 'provider-disabled': !provider.enabled }\"\n        >\n          <div class=\"provider-info\">\n            <div class=\"provider-name\">\n              <div :class=\"getProviderIcon(provider.type)\" />\n              {{ getProviderLabel(provider.type) }}\n              <span v-if=\"!provider.enabled\" class=\"disabled-badge\">\n                {{ $t('config.notification_set.disabled') }}\n              </span>\n            </div>\n          </div>\n          <div class=\"provider-actions\">\n            <ab-button\n              size=\"small\"\n              type=\"secondary\"\n              :disabled=\"testingIndex === index\"\n              :title=\"$t('config.notification_set.test')\"\n              @click=\"testProvider(index)\"\n            >\n              <div\n                v-if=\"testingIndex === index\"\n                i-carbon-circle-dash\n                animate-spin\n              />\n              <div v-else i-carbon-play />\n            </ab-button>\n            <ab-button\n              size=\"small\"\n              type=\"secondary\"\n              :title=\"$t('config.notification_set.edit')\"\n              @click=\"openEditDialog(index)\"\n            >\n              <div i-carbon-edit />\n            </ab-button>\n            <ab-button\n              size=\"small\"\n              type=\"secondary\"\n              :title=\"\n                provider.enabled\n                  ? $t('config.notification_set.disable')\n                  : $t('config.notification_set.enable_provider')\n              \"\n              @click=\"toggleProvider(index)\"\n            >\n              <div\n                :class=\"provider.enabled ? 'i-carbon-view' : 'i-carbon-view-off'\"\n              />\n            </ab-button>\n            <ab-button\n              size=\"small\"\n              type=\"warn\"\n              :title=\"$t('config.notification_set.remove')\"\n              @click=\"removeProvider(index)\"\n            >\n              <div i-carbon-trash-can />\n            </ab-button>\n          </div>\n        </div>\n\n        <!-- Test result message -->\n        <div\n          v-if=\"testResult\"\n          class=\"test-result\"\n          :class=\"testResult.success ? 'test-success' : 'test-error'\"\n        >\n          {{ testResult.message }}\n        </div>\n\n        <div line></div>\n\n        <!-- Add provider button -->\n        <div flex=\"~ justify-end\">\n          <ab-button size=\"small\" type=\"primary\" @click=\"openAddDialog\">\n            <div i-carbon-add />\n            {{ $t('config.notification_set.add_provider') }}\n          </ab-button>\n        </div>\n      </div>\n    </div>\n\n    <!-- Add Dialog -->\n    <ab-popup\n      v-model:show=\"showAddDialog\"\n      :title=\"$t('config.notification_set.add_provider')\"\n      css=\"w-400\"\n    >\n      <div space-y-16>\n        <ab-label :label=\"$t('config.notification_set.type')\">\n          <select v-model=\"newProvider.type\" ab-input>\n            <option v-for=\"pt in providerTypes\" :key=\"pt.value\" :value=\"pt.value\">\n              {{ pt.label }}\n            </option>\n          </select>\n        </ab-label>\n\n        <ab-label\n          v-for=\"field in getFieldsForType(newProvider.type)\"\n          :key=\"field.key\"\n          :label=\"field.label\"\n        >\n          <input\n            v-if=\"field.key !== 'template'\"\n            v-model=\"(newProvider as any)[field.key]\"\n            :placeholder=\"field.placeholder\"\n            ab-input\n          />\n          <textarea\n            v-else\n            v-model=\"(newProvider as any)[field.key]\"\n            :placeholder=\"field.placeholder\"\n            ab-input\n            class=\"field-textarea\"\n            rows=\"3\"\n          />\n        </ab-label>\n\n        <div\n          v-if=\"testResult\"\n          class=\"test-result\"\n          :class=\"testResult.success ? 'test-success' : 'test-error'\"\n        >\n          {{ testResult.message }}\n        </div>\n\n        <div line></div>\n\n        <div flex=\"~ justify-between items-center\">\n          <ab-button\n            size=\"small\"\n            type=\"secondary\"\n            :disabled=\"testingIndex === -999\"\n            @click=\"testNewProvider\"\n          >\n            <div\n              v-if=\"testingIndex === -999\"\n              i-carbon-circle-dash\n              animate-spin\n            />\n            <div v-else i-carbon-play />\n            {{ $t('config.notification_set.test') }}\n          </ab-button>\n          <div flex=\"~ gap-8\">\n            <ab-button size=\"small\" type=\"warn\" @click=\"showAddDialog = false\">\n              {{ $t('config.cancel') }}\n            </ab-button>\n            <ab-button size=\"small\" type=\"primary\" @click=\"addProvider\">\n              {{ $t('config.apply') }}\n            </ab-button>\n          </div>\n        </div>\n      </div>\n    </ab-popup>\n\n    <!-- Edit Dialog -->\n    <ab-popup\n      v-model:show=\"showEditDialog\"\n      :title=\"$t('config.notification_set.edit_provider')\"\n      css=\"w-400\"\n    >\n      <div space-y-16>\n        <ab-label :label=\"$t('config.notification_set.type')\">\n          <select v-model=\"newProvider.type\" ab-input disabled>\n            <option v-for=\"pt in providerTypes\" :key=\"pt.value\" :value=\"pt.value\">\n              {{ pt.label }}\n            </option>\n          </select>\n        </ab-label>\n\n        <ab-label\n          v-for=\"field in getFieldsForType(newProvider.type)\"\n          :key=\"field.key\"\n          :label=\"field.label\"\n        >\n          <input\n            v-if=\"field.key !== 'template'\"\n            v-model=\"(newProvider as any)[field.key]\"\n            :placeholder=\"field.placeholder\"\n            ab-input\n          />\n          <textarea\n            v-else\n            v-model=\"(newProvider as any)[field.key]\"\n            :placeholder=\"field.placeholder\"\n            ab-input\n            class=\"field-textarea\"\n            rows=\"3\"\n          />\n        </ab-label>\n\n        <div\n          v-if=\"testResult\"\n          class=\"test-result\"\n          :class=\"testResult.success ? 'test-success' : 'test-error'\"\n        >\n          {{ testResult.message }}\n        </div>\n\n        <div line></div>\n\n        <div flex=\"~ justify-between items-center\">\n          <ab-button\n            size=\"small\"\n            type=\"secondary\"\n            :disabled=\"testingIndex === -999\"\n            @click=\"testNewProvider\"\n          >\n            <div\n              v-if=\"testingIndex === -999\"\n              i-carbon-circle-dash\n              animate-spin\n            />\n            <div v-else i-carbon-play />\n            {{ $t('config.notification_set.test') }}\n          </ab-button>\n          <div flex=\"~ gap-8\">\n            <ab-button size=\"small\" type=\"warn\" @click=\"showEditDialog = false\">\n              {{ $t('config.cancel') }}\n            </ab-button>\n            <ab-button size=\"small\" type=\"primary\" @click=\"saveProvider\">\n              {{ $t('config.apply') }}\n            </ab-button>\n          </div>\n        </div>\n      </div>\n    </ab-popup>\n  </ab-fold-panel>\n</template>\n\n<style lang=\"scss\" scoped>\n.provider-item {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 12px;\n  background: var(--color-surface-elevated, #f9fafb);\n  border-radius: 8px;\n  transition: background-color var(--transition-normal), opacity var(--transition-normal);\n\n  :root.dark & {\n    background: var(--color-surface-elevated, #1f2937);\n  }\n}\n\n.provider-disabled {\n  opacity: 0.5;\n}\n\n.provider-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.provider-name {\n  font-weight: 500;\n  font-size: 14px;\n  color: var(--color-text);\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.disabled-badge {\n  font-size: 11px;\n  font-weight: 500;\n  padding: 2px 6px;\n  border-radius: 4px;\n  background: var(--color-danger);\n  color: white;\n  opacity: 0.8;\n}\n\n.provider-actions {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-shrink: 0;\n\n  :deep(.btn--small) {\n    min-width: 32px;\n    width: 32px;\n    height: 32px;\n    padding: 0;\n  }\n\n  :deep(.n-spin-container),\n  :deep(.n-spin-content) {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    width: 100%;\n    height: 100%;\n  }\n}\n\n.field-textarea {\n  resize: none;\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;\n  font-size: 13px;\n}\n\n.test-result {\n  font-size: 12px;\n  padding: 8px 12px;\n  border-radius: 6px;\n}\n\n.test-success {\n  color: var(--color-success, #22c55e);\n  background: color-mix(in srgb, var(--color-success, #22c55e) 10%, transparent);\n}\n\n.test-error {\n  color: var(--color-danger, #ef4444);\n  background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setting/config-openai.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Caution } from '@icon-park/vue-next';\nimport type { SettingItem } from '#/components';\nimport type { ExperimentalOpenAI, OpenAIModel, OpenAIType } from '#/config';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst openAI = getSettingGroup('experimental_openai');\nconst openAIModels: OpenAIModel = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'];\nconst openAITypes: OpenAIType = ['openai', 'azure'];\n\nconst providerItems: SettingItem<ExperimentalOpenAI>[] = [\n  {\n    configKey: 'api_type',\n    label: () => t('config.experimental_openai_set.api_type'),\n    type: 'select',\n    prop: {\n      items: openAITypes,\n    },\n  },\n  {\n    configKey: 'api_key',\n    label: () => t('config.experimental_openai_set.api_key'),\n    type: 'input',\n    prop: {\n      type: 'password',\n      placeholder: 'sk-...',\n    },\n  },\n  {\n    configKey: 'api_base',\n    label: () => t('config.experimental_openai_set.api_base'),\n    type: 'input',\n    prop: {\n      type: 'url',\n      placeholder: 'https://api.openai.com/v1',\n    },\n  },\n];\n\nconst openAIItems: SettingItem<ExperimentalOpenAI>[] = [\n  {\n    configKey: 'model',\n    label: () => t('config.experimental_openai_set.model'),\n    type: 'select',\n    prop: {\n      items: openAIModels,\n    },\n  },\n];\n\nconst azureItems: SettingItem<ExperimentalOpenAI>[] = [\n  {\n    configKey: 'api_version',\n    label: () => t('config.experimental_openai_set.api_version'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: '2024-02-01',\n    },\n  },\n  {\n    configKey: 'deployment_id',\n    label: () => t('config.experimental_openai_set.deployment_id'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: 'gpt-4o',\n    },\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.experimental_openai_set.title')\">\n    <div class=\"openai-section\">\n      <div class=\"openai-notice\">\n        <Caution size=\"16\" />\n        <span>{{ $t('config.experimental_openai_set.warning') }}</span>\n      </div>\n\n      <ab-setting\n        v-model:data=\"openAI.enable\"\n        config-key=\"enable\"\n        :label=\"() => t('config.experimental_openai_set.enable')\"\n        type=\"switch\"\n      />\n\n      <transition name=\"slide-fade\">\n        <div v-if=\"openAI.enable\" class=\"openai-config\">\n          <ab-setting\n            v-for=\"i in providerItems\"\n            :key=\"i.configKey\"\n            v-bind=\"i\"\n            v-model:data=\"openAI[i.configKey]\"\n          />\n\n          <ab-setting\n            v-for=\"i in openAI.api_type === 'azure' ? azureItems : openAIItems\"\n            :key=\"i.configKey\"\n            v-bind=\"i\"\n            v-model:data=\"openAI[i.configKey]\"\n          />\n        </div>\n      </transition>\n    </div>\n  </ab-fold-panel>\n</template>\n\n<style lang=\"scss\" scoped>\n.openai-section {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.openai-notice {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 12px;\n  border-radius: var(--radius-sm);\n  background: color-mix(in srgb, var(--color-warning) 10%, transparent);\n  border: 1px solid color-mix(in srgb, var(--color-warning) 30%, transparent);\n  color: var(--color-warning);\n  font-size: 12px;\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.openai-config {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  padding-top: 4px;\n}\n\n.slide-fade-enter-active {\n  transition: all 0.2s ease-out;\n}\n\n.slide-fade-leave-active {\n  transition: all 0.15s ease-in;\n}\n\n.slide-fade-enter-from,\n.slide-fade-leave-to {\n  opacity: 0;\n  transform: translateY(-8px);\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setting/config-parser.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { RssParser, RssParserLang } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst parser = getSettingGroup('rss_parser');\n\nconst langs: RssParserLang = ['zh', 'en', 'jp'];\n\nconst items: SettingItem<RssParser>[] = [\n  {\n    configKey: 'enable',\n    label: () => t('config.parser_set.enable'),\n    type: 'switch',\n  },\n  {\n    configKey: 'language',\n    label: () => t('config.parser_set.language'),\n    type: 'select',\n    prop: {\n      items: langs,\n    },\n  },\n  {\n    configKey: 'filter',\n    label: () => t('config.parser_set.exclude'),\n    type: 'dynamic-tags',\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.parser_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in items\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"parser[i.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-passkey.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Delete } from '@icon-park/vue-next';\nimport type { PasskeyItem } from '#/passkey';\n\nconst { t } = useMyI18n();\nconst { passkeys, loading, isSupported, loadPasskeys, addPasskey, deletePasskey } =\n  usePasskey();\n\nconst showAddDialog = ref(false);\nconst deviceName = ref('');\nconst isRegistering = ref(false);\n\nonMounted(() => {\n  loadPasskeys();\n});\n\nfunction openAddDialog() {\n  // 生成默认设备名称\n  const platform = navigator.platform || 'Device';\n  const userAgent = navigator.userAgent;\n\n  if (userAgent.includes('iPhone')) {\n    deviceName.value = 'iPhone';\n  } else if (userAgent.includes('iPad')) {\n    deviceName.value = 'iPad';\n  } else if (userAgent.includes('Mac')) {\n    deviceName.value = 'MacBook';\n  } else if (userAgent.includes('Windows')) {\n    deviceName.value = 'Windows PC';\n  } else if (userAgent.includes('Android')) {\n    deviceName.value = 'Android';\n  } else {\n    deviceName.value = platform;\n  }\n\n  showAddDialog.value = true;\n}\n\nasync function handleAdd() {\n  if (!deviceName.value.trim()) return;\n\n  isRegistering.value = true;\n  try {\n    const success = await addPasskey(deviceName.value.trim());\n    if (success) {\n      showAddDialog.value = false;\n      deviceName.value = '';\n    }\n  } finally {\n    isRegistering.value = false;\n  }\n}\n\nasync function handleDelete(passkey: PasskeyItem) {\n  if (!confirm(t('passkey.delete_confirm'))) return;\n  await deletePasskey(passkey.id);\n}\n\nfunction formatDate(dateString: string | null): string {\n  if (!dateString) return '-';\n  return new Date(dateString).toLocaleString();\n}\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('passkey.title')\">\n    <div space-y-8>\n      <!-- 不支持提示 -->\n      <div v-if=\"!isSupported\" text-orange-500 text-14>\n        {{ $t('passkey.not_supported') }}\n      </div>\n\n      <!-- 加载中 -->\n      <div v-else-if=\"loading\" text-gray-500 text-14>\n        {{ $t('passkey.loading') }}\n      </div>\n\n      <!-- 无 Passkey -->\n      <div v-else-if=\"passkeys.length === 0\" text-gray-500 text-14>\n        {{ $t('passkey.no_passkeys') }}\n      </div>\n\n      <!-- Passkey 列表 -->\n      <div v-else space-y-8>\n        <div\n          v-for=\"passkey in passkeys\"\n          :key=\"passkey.id\"\n          flex=\"~ justify-between items-center\"\n          p-12\n          bg-gray-50\n          rounded-8\n        >\n          <div>\n            <div font-medium>{{ passkey.name }}</div>\n            <div text-12 text-gray-500>\n              {{ $t('passkey.created_at') }}: {{ formatDate(passkey.created_at) }}\n            </div>\n            <div v-if=\"passkey.last_used_at\" text-12 text-gray-500>\n              {{ $t('passkey.last_used') }}: {{ formatDate(passkey.last_used_at) }}\n            </div>\n            <div v-if=\"passkey.backup_eligible\" text-12 text-green-600>\n              {{ $t('passkey.synced') }}\n            </div>\n          </div>\n          <ab-button\n            size=\"small\"\n            type=\"warn\"\n            @click=\"handleDelete(passkey)\"\n          >\n            <Delete size=\"16\" />\n          </ab-button>\n        </div>\n      </div>\n\n      <div line></div>\n\n      <!-- 添加按钮 -->\n      <div flex=\"~ justify-end\">\n        <ab-button\n          v-if=\"isSupported\"\n          size=\"small\"\n          type=\"primary\"\n          @click=\"openAddDialog\"\n        >\n          {{ $t('passkey.add_new') }}\n        </ab-button>\n      </div>\n    </div>\n\n    <!-- 添加对话框 -->\n    <ab-popup\n      v-model:show=\"showAddDialog\"\n      :title=\"$t('passkey.register_title')\"\n      css=\"w-365\"\n    >\n      <div space-y-16>\n        <ab-label :label=\"$t('passkey.device_name')\">\n          <input\n            v-model=\"deviceName\"\n            type=\"text\"\n            :placeholder=\"$t('passkey.device_name_placeholder')\"\n            ab-input\n            maxlength=\"64\"\n            @keyup.enter=\"handleAdd\"\n          />\n        </ab-label>\n\n        <div text-14 text-gray-500>\n          {{ $t('passkey.register_hint') }}\n        </div>\n\n        <div line></div>\n\n        <div flex=\"~ justify-end gap-8\">\n          <ab-button\n            size=\"small\"\n            type=\"warn\"\n            @click=\"showAddDialog = false\"\n          >\n            {{ $t('config.cancel') }}\n          </ab-button>\n          <ab-button\n            size=\"small\"\n            type=\"primary\"\n            :disabled=\"!deviceName.trim() || isRegistering\"\n            @click=\"handleAdd\"\n          >\n            {{ $t('config.apply') }}\n          </ab-button>\n        </div>\n      </div>\n    </ab-popup>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-player.vue",
    "content": "<script lang=\"ts\" setup>\nconst { types, type, rawUrl } = storeToRefs(usePlayerStore());\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.media_player_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-model:data=\"type\"\n        type=\"select\"\n        :label=\"$t('config.media_player_set.type')\"\n        :prop=\"{ items: types }\"\n      ></ab-setting>\n\n      <ab-setting\n        v-model:data=\"rawUrl\"\n        type=\"input\"\n        :label=\"$t('config.media_player_set.url')\"\n        :prop=\"{ placeholder: 'http://192.168.1.100:8096' }\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-proxy.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Proxy, ProxyType } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst proxy = getSettingGroup('proxy');\nconst proxyType: ProxyType = ['http', 'https', 'socks5'];\n\nconst items: SettingItem<Proxy>[] = [\n  {\n    configKey: 'enable',\n    label: () => t('config.proxy_set.enable'),\n    type: 'switch',\n  },\n  {\n    configKey: 'type',\n    label: () => t('config.proxy_set.type'),\n    type: 'select',\n    prop: {\n      items: proxyType,\n    },\n    bottomLine: true,\n  },\n  {\n    configKey: 'host',\n    label: () => t('config.proxy_set.host'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: '127.0.0.1',\n    },\n  },\n  {\n    configKey: 'port',\n    label: () => t('config.proxy_set.port'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: '7890',\n    },\n  },\n  {\n    configKey: 'username',\n    label: () => t('config.proxy_set.username'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: 'username',\n    },\n  },\n  {\n    configKey: 'password',\n    label: () => t('config.proxy_set.password'),\n    type: 'input',\n    prop: {\n      type: 'text',\n      placeholder: 'password',\n    },\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.proxy_set.title')\">\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in items\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"proxy[i.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n"
  },
  {
    "path": "webui/src/components/setting/config-search-provider.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Delete, EditTwo, Plus } from '@icon-park/vue-next';\n\ninterface SearchProvider {\n  name: string;\n  url: string;\n}\n\nconst { t } = useMyI18n();\n\n// State\nconst providers = ref<SearchProvider[]>([]);\nconst loading = ref(false);\nconst showAddDialog = ref(false);\nconst showEditDialog = ref(false);\nconst editingProvider = ref<SearchProvider | null>(null);\nconst editingIndex = ref<number>(-1);\n\n// Form state\nconst formName = ref('');\nconst formUrl = ref('');\n\n// Default providers that cannot be deleted\nconst defaultProviderNames = ['mikan', 'nyaa', 'dmhy'];\n\nonMounted(() => {\n  loadProviders();\n});\n\nasync function loadProviders() {\n  loading.value = true;\n  try {\n    const response = await fetch('/api/v1/search/provider/config');\n    if (response.ok) {\n      const data = await response.json();\n      providers.value = Object.entries(data).map(([name, url]) => ({\n        name,\n        url: url as string,\n      }));\n    }\n  } catch (error) {\n    console.error('Failed to load providers:', error);\n  } finally {\n    loading.value = false;\n  }\n}\n\nasync function saveProviders() {\n  const providerObj: Record<string, string> = {};\n  providers.value.forEach((p) => {\n    providerObj[p.name] = p.url;\n  });\n\n  try {\n    const response = await fetch('/api/v1/search/provider/config', {\n      method: 'PUT',\n      headers: { 'Content-Type': 'application/json' },\n      body: JSON.stringify(providerObj),\n    });\n    if (!response.ok) {\n      throw new Error('Failed to save providers');\n    }\n  } catch (error) {\n    console.error('Failed to save providers:', error);\n  }\n}\n\nfunction openAddDialog() {\n  formName.value = '';\n  formUrl.value = '';\n  showAddDialog.value = true;\n}\n\nfunction openEditDialog(provider: SearchProvider, index: number) {\n  editingProvider.value = provider;\n  editingIndex.value = index;\n  formName.value = provider.name;\n  formUrl.value = provider.url;\n  showEditDialog.value = true;\n}\n\nasync function handleAdd() {\n  if (!formName.value.trim() || !formUrl.value.trim()) return;\n\n  // Check for duplicate name\n  if (providers.value.some((p) => p.name === formName.value.trim())) {\n    return;\n  }\n\n  providers.value.push({\n    name: formName.value.trim(),\n    url: formUrl.value.trim(),\n  });\n\n  await saveProviders();\n  showAddDialog.value = false;\n  formName.value = '';\n  formUrl.value = '';\n}\n\nasync function handleEdit() {\n  if (!formName.value.trim() || !formUrl.value.trim()) return;\n  if (editingIndex.value < 0) return;\n\n  // Check for duplicate name (excluding current)\n  const duplicateIndex = providers.value.findIndex(\n    (p, i) => p.name === formName.value.trim() && i !== editingIndex.value\n  );\n  if (duplicateIndex !== -1) return;\n\n  providers.value[editingIndex.value] = {\n    name: formName.value.trim(),\n    url: formUrl.value.trim(),\n  };\n\n  await saveProviders();\n  showEditDialog.value = false;\n  editingProvider.value = null;\n  editingIndex.value = -1;\n  formName.value = '';\n  formUrl.value = '';\n}\n\nasync function handleDelete(index: number) {\n  const provider = providers.value[index];\n  if (defaultProviderNames.includes(provider.name)) {\n    return;\n  }\n\n  if (!confirm(t('config.search_provider_set.delete_confirm'))) return;\n\n  providers.value.splice(index, 1);\n  await saveProviders();\n}\n\nfunction isDefaultProvider(name: string): boolean {\n  return defaultProviderNames.includes(name);\n}\n\nfunction validateUrl(url: string): boolean {\n  return url.includes('%s');\n}\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.search_provider_set.title')\">\n    <div space-y-8>\n      <!-- Loading state -->\n      <div v-if=\"loading\" text-gray-500 text-14>\n        {{ $t('passkey.loading') }}\n      </div>\n\n      <!-- Empty state -->\n      <div v-else-if=\"providers.length === 0\" text-gray-500 text-14>\n        {{ $t('config.search_provider_set.no_providers') }}\n      </div>\n\n      <!-- Provider list -->\n      <div v-else space-y-8>\n        <div\n          v-for=\"(provider, index) in providers\"\n          :key=\"provider.name\"\n          class=\"provider-item\"\n        >\n          <div class=\"provider-info\">\n            <div class=\"provider-name\">\n              {{ provider.name }}\n              <span v-if=\"isDefaultProvider(provider.name)\" class=\"default-badge\">\n                {{ $t('config.search_provider_set.default') }}\n              </span>\n            </div>\n            <div class=\"provider-url\" :title=\"provider.url\">\n              {{ provider.url }}\n            </div>\n          </div>\n          <div class=\"provider-actions\">\n            <ab-button\n              size=\"small\"\n              type=\"secondary\"\n              @click=\"openEditDialog(provider, index)\"\n            >\n              <EditTwo size=\"16\" />\n            </ab-button>\n            <ab-button\n              v-if=\"!isDefaultProvider(provider.name)\"\n              size=\"small\"\n              type=\"warn\"\n              @click=\"handleDelete(index)\"\n            >\n              <Delete size=\"16\" />\n            </ab-button>\n          </div>\n        </div>\n      </div>\n\n      <div line></div>\n\n      <!-- Hint text -->\n      <div class=\"hint-text\">\n        {{ $t('config.search_provider_set.url_hint') }}\n      </div>\n\n      <!-- Add button -->\n      <div flex=\"~ justify-end\">\n        <ab-button size=\"small\" type=\"primary\" @click=\"openAddDialog\">\n          <Plus size=\"16\" />\n          {{ $t('config.search_provider_set.add_new') }}\n        </ab-button>\n      </div>\n    </div>\n\n    <!-- Add dialog -->\n    <ab-popup\n      v-model:show=\"showAddDialog\"\n      :title=\"$t('config.search_provider_set.add_title')\"\n      css=\"w-400\"\n    >\n      <div space-y-16>\n        <ab-label :label=\"$t('config.search_provider_set.name')\">\n          <input\n            v-model=\"formName\"\n            type=\"text\"\n            :placeholder=\"$t('config.search_provider_set.name_placeholder')\"\n            ab-input\n            maxlength=\"32\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"$t('config.search_provider_set.url')\">\n          <input\n            v-model=\"formUrl\"\n            type=\"text\"\n            :placeholder=\"$t('config.search_provider_set.url_placeholder')\"\n            ab-input\n            @keyup.enter=\"handleAdd\"\n          />\n        </ab-label>\n\n        <div\n          v-if=\"formUrl && !validateUrl(formUrl)\"\n          class=\"validation-warning\"\n        >\n          {{ $t('config.search_provider_set.url_missing_placeholder') }}\n        </div>\n\n        <div line></div>\n\n        <div flex=\"~ justify-end gap-8\">\n          <ab-button size=\"small\" type=\"warn\" @click=\"showAddDialog = false\">\n            {{ $t('config.cancel') }}\n          </ab-button>\n          <ab-button\n            size=\"small\"\n            type=\"primary\"\n            :disabled=\"!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)\"\n            @click=\"handleAdd\"\n          >\n            {{ $t('config.apply') }}\n          </ab-button>\n        </div>\n      </div>\n    </ab-popup>\n\n    <!-- Edit dialog -->\n    <ab-popup\n      v-model:show=\"showEditDialog\"\n      :title=\"$t('config.search_provider_set.edit_title')\"\n      css=\"w-400\"\n    >\n      <div space-y-16>\n        <ab-label :label=\"$t('config.search_provider_set.name')\">\n          <input\n            v-model=\"formName\"\n            type=\"text\"\n            :placeholder=\"$t('config.search_provider_set.name_placeholder')\"\n            ab-input\n            maxlength=\"32\"\n            :disabled=\"editingProvider !== null && isDefaultProvider(editingProvider.name)\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"$t('config.search_provider_set.url')\">\n          <input\n            v-model=\"formUrl\"\n            type=\"text\"\n            :placeholder=\"$t('config.search_provider_set.url_placeholder')\"\n            ab-input\n            @keyup.enter=\"handleEdit\"\n          />\n        </ab-label>\n\n        <div\n          v-if=\"formUrl && !validateUrl(formUrl)\"\n          class=\"validation-warning\"\n        >\n          {{ $t('config.search_provider_set.url_missing_placeholder') }}\n        </div>\n\n        <div line></div>\n\n        <div flex=\"~ justify-end gap-8\">\n          <ab-button size=\"small\" type=\"warn\" @click=\"showEditDialog = false\">\n            {{ $t('config.cancel') }}\n          </ab-button>\n          <ab-button\n            size=\"small\"\n            type=\"primary\"\n            :disabled=\"!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)\"\n            @click=\"handleEdit\"\n          >\n            {{ $t('config.apply') }}\n          </ab-button>\n        </div>\n      </div>\n    </ab-popup>\n  </ab-fold-panel>\n</template>\n\n<style lang=\"scss\" scoped>\n.provider-item {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  gap: 12px;\n  padding: 12px;\n  background: var(--color-surface-elevated, #f9fafb);\n  border-radius: 8px;\n  transition: background-color var(--transition-normal);\n\n  :root.dark & {\n    background: var(--color-surface-elevated, #1f2937);\n  }\n}\n\n.provider-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.provider-name {\n  font-weight: 500;\n  font-size: 14px;\n  color: var(--color-text);\n  display: flex;\n  align-items: center;\n  gap: 8px;\n}\n\n.default-badge {\n  font-size: 11px;\n  font-weight: 500;\n  padding: 2px 6px;\n  border-radius: 4px;\n  background: var(--color-primary);\n  color: white;\n  opacity: 0.8;\n}\n\n.provider-url {\n  font-size: 12px;\n  color: var(--color-text-secondary);\n  margin-top: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;\n}\n\n.provider-actions {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  flex-shrink: 0;\n}\n\n.hint-text {\n  font-size: 12px;\n  color: var(--color-text-secondary);\n  line-height: 1.5;\n}\n\n.validation-warning {\n  font-size: 12px;\n  color: var(--color-danger, #ef4444);\n  padding: 8px 12px;\n  background: color-mix(in srgb, var(--color-danger, #ef4444) 10%, transparent);\n  border-radius: 6px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setting/config-security.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { Security } from '#/config';\nimport type { SettingItem } from '#/components';\n\nconst { t } = useMyI18n();\nconst { getSettingGroup } = useConfigStore();\n\nconst security = getSettingGroup('security');\n\nconst items: SettingItem<Security>[] = [\n  {\n    configKey: 'login_whitelist',\n    label: () => t('config.security_set.login_whitelist'),\n    type: 'dynamic-tags',\n    prop: {\n      placeholder: '192.168.0.0/16',\n    },\n  },\n  {\n    configKey: 'login_tokens',\n    label: () => t('config.security_set.login_tokens'),\n    type: 'dynamic-tags',\n    prop: {\n      placeholder: 'your-api-token',\n    },\n    bottomLine: true,\n  },\n  {\n    configKey: 'mcp_whitelist',\n    label: () => t('config.security_set.mcp_whitelist'),\n    type: 'dynamic-tags',\n    prop: {\n      placeholder: '127.0.0.0/8',\n    },\n  },\n  {\n    configKey: 'mcp_tokens',\n    label: () => t('config.security_set.mcp_tokens'),\n    type: 'dynamic-tags',\n    prop: {\n      placeholder: 'your-mcp-token',\n    },\n  },\n];\n</script>\n\n<template>\n  <ab-fold-panel :title=\"$t('config.security_set.title')\">\n    <p class=\"hint-text\">{{ $t('config.security_set.hint') }}</p>\n    <div space-y-8>\n      <ab-setting\n        v-for=\"i in items\"\n        :key=\"i.configKey\"\n        v-bind=\"i\"\n        v-model:data=\"security[i.configKey]\"\n      ></ab-setting>\n    </div>\n  </ab-fold-panel>\n</template>\n\n<style lang=\"scss\" scoped>\n.hint-text {\n  font-size: 12px;\n  color: var(--color-text-secondary);\n  margin-bottom: 12px;\n  line-height: 1.5;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-container.vue",
    "content": "<script lang=\"ts\" setup>\ndefineProps<{\n  currentStep: number;\n  totalSteps: number;\n}>();\n\nconst { t } = useMyI18n();\n</script>\n\n<template>\n  <div class=\"wizard-container\">\n    <div class=\"wizard-progress\">\n      <div class=\"wizard-progress-bar\">\n        <div\n          class=\"wizard-progress-fill\"\n          :style=\"{ width: `${(currentStep / (totalSteps - 1)) * 100}%` }\"\n        />\n      </div>\n      <div class=\"wizard-step-indicator\">\n        {{ t('setup.nav.step', { current: currentStep + 1, total: totalSteps }) }}\n      </div>\n    </div>\n\n    <div class=\"wizard-content\">\n      <slot />\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-container {\n  width: 480px;\n  max-width: 92%;\n  display: flex;\n  flex-direction: column;\n  gap: 24px;\n}\n\n.wizard-progress {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n}\n\n.wizard-progress-bar {\n  width: 100%;\n  height: 4px;\n  background: var(--color-border);\n  border-radius: 2px;\n  overflow: hidden;\n}\n\n.wizard-progress-fill {\n  height: 100%;\n  background: var(--color-primary);\n  border-radius: 2px;\n  transition: width 0.3s ease;\n}\n\n.wizard-step-indicator {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  text-align: right;\n}\n\n.wizard-content {\n  display: flex;\n  flex-direction: column;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-account.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\nconst { accountData } = storeToRefs(setupStore);\n\nconst isValid = computed(() => {\n  return (\n    accountData.value.username.length >= 4 &&\n    accountData.value.password.length >= 8 &&\n    accountData.value.password === accountData.value.confirmPassword\n  );\n});\n\nconst passwordError = computed(() => {\n  if (accountData.value.password && accountData.value.password.length < 8) {\n    return t('setup.account.password_too_short');\n  }\n  if (\n    accountData.value.confirmPassword &&\n    accountData.value.password !== accountData.value.confirmPassword\n  ) {\n    return t('setup.account.password_mismatch');\n  }\n  return '';\n});\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.account.title')\" class=\"wizard-step\">\n    <div class=\"step-content\">\n      <p class=\"step-subtitle\">{{ t('setup.account.subtitle') }}</p>\n\n      <div class=\"form-fields\">\n        <ab-label :label=\"t('setup.account.username')\">\n          <input\n            v-model=\"accountData.username\"\n            type=\"text\"\n            placeholder=\"admin\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('setup.account.password')\">\n          <input\n            v-model=\"accountData.password\"\n            type=\"password\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('setup.account.confirm_password')\">\n          <input\n            v-model=\"accountData.confirmPassword\"\n            type=\"password\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <p v-if=\"passwordError\" class=\"error-text\">{{ passwordError }}</p>\n      </div>\n\n      <div class=\"wizard-actions\">\n        <ab-button size=\"small\" type=\"secondary\" @click=\"setupStore.prevStep()\">\n          {{ t('setup.nav.previous') }}\n        </ab-button>\n        <ab-button size=\"small\" :disabled=\"!isValid\" @click=\"setupStore.nextStep()\">\n          {{ t('setup.nav.next') }}\n        </ab-button>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.step-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.step-subtitle {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.form-fields {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.setup-input {\n  outline: none;\n  min-width: 0;\n  width: 200px;\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  text-align: right;\n  border-radius: 6px;\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  color: var(--color-text);\n  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);\n  }\n}\n\n.error-text {\n  font-size: 11px;\n  color: var(--color-error, #e53935);\n  margin: 0;\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-downloader.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\nconst { downloaderData, validation } = storeToRefs(setupStore);\n\nconst isTesting = ref(false);\nconst testMessage = ref('');\nconst testSuccess = ref(false);\n\nasync function testConnection() {\n  isTesting.value = true;\n  testMessage.value = '';\n  try {\n    const result = await apiSetup.testDownloader({\n      type: downloaderData.value.type,\n      host: downloaderData.value.host,\n      username: downloaderData.value.username,\n      password: downloaderData.value.password,\n      ssl: downloaderData.value.ssl,\n    });\n    testSuccess.value = result.success;\n    const { returnUserLangText } = useMyI18n();\n    testMessage.value = returnUserLangText({\n      en: result.message_en,\n      'zh-CN': result.message_zh,\n    });\n    validation.value.downloaderTested = result.success;\n  } catch {\n    testSuccess.value = false;\n    testMessage.value = t('setup.downloader.test_failed');\n  } finally {\n    isTesting.value = false;\n  }\n}\n\nfunction handleNext() {\n  setupStore.nextStep();\n}\n\nconst canTest = computed(() => {\n  return downloaderData.value.host && downloaderData.value.username && downloaderData.value.password;\n});\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.downloader.title')\" class=\"wizard-step\">\n    <div class=\"step-content\">\n      <p class=\"step-subtitle\">{{ t('setup.downloader.subtitle') }}</p>\n\n      <div class=\"form-fields\">\n        <ab-label :label=\"t('config.downloader_set.host')\">\n          <input\n            v-model=\"downloaderData.host\"\n            type=\"text\"\n            placeholder=\"172.17.0.1:8080\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.downloader_set.username')\">\n          <input\n            v-model=\"downloaderData.username\"\n            type=\"text\"\n            placeholder=\"admin\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.downloader_set.password')\">\n          <input\n            v-model=\"downloaderData.password\"\n            type=\"password\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.downloader_set.path')\">\n          <input\n            v-model=\"downloaderData.path\"\n            type=\"text\"\n            placeholder=\"/downloads/Bangumi\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.downloader_set.ssl')\">\n          <ab-switch v-model=\"downloaderData.ssl\" />\n        </ab-label>\n      </div>\n\n      <div class=\"test-section\">\n        <ab-button\n          size=\"small\"\n          type=\"secondary\"\n          :disabled=\"!canTest || isTesting\"\n          @click=\"testConnection\"\n        >\n          {{ isTesting ? t('setup.downloader.testing') : t('setup.downloader.test') }}\n        </ab-button>\n        <p v-if=\"testMessage\" class=\"test-message\" :class=\"{ success: testSuccess }\">\n          {{ testMessage }}\n        </p>\n      </div>\n\n      <div class=\"wizard-actions\">\n        <ab-button size=\"small\" type=\"secondary\" @click=\"setupStore.prevStep()\">\n          {{ t('setup.nav.previous') }}\n        </ab-button>\n        <ab-button\n          size=\"small\"\n          :disabled=\"!validation.downloaderTested\"\n          @click=\"handleNext\"\n        >\n          {{ t('setup.nav.next') }}\n        </ab-button>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.step-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.step-subtitle {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.form-fields {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.setup-input {\n  outline: none;\n  min-width: 0;\n  width: 200px;\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  text-align: right;\n  border-radius: 6px;\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  color: var(--color-text);\n  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);\n  }\n}\n\n.test-section {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.test-message {\n  font-size: 11px;\n  color: var(--color-error, #e53935);\n  margin: 0;\n\n  &.success {\n    color: var(--color-success, #43a047);\n  }\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-notification.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\nconst { notificationData, validation } = storeToRefs(setupStore);\n\nconst isTesting = ref(false);\nconst testMessage = ref('');\nconst testSuccess = ref(false);\n\nconst notificationTypes = [\n  { id: 0, label: 'Telegram', value: 'telegram' },\n  { id: 1, label: 'Server Chan', value: 'server-chan' },\n  { id: 2, label: 'Bark', value: 'bark' },\n  { id: 3, label: 'WeChat Work', value: 'wecom' },\n];\n\nasync function testNotification() {\n  isTesting.value = true;\n  testMessage.value = '';\n  try {\n    const result = await apiSetup.testNotification({\n      type: notificationData.value.type,\n      token: notificationData.value.token,\n      chat_id: notificationData.value.chat_id,\n    });\n    testSuccess.value = result.success;\n    const { returnUserLangText } = useMyI18n();\n    testMessage.value = returnUserLangText({\n      en: result.message_en,\n      'zh-CN': result.message_zh,\n    });\n    validation.value.notificationTested = result.success;\n  } catch {\n    testSuccess.value = false;\n    testMessage.value = t('setup.notification.test_failed');\n  } finally {\n    isTesting.value = false;\n  }\n}\n\nfunction skipStep() {\n  notificationData.value.skipped = true;\n  setupStore.nextStep();\n}\n\nfunction handleNext() {\n  notificationData.value.skipped = false;\n  notificationData.value.enable = true;\n  setupStore.nextStep();\n}\n\nconst canTest = computed(() => {\n  return notificationData.value.token;\n});\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.notification.title')\" class=\"wizard-step\">\n    <div class=\"step-content\">\n      <p class=\"step-subtitle\">{{ t('setup.notification.subtitle') }}</p>\n\n      <div class=\"form-fields\">\n        <ab-label :label=\"t('config.notification_set.type')\">\n          <ab-select\n            v-model=\"notificationData.type\"\n            :items=\"notificationTypes\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.notification_set.token')\">\n          <input\n            v-model=\"notificationData.token\"\n            type=\"text\"\n            class=\"setup-input setup-input-wide\"\n          />\n        </ab-label>\n\n        <ab-label :label=\"t('config.notification_set.chat_id')\">\n          <input\n            v-model=\"notificationData.chat_id\"\n            type=\"text\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n      </div>\n\n      <div class=\"test-section\">\n        <ab-button\n          size=\"small\"\n          type=\"secondary\"\n          :disabled=\"!canTest || isTesting\"\n          @click=\"testNotification\"\n        >\n          {{ isTesting ? t('setup.downloader.testing') : t('setup.notification.test') }}\n        </ab-button>\n        <p v-if=\"testMessage\" class=\"test-message\" :class=\"{ success: testSuccess }\">\n          {{ testMessage }}\n        </p>\n      </div>\n\n      <div class=\"wizard-actions\">\n        <ab-button size=\"small\" type=\"secondary\" @click=\"setupStore.prevStep()\">\n          {{ t('setup.nav.previous') }}\n        </ab-button>\n        <div class=\"action-group\">\n          <ab-button size=\"small\" type=\"secondary\" @click=\"skipStep\">\n            {{ t('setup.nav.skip') }}\n          </ab-button>\n          <ab-button\n            size=\"small\"\n            :disabled=\"!validation.notificationTested\"\n            @click=\"handleNext\"\n          >\n            {{ t('setup.nav.next') }}\n          </ab-button>\n        </div>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.step-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.step-subtitle {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.form-fields {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.setup-input {\n  outline: none;\n  min-width: 0;\n  width: 200px;\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  text-align: right;\n  border-radius: 6px;\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  color: var(--color-text);\n  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);\n  }\n}\n\n.setup-input-wide {\n  width: 260px;\n}\n\n.test-section {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.test-message {\n  font-size: 11px;\n  color: var(--color-error, #e53935);\n  margin: 0;\n\n  &.success {\n    color: var(--color-success, #43a047);\n  }\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n\n.action-group {\n  display: flex;\n  gap: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-review.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\nconst { accountData, downloaderData, rssData, notificationData, isLoading } =\n  storeToRefs(setupStore);\nconst router = useRouter();\nconst message = useMessage();\n\nasync function completeSetup() {\n  isLoading.value = true;\n  try {\n    const request = setupStore.buildCompleteRequest();\n    await apiSetup.complete(request);\n    message.success(t('setup.review.success'));\n    setupStore.$reset();\n    router.push({ name: 'Login' });\n  } catch (e) {\n    message.error(t('setup.review.failed'));\n  } finally {\n    isLoading.value = false;\n  }\n}\n\nfunction maskPassword(pwd: string): string {\n  if (pwd.length <= 2) return '**';\n  return pwd[0] + '*'.repeat(pwd.length - 2) + pwd[pwd.length - 1];\n}\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.review.title')\" class=\"wizard-step\">\n    <div class=\"step-content\">\n      <p class=\"step-subtitle\">{{ t('setup.review.subtitle') }}</p>\n\n      <div class=\"review-sections\">\n        <div class=\"review-section\">\n          <h4>{{ t('setup.account.title') }}</h4>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('setup.account.username') }}</span>\n            <span class=\"review-value\">{{ accountData.username }}</span>\n          </div>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('setup.account.password') }}</span>\n            <span class=\"review-value\">{{ maskPassword(accountData.password) }}</span>\n          </div>\n        </div>\n\n        <div class=\"review-section\">\n          <h4>{{ t('setup.downloader.title') }}</h4>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('config.downloader_set.host') }}</span>\n            <span class=\"review-value\">{{ downloaderData.host }}</span>\n          </div>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('config.downloader_set.username') }}</span>\n            <span class=\"review-value\">{{ downloaderData.username }}</span>\n          </div>\n        </div>\n\n        <div v-if=\"!rssData.skipped && rssData.url\" class=\"review-section\">\n          <h4>{{ t('setup.rss.title') }}</h4>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('setup.rss.feed_name') }}</span>\n            <span class=\"review-value\">{{ rssData.name || rssData.url }}</span>\n          </div>\n        </div>\n\n        <div v-if=\"!notificationData.skipped && notificationData.token\" class=\"review-section\">\n          <h4>{{ t('setup.notification.title') }}</h4>\n          <div class=\"review-item\">\n            <span class=\"review-label\">{{ t('config.notification_set.type') }}</span>\n            <span class=\"review-value\">{{ notificationData.type }}</span>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"wizard-actions\">\n        <ab-button size=\"small\" type=\"secondary\" @click=\"setupStore.prevStep()\">\n          {{ t('setup.nav.previous') }}\n        </ab-button>\n        <ab-button size=\"small\" :disabled=\"isLoading\" @click=\"completeSetup\">\n          {{ isLoading ? t('setup.review.completing') : t('setup.review.complete') }}\n        </ab-button>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.step-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.step-subtitle {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.review-sections {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.review-section {\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: 6px;\n  padding: 12px;\n\n  h4 {\n    margin: 0 0 8px;\n    font-size: 12px;\n    font-weight: 600;\n    color: var(--color-text);\n  }\n}\n\n.review-item {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  padding: 4px 0;\n  font-size: 12px;\n\n  &:not(:last-child) {\n    border-bottom: 1px solid var(--color-border);\n  }\n}\n\n.review-label {\n  color: var(--color-text-muted);\n}\n\n.review-value {\n  color: var(--color-text);\n  font-family: monospace;\n  max-width: 200px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-rss.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\nconst { rssData, validation } = storeToRefs(setupStore);\n\nconst isTesting = ref(false);\nconst testMessage = ref('');\nconst testSuccess = ref(false);\nconst feedTitle = ref('');\nconst itemCount = ref(0);\n\nasync function testFeed() {\n  if (!rssData.value.url) return;\n  isTesting.value = true;\n  testMessage.value = '';\n  feedTitle.value = '';\n  try {\n    const result = await apiSetup.testRSS(rssData.value.url);\n    testSuccess.value = result.success;\n    const { returnUserLangText } = useMyI18n();\n    testMessage.value = returnUserLangText({\n      en: result.message_en,\n      'zh-CN': result.message_zh,\n    });\n    if (result.success) {\n      feedTitle.value = result.title || '';\n      itemCount.value = result.item_count || 0;\n      if (!rssData.value.name && result.title) {\n        rssData.value.name = result.title;\n      }\n      validation.value.rssTested = true;\n    }\n  } catch {\n    testSuccess.value = false;\n    testMessage.value = t('setup.rss.test_failed');\n  } finally {\n    isTesting.value = false;\n  }\n}\n\nfunction skipStep() {\n  rssData.value.skipped = true;\n  setupStore.nextStep();\n}\n\nfunction handleNext() {\n  rssData.value.skipped = false;\n  setupStore.nextStep();\n}\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.rss.title')\" class=\"wizard-step\">\n    <div class=\"step-content\">\n      <p class=\"step-subtitle\">{{ t('setup.rss.subtitle') }}</p>\n\n      <div class=\"form-fields\">\n        <ab-label :label=\"t('setup.rss.url')\">\n          <input\n            v-model=\"rssData.url\"\n            type=\"text\"\n            placeholder=\"https://mikanani.me/RSS/...\"\n            class=\"setup-input setup-input-wide\"\n          />\n        </ab-label>\n\n        <ab-label v-if=\"rssData.name\" :label=\"t('setup.rss.feed_name')\">\n          <input\n            v-model=\"rssData.name\"\n            type=\"text\"\n            class=\"setup-input\"\n          />\n        </ab-label>\n      </div>\n\n      <div class=\"test-section\">\n        <ab-button\n          size=\"small\"\n          type=\"secondary\"\n          :disabled=\"!rssData.url || isTesting\"\n          @click=\"testFeed\"\n        >\n          {{ isTesting ? t('setup.downloader.testing') : t('setup.rss.test') }}\n        </ab-button>\n        <p v-if=\"testMessage\" class=\"test-message\" :class=\"{ success: testSuccess }\">\n          {{ testMessage }}\n        </p>\n      </div>\n\n      <div v-if=\"feedTitle\" class=\"feed-info\">\n        <p><strong>{{ t('setup.rss.feed_title') }}:</strong> {{ feedTitle }}</p>\n        <p><strong>{{ t('setup.rss.item_count') }}:</strong> {{ itemCount }}</p>\n      </div>\n\n      <div class=\"wizard-actions\">\n        <ab-button size=\"small\" type=\"secondary\" @click=\"setupStore.prevStep()\">\n          {{ t('setup.nav.previous') }}\n        </ab-button>\n        <div class=\"action-group\">\n          <ab-button size=\"small\" type=\"secondary\" @click=\"skipStep\">\n            {{ t('setup.nav.skip') }}\n          </ab-button>\n          <ab-button\n            size=\"small\"\n            :disabled=\"!validation.rssTested\"\n            @click=\"handleNext\"\n          >\n            {{ t('setup.nav.next') }}\n          </ab-button>\n        </div>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.step-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.step-subtitle {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.form-fields {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.setup-input {\n  outline: none;\n  min-width: 0;\n  width: 200px;\n  height: 28px;\n  padding: 0 12px;\n  font-size: 12px;\n  text-align: right;\n  border-radius: 6px;\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  color: var(--color-text);\n  transition: border-color var(--transition-fast), box-shadow var(--transition-fast);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 2px rgba(108, 74, 182, 0.2);\n  }\n}\n\n.setup-input-wide {\n  width: 260px;\n}\n\n.test-section {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n}\n\n.test-message {\n  font-size: 11px;\n  color: var(--color-error, #e53935);\n  margin: 0;\n\n  &.success {\n    color: var(--color-success, #43a047);\n  }\n}\n\n.feed-info {\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: 6px;\n  padding: 12px;\n  font-size: 12px;\n\n  p {\n    margin: 0 0 4px;\n    &:last-child { margin: 0; }\n  }\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n\n.action-group {\n  display: flex;\n  gap: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/components/setup/wizard-step-welcome.vue",
    "content": "<script lang=\"ts\" setup>\nconst { t } = useMyI18n();\nconst setupStore = useSetupStore();\n</script>\n\n<template>\n  <ab-container :title=\"t('setup.welcome.title')\" class=\"wizard-step\">\n    <div class=\"welcome-content\">\n      <p class=\"welcome-subtitle\">{{ t('setup.welcome.subtitle') }}</p>\n      <p class=\"welcome-description\">{{ t('setup.welcome.description') }}</p>\n\n      <div class=\"wizard-actions\">\n        <div></div>\n        <ab-button size=\"small\" @click=\"setupStore.nextStep()\">\n          {{ t('setup.welcome.start') }}\n        </ab-button>\n      </div>\n    </div>\n  </ab-container>\n</template>\n\n<style lang=\"scss\" scoped>\n.wizard-step {\n  width: 100%;\n}\n\n.welcome-content {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n.welcome-subtitle {\n  font-size: 14px;\n  color: var(--color-text);\n  margin: 0;\n}\n\n.welcome-description {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n.wizard-actions {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin-top: 8px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/hooks/__tests__/useApi.test.ts",
    "content": "/**\n * Tests for useApi hook logic\n * Note: These tests focus on testable aspects of the hook's behavior\n */\n\nimport { describe, it, expect, vi } from 'vitest';\n\n// Simplified useApi implementation for testing\ninterface Options<T = unknown> {\n  showMessage?: boolean;\n  onBeforeExecute?: () => void;\n  onSuccess?: (data: T) => void;\n  onError?: (error: unknown) => void;\n  onFinally?: () => void;\n}\n\n// eslint-disable-next-line @typescript-eslint/no-explicit-any\ntype AnyAsyncFunction = (...args: any[]) => Promise<any>;\n\nfunction createUseApi<TApi extends AnyAsyncFunction>(\n  api: TApi,\n  options: Options<Awaited<ReturnType<TApi>>> = {}\n) {\n  let data: Awaited<ReturnType<TApi>> | undefined;\n  let isLoading = false;\n\n  const execute = async (...params: Parameters<TApi>): Promise<void> => {\n    options.onBeforeExecute?.();\n    isLoading = true;\n    try {\n      const res: Awaited<ReturnType<TApi>> = await api(...params);\n      data = res;\n      options.onSuccess?.(res);\n    } catch (err) {\n      options.onError?.(err);\n    } finally {\n      isLoading = false;\n      options.onFinally?.();\n    }\n  };\n\n  return {\n    getData: () => data,\n    getIsLoading: () => isLoading,\n    execute,\n  };\n}\n\ndescribe('useApi logic', () => {\n  describe('execute', () => {\n    it('should call API function with provided parameters', async () => {\n      const mockApi = vi.fn().mockResolvedValue({ msg_en: 'Success' });\n      const { execute } = createUseApi(mockApi);\n\n      await execute('param1', 'param2', 123);\n\n      expect(mockApi).toHaveBeenCalledWith('param1', 'param2', 123);\n    });\n\n    it('should set data to API response on success', async () => {\n      const responseData = { msg_en: 'Success', value: 42 };\n      const mockApi = vi.fn().mockResolvedValue(responseData);\n      const { execute, getData } = createUseApi(mockApi);\n\n      await execute();\n\n      expect(getData()).toEqual(responseData);\n    });\n  });\n\n  describe('callbacks', () => {\n    it('should call onBeforeExecute before API call', async () => {\n      const onBeforeExecute = vi.fn();\n      const mockApi = vi.fn().mockResolvedValue({});\n      const { execute } = createUseApi(mockApi, { onBeforeExecute });\n\n      await execute();\n\n      expect(onBeforeExecute).toHaveBeenCalled();\n    });\n\n    it('should call onSuccess with response data', async () => {\n      const onSuccess = vi.fn();\n      const responseData = { msg_en: 'Success', id: 1 };\n      const mockApi = vi.fn().mockResolvedValue(responseData);\n      const { execute } = createUseApi(mockApi, { onSuccess });\n\n      await execute();\n\n      expect(onSuccess).toHaveBeenCalledWith(responseData);\n    });\n\n    it('should call onError when API throws', async () => {\n      const onError = vi.fn();\n      const error = new Error('API Error');\n      const mockApi = vi.fn().mockRejectedValue(error);\n      const { execute } = createUseApi(mockApi, { onError });\n\n      await execute();\n\n      expect(onError).toHaveBeenCalledWith(error);\n    });\n\n    it('should call onFinally after success', async () => {\n      const onFinally = vi.fn();\n      const mockApi = vi.fn().mockResolvedValue({});\n      const { execute } = createUseApi(mockApi, { onFinally });\n\n      await execute();\n\n      expect(onFinally).toHaveBeenCalled();\n    });\n\n    it('should call onFinally after error', async () => {\n      const onFinally = vi.fn();\n      const mockApi = vi.fn().mockRejectedValue(new Error());\n      const { execute } = createUseApi(mockApi, { onFinally, onError: vi.fn() });\n\n      await execute();\n\n      expect(onFinally).toHaveBeenCalled();\n    });\n  });\n\n  describe('error handling', () => {\n    it('should set isLoading to false after error', async () => {\n      const mockApi = vi.fn().mockRejectedValue(new Error('API Error'));\n      const { execute, getIsLoading } = createUseApi(mockApi, { onError: vi.fn() });\n\n      await execute();\n\n      expect(getIsLoading()).toBe(false);\n    });\n\n    it('should not set data on error', async () => {\n      const mockApi = vi.fn().mockRejectedValue(new Error('API Error'));\n      const { execute, getData } = createUseApi(mockApi, { onError: vi.fn() });\n\n      await execute();\n\n      expect(getData()).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/hooks/__tests__/useAuth.test.ts",
    "content": "/**\n * Tests for useAuth hook logic\n * Note: These tests focus on testable aspects of the auth flow logic\n */\n\nimport { describe, it, expect, vi } from 'vitest';\n\n// Test the core auth validation and state logic without the full composable dependencies\n\ndescribe('Auth Logic', () => {\n  describe('formVerify logic', () => {\n    // Validation rules extracted from useAuth\n    const MIN_PASSWORD_LENGTH = 8;\n\n    const validateForm = (\n      username: string,\n      password: string\n    ): { valid: boolean; error: 'empty_username' | 'empty_password' | 'short_password' | null } => {\n      if (!username) {\n        return { valid: false, error: 'empty_username' };\n      }\n      if (!password) {\n        return { valid: false, error: 'empty_password' };\n      }\n      if (password.length < MIN_PASSWORD_LENGTH) {\n        return { valid: false, error: 'short_password' };\n      }\n      return { valid: true, error: null };\n    };\n\n    it('should return error for empty username', () => {\n      const result = validateForm('', 'validpassword123');\n      expect(result.valid).toBe(false);\n      expect(result.error).toBe('empty_username');\n    });\n\n    it('should return error for empty password', () => {\n      const result = validateForm('testuser', '');\n      expect(result.valid).toBe(false);\n      expect(result.error).toBe('empty_password');\n    });\n\n    it('should return error for short password', () => {\n      const result = validateForm('testuser', 'short');\n      expect(result.valid).toBe(false);\n      expect(result.error).toBe('short_password');\n    });\n\n    it('should return valid for correct credentials', () => {\n      const result = validateForm('testuser', 'validpassword123');\n      expect(result.valid).toBe(true);\n      expect(result.error).toBeNull();\n    });\n\n    it('should accept password exactly at minimum length', () => {\n      const result = validateForm('testuser', '12345678'); // exactly 8 chars\n      expect(result.valid).toBe(true);\n    });\n\n    it('should reject password one char below minimum', () => {\n      const result = validateForm('testuser', '1234567'); // 7 chars\n      expect(result.valid).toBe(false);\n      expect(result.error).toBe('short_password');\n    });\n  });\n\n  describe('user state management logic', () => {\n    interface User {\n      username: string;\n      password: string;\n    }\n\n    const createUserState = (): User => ({\n      username: '',\n      password: '',\n    });\n\n    const clearUserState = (user: User): void => {\n      user.username = '';\n      user.password = '';\n    };\n\n    it('should initialize with empty credentials', () => {\n      const user = createUserState();\n      expect(user.username).toBe('');\n      expect(user.password).toBe('');\n    });\n\n    it('should allow setting credentials', () => {\n      const user = createUserState();\n      user.username = 'testuser';\n      user.password = 'testpassword';\n\n      expect(user.username).toBe('testuser');\n      expect(user.password).toBe('testpassword');\n    });\n\n    it('should clear credentials', () => {\n      const user = createUserState();\n      user.username = 'testuser';\n      user.password = 'testpassword';\n\n      clearUserState(user);\n\n      expect(user.username).toBe('');\n      expect(user.password).toBe('');\n    });\n  });\n\n  describe('login flow logic', () => {\n    it('should not proceed with login if validation fails', async () => {\n      const mockLoginApi = vi.fn();\n      const validateForm = (username: string, password: string) =>\n        username !== '' && password !== '' && password.length >= 8;\n\n      const login = async (username: string, password: string) => {\n        if (!validateForm(username, password)) {\n          return { success: false, reason: 'validation_failed' };\n        }\n        await mockLoginApi(username, password);\n        return { success: true, reason: null };\n      };\n\n      const result = await login('', '');\n\n      expect(result.success).toBe(false);\n      expect(result.reason).toBe('validation_failed');\n      expect(mockLoginApi).not.toHaveBeenCalled();\n    });\n\n    it('should call API with credentials when validation passes', async () => {\n      const mockLoginApi = vi.fn().mockResolvedValue({ access_token: 'token' });\n      const validateForm = (username: string, password: string) =>\n        username !== '' && password !== '' && password.length >= 8;\n\n      const login = async (username: string, password: string) => {\n        if (!validateForm(username, password)) {\n          return { success: false, reason: 'validation_failed' };\n        }\n        await mockLoginApi(username, password);\n        return { success: true, reason: null };\n      };\n\n      const result = await login('testuser', 'validpassword123');\n\n      expect(result.success).toBe(true);\n      expect(mockLoginApi).toHaveBeenCalledWith('testuser', 'validpassword123');\n    });\n  });\n\n  describe('auth state logic', () => {\n    it('should track login state', () => {\n      let isLoggedIn = false;\n\n      const setLoggedIn = () => {\n        isLoggedIn = true;\n      };\n\n      const setLoggedOut = () => {\n        isLoggedIn = false;\n      };\n\n      expect(isLoggedIn).toBe(false);\n\n      setLoggedIn();\n      expect(isLoggedIn).toBe(true);\n\n      setLoggedOut();\n      expect(isLoggedIn).toBe(false);\n    });\n  });\n\n  describe('update credentials logic', () => {\n    it('should validate credentials before update', () => {\n      const mockUpdateApi = vi.fn();\n      const validateForm = (username: string, password: string) =>\n        username !== '' && password !== '' && password.length >= 8;\n\n      const update = (username: string, password: string) => {\n        if (!validateForm(username, password)) {\n          return false;\n        }\n        mockUpdateApi(username, password);\n        return true;\n      };\n\n      const failResult = update('', '');\n      expect(failResult).toBe(false);\n      expect(mockUpdateApi).not.toHaveBeenCalled();\n\n      const successResult = update('newuser', 'newpassword123');\n      expect(successResult).toBe(true);\n      expect(mockUpdateApi).toHaveBeenCalledWith('newuser', 'newpassword123');\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/hooks/useAddRss.ts",
    "content": "/**\n * Composable to manage the Add RSS modal state globally.\n * Allows triggering the Add RSS modal from anywhere in the app.\n */\n\n// Global reactive state (shared across all component instances)\nconst showAddRss = ref(false);\n\nexport function useAddRss() {\n  const open = () => {\n    showAddRss.value = true;\n  };\n\n  const close = () => {\n    showAddRss.value = false;\n  };\n\n  return {\n    showAddRss,\n    openAddRss: open,\n    closeAddRss: close,\n  };\n}\n"
  },
  {
    "path": "webui/src/hooks/useApi.ts",
    "content": "type AnyAsyncFuntion<TData = any> = (...args: any[]) => Promise<TData>;\n\ninterface Options<T = any> {\n  showMessage?: boolean;\n  onBeforeExecute?: () => void;\n  onSuccess?: (data: T) => void;\n  onError?: (error: any) => void;\n  onFinally?: () => void;\n}\n\nexport function useApi<\n  TApi extends AnyAsyncFuntion = AnyAsyncFuntion,\n  TData = Awaited<ReturnType<TApi>>\n>(\n  api: TApi,\n  {\n    showMessage = true,\n    onBeforeExecute,\n    onSuccess,\n    onError,\n    onFinally,\n  }: Options = {}\n) {\n  const data = ref<TData>();\n  const isLoading = ref(false);\n\n  const message = useMessage();\n  const { returnUserLangMsg } = useMyI18n();\n\n  async function execute(...params: Parameters<TApi>) {\n    onBeforeExecute?.();\n\n    try {\n      isLoading.value = true;\n      const res = await api(...params);\n      data.value = res;\n\n      onSuccess?.(res);\n\n      if (showMessage && 'msg_en' in res) {\n        message.success(returnUserLangMsg(res));\n      }\n    } catch (err) {\n      onError?.(err);\n    } finally {\n      isLoading.value = false;\n      onFinally?.();\n    }\n  }\n\n  return {\n    data,\n    isLoading,\n\n    execute,\n  };\n}\n"
  },
  {
    "path": "webui/src/hooks/useAppInfo.ts",
    "content": "import { createSharedComposable, useIntervalFn } from '@vueuse/core';\n\nexport const useAppInfo = createSharedComposable(() => {\n  const { isLoggedIn } = useAuth();\n  const running = ref<boolean>(false);\n  const version = ref<string>('');\n\n  function getStatus() {\n    if (isLoggedIn.value) {\n      apiProgram.status().then((res) => {\n        running.value = res.status;\n        version.value = res.version;\n      });\n    }\n  }\n\n  const { pause: offUpdate, resume: onUpdate } = useIntervalFn(\n    getStatus,\n    3000,\n    {\n      immediate: false,\n      immediateCallback: true,\n    }\n  );\n\n  return {\n    running,\n    version,\n\n    onUpdate,\n    offUpdate,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/useAuth.ts",
    "content": "import { createSharedComposable, useLocalStorage } from '@vueuse/core';\nimport type { User } from '#/auth';\nimport type { ApiError } from '#/api';\nimport { router } from '@/router';\n\nexport const useAuth = createSharedComposable(() => {\n  const message = useMessage();\n  const { t } = useMyI18n();\n\n  const isLoggedIn = useLocalStorage('isLoggedIn', false);\n\n  const user = reactive<User>({\n    username: '',\n    password: '',\n  });\n\n  function clearUser() {\n    user.username = '';\n    user.password = '';\n  }\n\n  function formVerify() {\n    if (user.username === '') {\n      message.warning(t('notify.please_enter', [t('topbar.profile.username')]));\n      return false;\n    } else if (user.password === '') {\n      message.warning(t('notify.please_enter', [t('topbar.profile.password')]));\n      return false;\n    } else if (user.password.length < 8) {\n      message.error(t('notify.password_length_error'));\n      return false;\n    }\n\n    return true;\n  }\n\n  function login() {\n    if (!formVerify()) return;\n\n    apiAuth\n      .login(user.username, user.password)\n      .then(() => {\n        isLoggedIn.value = true;\n        clearUser();\n        message.success(t('notify.login_success'));\n        router.replace({ name: 'Index' });\n      })\n      .catch((err: ApiError) => {\n        if (err.status === 404) {\n          message.error(t('notify.please_update'));\n        }\n      });\n  }\n\n  const { execute: logout } = useApi(apiAuth.logout, {\n    showMessage: true,\n    onSuccess() {\n      clearUser();\n      isLoggedIn.value = false;\n      router.replace({ name: 'Login' });\n    },\n  });\n\n  const { execute: refresh } = useApi(apiAuth.refresh, {\n    showMessage: false,\n    onSuccess() {\n      isLoggedIn.value = true;\n    },\n  });\n\n  function update() {\n    if (!formVerify()) return;\n\n    apiAuth.update(user.username, user.password).then((res) => {\n      if (res.message.toLocaleLowerCase() === 'update success') {\n        clearUser();\n        message.success(t('notify.update_success'));\n      } else {\n        user.password = '';\n        message.error(t('notify.update_failed'));\n      }\n    });\n  }\n\n  return {\n    isLoggedIn,\n    user,\n\n    login,\n    logout,\n    refresh,\n    update,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/useBreakpointQuery.ts",
    "content": "import { createSharedComposable, useBreakpoints } from '@vueuse/core';\n\nexport const useBreakpointQuery = createSharedComposable(() => {\n  const breakpoints = useBreakpoints({\n    tablet: 640,\n    pc: 1024,\n  });\n\n  const isMobile = breakpoints.smaller('tablet');          // <640px (phones)\n  const isTablet = breakpoints.between('tablet', 'pc');    // 640-1023px (tablets)\n  const isPC = breakpoints.isGreater('pc');                // >=1024px (desktop)\n  const isMobileOrTablet = breakpoints.smaller('pc');      // <1024px (legacy isMobile)\n  const isTabletOrPC = breakpoints.greaterOrEqual('tablet'); // >=640px\n\n  return {\n    breakpoints,\n    isMobile,\n    isTablet,\n    isPC,\n    isMobileOrTablet,\n    isTabletOrPC,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/useDarkMode.ts",
    "content": "import { computed, ref, watch } from 'vue';\nimport { createSharedComposable, usePreferredDark } from '@vueuse/core';\n\ntype ThemeMode = 'light' | 'dark' | 'system';\n\nexport const useDarkMode = createSharedComposable(() => {\n  const prefersDark = usePreferredDark();\n  const stored = localStorage.getItem('theme') as ThemeMode | null;\n  const mode = ref<ThemeMode>(stored || 'system');\n\n  const isDark = computed(() => {\n    if (mode.value === 'system') return prefersDark.value;\n    return mode.value === 'dark';\n  });\n\n  function applyTheme() {\n    const html = document.documentElement;\n    if (isDark.value) {\n      html.classList.add('dark');\n    } else {\n      html.classList.remove('dark');\n    }\n  }\n\n  function setMode(newMode: ThemeMode) {\n    mode.value = newMode;\n    if (newMode === 'system') {\n      localStorage.removeItem('theme');\n    } else {\n      localStorage.setItem('theme', newMode);\n    }\n  }\n\n  function toggle() {\n    setMode(isDark.value ? 'light' : 'dark');\n  }\n\n  watch(isDark, applyTheme, { immediate: true });\n  watch(prefersDark, () => {\n    if (mode.value === 'system') applyTheme();\n  });\n\n  return {\n    mode,\n    isDark,\n    setMode,\n    toggle,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/useMessage.ts",
    "content": "import { createDiscreteApi } from 'naive-ui';\nimport { createSharedComposable } from '@vueuse/core';\n\nexport const useMessage = createSharedComposable(() => {\n  const { message } = createDiscreteApi(['message']);\n  return message;\n});\n"
  },
  {
    "path": "webui/src/hooks/useMyI18n.ts",
    "content": "import { createI18n } from 'vue-i18n';\nimport { createSharedComposable, useLocalStorage } from '@vueuse/core';\nimport enUS from '@/i18n/en.json';\nimport zhCN from '@/i18n/zh-CN.json';\nimport type { ApiSuccess } from '#/api';\n\nconst messages = {\n  en: enUS,\n  'zh-CN': zhCN,\n};\n\ntype Languages = keyof typeof messages;\n\nfunction normalizeLocale(locale: string): Languages {\n  if (locale.startsWith('zh')) return 'zh-CN';\n  return 'en';\n}\n\nexport const i18n = createI18n({\n  legacy: false,\n  locale: normalizeLocale(navigator.language),\n  fallbackLocale: 'en',\n  messages,\n});\n\nexport const useMyI18n = createSharedComposable(() => {\n  const lang = useLocalStorage<Languages>(\n    'lang',\n    normalizeLocale(navigator.language)\n  );\n\n  i18n.global.locale.value = lang.value as unknown as Languages;\n\n  watch(lang, (val) => {\n    i18n.global.locale.value = val as unknown as Languages;\n  });\n\n  function changeLocale() {\n    if (lang.value === 'zh-CN') {\n      lang.value = 'en';\n    } else {\n      lang.value = 'zh-CN';\n    }\n  }\n\n  function returnUserLangText(texts: {\n    [k in Languages]: string;\n  }) {\n    return texts[lang.value] ?? texts.en;\n  }\n\n  function returnUserLangMsg(res: ApiSuccess) {\n    const msg = returnUserLangText({\n      en: res.msg_en,\n      'zh-CN': res.msg_zh,\n    });\n    return msg;\n  }\n\n  return {\n    lang,\n    i18n,\n    t: i18n.global.t,\n    locale: i18n.global.locale,\n    changeLocale,\n    returnUserLangText,\n    returnUserLangMsg,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/usePasskey.ts",
    "content": "import { createSharedComposable } from '@vueuse/core';\nimport { apiPasskey } from '@/api/passkey';\nimport {\n  isWebAuthnSupported,\n  registerPasskey,\n  loginWithPasskey as webauthnLogin,\n} from '@/services/webauthn';\nimport type { PasskeyItem } from '#/passkey';\n\nexport const usePasskey = createSharedComposable(() => {\n  const message = useMessage();\n  const { t } = useMyI18n();\n  const { isLoggedIn } = useAuth();\n\n  // 状态\n  const passkeys = ref<PasskeyItem[]>([]);\n  const loading = ref(false);\n  const isSupported = ref(isWebAuthnSupported());\n\n  // 加载 Passkey 列表\n  async function loadPasskeys() {\n    if (!isLoggedIn.value) return;\n\n    try {\n      loading.value = true;\n      passkeys.value = await apiPasskey.list();\n    } catch (error) {\n      console.error('Failed to load passkeys:', error);\n    } finally {\n      loading.value = false;\n    }\n  }\n\n  // 注册新 Passkey\n  async function addPasskey(deviceName: string): Promise<boolean> {\n    try {\n      await registerPasskey(deviceName);\n      message.success(t('passkey.register_success'));\n      await loadPasskeys();\n      return true;\n    } catch (error: unknown) {\n      // Don't show duplicate message if axios interceptor already handled it\n      if (error && typeof error === 'object' && 'status' in error) {\n        return false;\n      }\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      message.error(`${t('passkey.register_failed')}: ${errorMessage}`);\n      return false;\n    }\n  }\n\n  // 使用 Passkey 登录\n  async function loginWithPasskey(username?: string): Promise<boolean> {\n    try {\n      await webauthnLogin(username);\n      isLoggedIn.value = true;\n      message.success(t('notify.login_success'));\n      return true;\n    } catch (error: unknown) {\n      if (error && typeof error === 'object' && 'status' in error) {\n        return false;\n      }\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      message.error(`${t('passkey.login_failed')}: ${errorMessage}`);\n      return false;\n    }\n  }\n\n  // 删除 Passkey\n  async function deletePasskey(passkeyId: number): Promise<boolean> {\n    try {\n      await apiPasskey.delete({ passkey_id: passkeyId });\n      message.success(t('passkey.delete_success'));\n      await loadPasskeys();\n      return true;\n    } catch (error) {\n      message.error(t('passkey.delete_failed'));\n      return false;\n    }\n  }\n\n  return {\n    // 状态\n    passkeys,\n    loading,\n    isSupported,\n\n    // 方法\n    loadPasskeys,\n    addPasskey,\n    loginWithPasskey,\n    deletePasskey,\n  };\n});\n"
  },
  {
    "path": "webui/src/hooks/useSafeArea.ts",
    "content": "import { computed } from 'vue';\n\nexport function useSafeArea() {\n  const safeAreaTop = computed(() => 'env(safe-area-inset-top, 0px)');\n  const safeAreaBottom = computed(() => 'env(safe-area-inset-bottom, 0px)');\n  const safeAreaLeft = computed(() => 'env(safe-area-inset-left, 0px)');\n  const safeAreaRight = computed(() => 'env(safe-area-inset-right, 0px)');\n\n  return {\n    safeAreaTop,\n    safeAreaBottom,\n    safeAreaLeft,\n    safeAreaRight,\n  };\n}\n"
  },
  {
    "path": "webui/src/i18n/en.json",
    "content": "{\n  \"config\": {\n    \"apply\": \"Apply\",\n    \"cancel\": \"Cancel\",\n    \"downloader_set\": {\n      \"host\": \"Host\",\n      \"password\": \"Password\",\n      \"path\": \"Download Path\",\n      \"ssl\": \"SSL\",\n      \"title\": \"Downloader Setting\",\n      \"type\": \"Downloader Type\",\n      \"username\": \"Username\"\n    },\n    \"experimental_openai_set\": {\n      \"api_base\": \"API Base URL\",\n      \"api_key\": \"API Key\",\n      \"api_type\": \"Provider\",\n      \"api_version\": \"API Version\",\n      \"deployment_id\": \"Deployment ID\",\n      \"enable\": \"Enable LLM Parsing\",\n      \"model\": \"Model\",\n      \"title\": \"LLM Settings\",\n      \"warning\": \"Experimental: LLM-based anime title parsing may produce inaccurate results.\"\n    },\n    \"manage_set\": {\n      \"delete_bad_torrent\": \"Delete Bad Torrent\",\n      \"enable\": \"Enable\",\n      \"eps\": \"EPS complete\",\n      \"group_tag\": \"Add Group Tag\",\n      \"method\": \"Rename Method\",\n      \"title\": \"Manage Setting\"\n    },\n    \"media_player_set\": {\n      \"title\": \"Media Player Setting\",\n      \"type\": \"type\",\n      \"url\": \"url\"\n    },\n    \"normal_set\": {\n      \"debug\": \"Debug\",\n      \"rename_interval\": \"Interval Time of Rename\",\n      \"rss_interval\": \"Interval Time of Rss\",\n      \"title\": \"Normal Setting\",\n      \"web_port\": \"WebUI Port\"\n    },\n    \"notification_set\": {\n      \"title\": \"Notification Setting\",\n      \"enable\": \"Enable\",\n      \"enabled\": \"Enabled\",\n      \"disabled\": \"Disabled\",\n      \"type\": \"Type\",\n      \"token\": \"Token\",\n      \"chat_id\": \"Chat ID\",\n      \"add_provider\": \"Add Provider\",\n      \"edit_provider\": \"Edit Provider\",\n      \"remove\": \"Remove\",\n      \"edit\": \"Edit\",\n      \"test\": \"Test\",\n      \"enable_provider\": \"Enable\",\n      \"disable\": \"Disable\",\n      \"test_success\": \"Test successful\",\n      \"test_failed\": \"Test failed\"\n    },\n    \"parser_set\": {\n      \"enable\": \"Enable\",\n      \"exclude\": \"Exclude\",\n      \"language\": \"Language\",\n      \"source\": \"Source\",\n      \"title\": \"Parser Setting\",\n      \"token\": \"Token\",\n      \"type\": \"Parser Type\",\n      \"url\": \"Custom Url\"\n    },\n    \"proxy_set\": {\n      \"enable\": \"Enable\",\n      \"host\": \"Host\",\n      \"password\": \"Password\",\n      \"port\": \"Port\",\n      \"title\": \"Proxy Setting\",\n      \"type\": \"Proxy Type\",\n      \"username\": \"Username\"\n    },\n    \"security_set\": {\n      \"title\": \"Security\",\n      \"hint\": \"Login whitelist: empty = allow all IPs. MCP whitelist: empty = deny all access.\",\n      \"login_whitelist\": \"Login IP Whitelist\",\n      \"login_tokens\": \"Login API Tokens\",\n      \"mcp_whitelist\": \"MCP IP Whitelist\",\n      \"mcp_tokens\": \"MCP API Tokens\"\n    },\n    \"search_provider_set\": {\n      \"title\": \"Search Provider\",\n      \"add_new\": \"Add Provider\",\n      \"add_title\": \"Add Search Provider\",\n      \"edit_title\": \"Edit Search Provider\",\n      \"name\": \"Name\",\n      \"name_placeholder\": \"e.g., mikan\",\n      \"url\": \"URL Template\",\n      \"url_placeholder\": \"https://example.com/search?q=%s\",\n      \"url_hint\": \"Use %s as placeholder for search keywords in the URL\",\n      \"url_missing_placeholder\": \"URL must contain %s as search keyword placeholder\",\n      \"default\": \"Default\",\n      \"no_providers\": \"No search providers configured\",\n      \"delete_confirm\": \"Are you sure you want to delete this provider?\"\n    }\n  },\n  \"downloader\": {\n    \"hit\": \"Please set up the downloader\",\n    \"empty\": {\n      \"title\": \"Downloader not configured\",\n      \"subtitle\": \"Connect your download client to manage torrents here\",\n      \"step1_title\": \"Open Config\",\n      \"step1_desc\": \"Navigate to the Config page and find the Downloader section.\",\n      \"step2_title\": \"Enter Connection Details\",\n      \"step2_desc\": \"Set your qBittorrent host address, username, and password.\",\n      \"step3_title\": \"Access Downloader\",\n      \"step3_desc\": \"Once configured, the downloader web UI will be embedded right here.\"\n    },\n    \"empty_torrents\": \"No torrents in Bangumi category\",\n    \"selected\": \"selected\",\n    \"torrent\": {\n      \"name\": \"Name\",\n      \"progress\": \"Progress\",\n      \"status\": \"Status\",\n      \"size\": \"Size\",\n      \"dlspeed\": \"DL Speed\",\n      \"upspeed\": \"UP Speed\",\n      \"peers\": \"Seeds/Peers\"\n    },\n    \"state\": {\n      \"downloading\": \"Downloading\",\n      \"seeding\": \"Seeding\",\n      \"paused\": \"Paused\",\n      \"stalled\": \"Stalled\",\n      \"queued\": \"Queued\",\n      \"checking\": \"Checking\",\n      \"error\": \"Error\",\n      \"metadata\": \"Metadata\"\n    },\n    \"action\": {\n      \"pause\": \"Pause\",\n      \"resume\": \"Resume\",\n      \"delete\": \"Delete\"\n    }\n  },\n  \"homepage\": {\n    \"empty\": {\n      \"title\": \"No subscriptions yet\",\n      \"subtitle\": \"Get started by adding your first RSS feed\",\n      \"step1_title\": \"Add RSS Feed\",\n      \"step1_desc\": \"Click the \\\"Add\\\" button in the top bar and paste an RSS link from your anime source.\",\n      \"step2_title\": \"Configure Downloader\",\n      \"step2_desc\": \"Go to Config and set up your downloader (e.g. qBittorrent) connection.\",\n      \"step3_title\": \"Sit Back & Enjoy\",\n      \"step3_desc\": \"AutoBangumi will automatically download and rename new episodes for you.\",\n      \"add_rss_btn\": \"Add RSS Feed\"\n    },\n    \"rule\": {\n      \"apply\": \"Apply\",\n      \"delete\": \"Delete\",\n      \"delete_hit\": \"Delete Local File?\",\n      \"disable\": \"Disable\",\n      \"edit\": \"Edit\",\n      \"edit_rule\": \"Edit Rule\",\n      \"enable\": \"Enable\",\n      \"enable_hit\": \"Do you want to enable this rule?\",\n      \"enable_rule\": \"Enable Rule\",\n      \"exclude\": \"Exclude\",\n      \"no_btn\": \"No\",\n      \"official_title\": \"Official Title\",\n      \"episode_offset\": \"Episode Offset\",\n      \"season_offset\": \"Season Offset\",\n      \"auto_detect\": \"Auto Detect\",\n      \"needs_review\": \"Needs Review\",\n      \"archive\": \"Archive\",\n      \"unarchive\": \"Unarchive\",\n      \"archived\": \"Archived\",\n      \"archived_section\": \"Archived ({count})\",\n      \"select_hint\": \"Multiple rules found for this anime. Select one to edit:\",\n      \"filter\": \"Filter\",\n      \"unnamed\": \"Unnamed Rule\",\n      \"season\": \"Season\",\n      \"year\": \"Year\",\n      \"yes_btn\": \"Yes\"\n    }\n  },\n  \"log\": {\n    \"bug_repo\": \"Bug Report\",\n    \"clear_filters\": \"Clear\",\n    \"contact_info\": \"Contact Infomation\",\n    \"copy\": \"Copy\",\n    \"filter_level\": \"Level\",\n    \"go\": \"Go\",\n    \"join\": \"Join\",\n    \"reset\": \"Reset\",\n    \"title\": \"Log\",\n    \"update_now\": \"Update Now\"\n  },\n  \"login\": {\n    \"login_btn\": \"Login\",\n    \"passkey_btn\": \"Passkey\",\n    \"password\": \"Password\",\n    \"title\": \"Login\",\n    \"username\": \"Username\"\n  },\n  \"passkey\": {\n    \"add_new\": \"Add Passkey\",\n    \"created_at\": \"Created\",\n    \"delete_confirm\": \"Are you sure you want to delete this passkey?\",\n    \"delete_failed\": \"Delete failed\",\n    \"delete_success\": \"Passkey deleted\",\n    \"device_name\": \"Device Name\",\n    \"device_name_placeholder\": \"e.g., iPhone 15, MacBook Pro\",\n    \"last_used\": \"Last used\",\n    \"loading\": \"Loading...\",\n    \"login_failed\": \"Passkey login failed\",\n    \"no_passkeys\": \"No passkeys registered yet\",\n    \"not_supported\": \"Your browser does not support Passkeys\",\n    \"register_failed\": \"Registration failed\",\n    \"register_hint\": \"After clicking confirm, follow your browser's prompts to complete authentication.\",\n    \"register_success\": \"Passkey registered successfully\",\n    \"register_title\": \"Add New Passkey\",\n    \"synced\": \"Synced across devices\",\n    \"title\": \"Passkey Settings\"\n  },\n  \"notify\": {\n    \"copy_failed\": \"Your browser does not support Clipboard API!\",\n    \"copy_success\": \"Copy Success!\",\n    \"login_failed\": \"Login Failed!\",\n    \"login_success\": \"Login Success!\",\n    \"password_length_error\": \"Password must be at least 8 characters long!\",\n    \"please_enter\": \"Please Enter {0}!\",\n    \"please_update\": \"Please Update AutoBangumi!\",\n    \"rss_link\": \"RSS Link\",\n    \"update_failed\": \"Update Failed!\",\n    \"update_success\": \"Update Success!\"\n  },\n  \"player\": {\n    \"hit\": \"Please set up the media player\",\n    \"empty\": {\n      \"title\": \"Media player not configured\",\n      \"subtitle\": \"Connect your media server to stream directly from here\",\n      \"step1_title\": \"Open Config\",\n      \"step1_desc\": \"Navigate to the Config page and find the Media Player section.\",\n      \"step2_title\": \"Set Player URL\",\n      \"step2_desc\": \"Enter the URL of your media server (Jellyfin, Emby, Plex, etc.).\",\n      \"step3_title\": \"Start Watching\",\n      \"step3_desc\": \"Your media player will be embedded here for easy access.\"\n    }\n  },\n  \"rss\": {\n    \"connected\": \"Connected\",\n    \"delete\": \"Delete\",\n    \"disable\": \"Disable\",\n    \"enable\": \"Enable\",\n    \"error\": \"Error\",\n    \"name\": \"Name\",\n    \"selectbox\": \"Select\",\n    \"status\": \"Status\",\n    \"title\": \"RSS Item\",\n    \"url\": \"Url\"\n  },\n  \"calendar\": {\n    \"title\": \"Schedule\",\n    \"subtitle\": \"This season's broadcast schedule\",\n    \"days\": {\n      \"mon\": \"Monday\",\n      \"tue\": \"Tuesday\",\n      \"wed\": \"Wednesday\",\n      \"thu\": \"Thursday\",\n      \"fri\": \"Friday\",\n      \"sat\": \"Saturday\",\n      \"sun\": \"Sunday\"\n    },\n    \"days_short\": {\n      \"mon\": \"Mon\",\n      \"tue\": \"Tue\",\n      \"wed\": \"Wed\",\n      \"thu\": \"Thu\",\n      \"fri\": \"Fri\",\n      \"sat\": \"Sat\",\n      \"sun\": \"Sun\"\n    },\n    \"unknown\": \"Unknown\",\n    \"unknown_title\": \"Unknown Title\",\n    \"today\": \"Today\",\n    \"empty\": \"No anime\",\n    \"refresh\": \"Refresh schedule\",\n    \"no_data\": \"No schedule data available\",\n    \"loading\": \"Loading...\",\n    \"tracked\": \"Tracked\",\n    \"tracked_count\": \"{count} tracked\",\n    \"total_count\": \"{count} total\",\n    \"search_title\": \"Search and Add Anime\",\n    \"no_search_results\": \"No results found. Try different keywords.\",\n    \"empty_state\": {\n      \"title\": \"No Schedule Yet\",\n      \"subtitle\": \"Click refresh to fetch this season's broadcast data\"\n    },\n    \"drag_hint\": \"Drag to assign weekday\",\n    \"pinned\": \"Manually assigned\",\n    \"unpin\": \"Reset to unknown\",\n    \"drop_here\": \"Drop here\"\n  },\n  \"setup\": {\n    \"welcome\": {\n      \"title\": \"Welcome to AutoBangumi\",\n      \"subtitle\": \"Let's set up your anime downloading system.\",\n      \"description\": \"This wizard will guide you through the initial configuration. You can skip optional steps and configure them later.\",\n      \"start\": \"Get Started\"\n    },\n    \"account\": {\n      \"title\": \"Create Your Account\",\n      \"subtitle\": \"Change the default credentials for security.\",\n      \"username\": \"Username\",\n      \"password\": \"Password\",\n      \"confirm_password\": \"Confirm Password\",\n      \"password_mismatch\": \"Passwords do not match\",\n      \"password_too_short\": \"Password must be at least 8 characters\"\n    },\n    \"downloader\": {\n      \"title\": \"Download Client\",\n      \"subtitle\": \"Connect to your qBittorrent instance.\",\n      \"test\": \"Test Connection\",\n      \"testing\": \"Testing...\",\n      \"test_success\": \"Connection successful\",\n      \"test_failed\": \"Connection failed\"\n    },\n    \"rss\": {\n      \"title\": \"RSS Source\",\n      \"subtitle\": \"Add your first anime RSS feed.\",\n      \"url\": \"RSS Feed URL\",\n      \"feed_name\": \"Feed Name\",\n      \"test\": \"Test Feed\",\n      \"test_failed\": \"Failed to fetch feed\",\n      \"feed_title\": \"Feed Title\",\n      \"item_count\": \"Items Found\"\n    },\n    \"media\": {\n      \"title\": \"Media Library\",\n      \"subtitle\": \"Configure the download and organization path.\",\n      \"path\": \"Download Path\",\n      \"path_hint\": \"Episodes will be organized into subdirectories automatically.\"\n    },\n    \"notification\": {\n      \"title\": \"Notifications\",\n      \"subtitle\": \"Get notified about new episodes (optional).\",\n      \"test\": \"Send Test\",\n      \"test_failed\": \"Notification test failed\"\n    },\n    \"review\": {\n      \"title\": \"Review Settings\",\n      \"subtitle\": \"Confirm your configuration before completing setup.\",\n      \"complete\": \"Complete Setup\",\n      \"completing\": \"Setting up...\",\n      \"success\": \"Setup complete! Please log in with your new credentials.\",\n      \"failed\": \"Setup failed. Please try again.\"\n    },\n    \"nav\": {\n      \"next\": \"Next\",\n      \"previous\": \"Previous\",\n      \"skip\": \"Skip\",\n      \"step\": \"Step {current} of {total}\"\n    }\n  },\n  \"sidebar\": {\n    \"calendar\": \"Calendar\",\n    \"config\": \"Config\",\n    \"downloader\": \"Downloader\",\n    \"homepage\": \"HomePage\",\n    \"log\": \"Log\",\n    \"logout\": \"Logout\",\n    \"player\": \"Player\",\n    \"rss\": \"RSS Manager\",\n    \"title\": \"Menu\"\n  },\n  \"common\": {\n    \"cancel\": \"Cancel\",\n    \"confirm\": \"Confirm\",\n    \"select\": \"Select\",\n    \"selectAll\": \"Select All\",\n    \"items\": \"items\"\n  },\n  \"theme\": {\n    \"light\": \"Light\",\n    \"dark\": \"Dark\"\n  },\n  \"offset\": {\n    \"dialog_title\": \"Season/Episode Mismatch Detected\",\n    \"parsed_result\": \"RSS Parsed Result\",\n    \"tmdb_data\": \"TMDB Data\",\n    \"season\": \"Season\",\n    \"episode\": \"Episode\",\n    \"total_seasons\": \"Total Seasons\",\n    \"season_episodes\": \"S{season} Episodes\",\n    \"suggested_offset\": \"Suggested Offset\",\n    \"season_offset\": \"Season Offset\",\n    \"episode_offset\": \"Episode Offset\",\n    \"preview\": \"Preview\",\n    \"apply\": \"Apply Suggestion\",\n    \"keep\": \"Keep Original\",\n    \"cancel\": \"Cancel\",\n    \"badge_tooltip\": \"Review needed: {reason}\",\n    \"reason_season_mismatch\": \"RSS shows S{parsed}, but TMDB only has {total} season(s)\",\n    \"reason_episode_exceeded\": \"Episode {ep} exceeds TMDB count of {count} for this season\",\n    \"needs_review\": \"Offset Review Needed\",\n    \"dismiss\": \"Dismiss\",\n    \"suggestion_applied\": \"Offset suggestion applied\",\n    \"no_mismatch\": \"No season/episode mismatch detected\",\n    \"review_dismissed\": \"Review reminder dismissed\"\n  },\n  \"search\": {\n    \"subscribe\": \"Subscribe\",\n    \"no_results\": \"No results found. Try different keywords.\",\n    \"start_typing\": \"Enter keywords to start searching\",\n    \"filter\": {\n      \"group\": \"Group\",\n      \"resolution\": \"Resolution\",\n      \"subtitle_type\": \"Subtitle\",\n      \"season\": \"Season\",\n      \"clear\": \"Clear\",\n      \"results\": \"results\",\n      \"active\": \"Filtering\",\n      \"collapse\": \"Less\"\n    },\n    \"confirm\": {\n      \"title\": \"Add Subscription\",\n      \"rss\": \"RSS Feed\",\n      \"group\": \"Subtitle Group\",\n      \"resolution\": \"Resolution\",\n      \"subtitle\": \"Subtitle Type\",\n      \"season\": \"Season\",\n      \"filter\": \"Filter Rules\",\n      \"filter_hint\": \"Regex patterns to exclude unwanted torrents\",\n      \"save_path\": \"Save Path\",\n      \"save_path_placeholder\": \"Leave empty for default path\",\n      \"advanced\": \"Advanced Settings\",\n      \"subscribe\": \"Subscribe\"\n    }\n  },\n  \"topbar\": {\n    \"add\": {\n      \"aggregate\": \"Aggregate RSS\",\n      \"button\": \"Add\",\n      \"collect\": \"Collect\",\n      \"name\": \"Name\",\n      \"parser\": \"Parser\",\n      \"placeholder_link\": \"Please enter the RSS link\",\n      \"placeholder_name\": \"Optional\",\n      \"rss_link\": \"RSS Link\",\n      \"subscribe\": \"Subscribe\",\n      \"title\": \"Add RSS\"\n    },\n    \"pause\": \"Pause\",\n    \"profile\": {\n      \"password\": \"Password\",\n      \"pop_title\": \"Change Account\",\n      \"title\": \"Profile\",\n      \"update_btn\": \"Update\",\n      \"username\": \"Username\"\n    },\n    \"refresh_poster\": \"Refresh Poster\",\n    \"reset_rule\": \"Reset Rule\",\n    \"restart\": \"Restart\",\n    \"search\": {\n      \"placeholder\": \"Type to search\",\n      \"click_to_search\": \"Click to search\"\n    },\n    \"shutdown\": \"Shutdown\",\n    \"start\": \"Start\"\n  }\n}\n"
  },
  {
    "path": "webui/src/i18n/zh-CN.json",
    "content": "{\n  \"config\": {\n    \"apply\": \"应用\",\n    \"cancel\": \"取消\",\n    \"downloader_set\": {\n      \"host\": \"下载器地址\",\n      \"password\": \"密码\",\n      \"path\": \"下载地址\",\n      \"ssl\": \"SSL\",\n      \"title\": \"下载设置\",\n      \"type\": \"下载器类型\",\n      \"username\": \"用户名\"\n    },\n    \"experimental_openai_set\": {\n      \"api_base\": \"API 地址\",\n      \"api_key\": \"API 密钥\",\n      \"api_type\": \"服务商\",\n      \"api_version\": \"API 版本\",\n      \"deployment_id\": \"部署 ID\",\n      \"enable\": \"启用 LLM 解析\",\n      \"model\": \"模型\",\n      \"title\": \"LLM 设置\",\n      \"warning\": \"实验功能：基于 LLM 的番剧标题解析可能产生不准确的结果。\"\n    },\n    \"manage_set\": {\n      \"delete_bad_torrent\": \"删除坏种\",\n      \"enable\": \"启用\",\n      \"eps\": \"番剧补全\",\n      \"group_tag\": \"添加组标签\",\n      \"method\": \"重命名方式\",\n      \"title\": \"番剧管理设置\"\n    },\n    \"media_player_set\": {\n      \"title\": \"播放器设置\",\n      \"type\": \"类型\",\n      \"url\": \"播放器地址\"\n    },\n    \"normal_set\": {\n      \"debug\": \"调试\",\n      \"rename_interval\": \"重命名间隔\",\n      \"rss_interval\": \"RSS 间隔\",\n      \"title\": \"常规设置\",\n      \"web_port\": \"网页端口\"\n    },\n    \"notification_set\": {\n      \"title\": \"通知设置\",\n      \"enable\": \"启用\",\n      \"enabled\": \"已启用\",\n      \"disabled\": \"已禁用\",\n      \"type\": \"类型\",\n      \"token\": \"Token\",\n      \"chat_id\": \"Chat ID\",\n      \"add_provider\": \"添加通知服务\",\n      \"edit_provider\": \"编辑通知服务\",\n      \"remove\": \"删除\",\n      \"edit\": \"编辑\",\n      \"test\": \"测试\",\n      \"enable_provider\": \"启用\",\n      \"disable\": \"禁用\",\n      \"test_success\": \"测试成功\",\n      \"test_failed\": \"测试失败\"\n    },\n    \"parser_set\": {\n      \"enable\": \"启用\",\n      \"exclude\": \"排除\",\n      \"language\": \"语言\",\n      \"source\": \"数据源\",\n      \"title\": \"解析设置\",\n      \"token\": \"Token\",\n      \"type\": \"解析类型\",\n      \"url\": \"自定义网址\"\n    },\n    \"proxy_set\": {\n      \"enable\": \"启用\",\n      \"host\": \"地址\",\n      \"password\": \"密码\",\n      \"port\": \"端口\",\n      \"title\": \"代理设置\",\n      \"type\": \"类型\",\n      \"username\": \"用户名\"\n    },\n    \"security_set\": {\n      \"title\": \"安全设置\",\n      \"hint\": \"登录白名单：为空则允许所有 IP。MCP 白名单：为空则拒绝所有访问。\",\n      \"login_whitelist\": \"登录 IP 白名单\",\n      \"login_tokens\": \"登录 API 令牌\",\n      \"mcp_whitelist\": \"MCP IP 白名单\",\n      \"mcp_tokens\": \"MCP API 令牌\"\n    },\n    \"search_provider_set\": {\n      \"title\": \"搜索源设置\",\n      \"add_new\": \"添加搜索源\",\n      \"add_title\": \"添加搜索源\",\n      \"edit_title\": \"编辑搜索源\",\n      \"name\": \"名称\",\n      \"name_placeholder\": \"例如：mikan\",\n      \"url\": \"URL 模板\",\n      \"url_placeholder\": \"https://example.com/search?q=%s\",\n      \"url_hint\": \"URL 中使用 %s 作为搜索关键词的占位符\",\n      \"url_missing_placeholder\": \"URL 必须包含 %s 作为搜索关键词占位符\",\n      \"default\": \"默认\",\n      \"no_providers\": \"暂无搜索源\",\n      \"delete_confirm\": \"确定删除此搜索源？\"\n    }\n  },\n  \"downloader\": {\n    \"hit\": \"请设置下载器\",\n    \"empty\": {\n      \"title\": \"下载器未配置\",\n      \"subtitle\": \"连接下载客户端以在此管理种子\",\n      \"step1_title\": \"打开设置\",\n      \"step1_desc\": \"前往设置页面，找到下载器设置部分。\",\n      \"step2_title\": \"输入连接信息\",\n      \"step2_desc\": \"设置 qBittorrent 的地址、用户名和密码。\",\n      \"step3_title\": \"访问下载器\",\n      \"step3_desc\": \"配置完成后，下载器界面将直接嵌入此处。\"\n    },\n    \"empty_torrents\": \"Bangumi 分类中暂无种子\",\n    \"selected\": \"已选择\",\n    \"torrent\": {\n      \"name\": \"名称\",\n      \"progress\": \"进度\",\n      \"status\": \"状态\",\n      \"size\": \"大小\",\n      \"dlspeed\": \"下载速度\",\n      \"upspeed\": \"上传速度\",\n      \"peers\": \"做种/下载\"\n    },\n    \"state\": {\n      \"downloading\": \"下载中\",\n      \"seeding\": \"做种中\",\n      \"paused\": \"已暂停\",\n      \"stalled\": \"等待中\",\n      \"queued\": \"排队中\",\n      \"checking\": \"校验中\",\n      \"error\": \"错误\",\n      \"metadata\": \"获取元数据\"\n    },\n    \"action\": {\n      \"pause\": \"暂停\",\n      \"resume\": \"恢复\",\n      \"delete\": \"删除\"\n    }\n  },\n  \"homepage\": {\n    \"empty\": {\n      \"title\": \"暂无订阅\",\n      \"subtitle\": \"添加你的第一个 RSS 订阅开始使用\",\n      \"step1_title\": \"添加 RSS 订阅\",\n      \"step1_desc\": \"点击顶部栏的「添加」按钮，粘贴来自番剧源的 RSS 链接。\",\n      \"step2_title\": \"配置下载器\",\n      \"step2_desc\": \"前往设置页面，配置你的下载器（如 qBittorrent）连接信息。\",\n      \"step3_title\": \"坐享其成\",\n      \"step3_desc\": \"AutoBangumi 将自动下载并重命名新剧集。\",\n      \"add_rss_btn\": \"添加 RSS 订阅\"\n    },\n    \"rule\": {\n      \"apply\": \"应用\",\n      \"delete\": \"删除\",\n      \"delete_hit\": \"是否删除本地文件？\",\n      \"disable\": \"禁用\",\n      \"edit\": \"编辑\",\n      \"edit_rule\": \"编辑规则\",\n      \"enable\": \"启用\",\n      \"enable_hit\": \"确定启用该规则？\",\n      \"enable_rule\": \"启用规则\",\n      \"exclude\": \"排除\",\n      \"no_btn\": \"否\",\n      \"official_title\": \"官方名称\",\n      \"episode_offset\": \"集数偏移\",\n      \"season_offset\": \"季度偏移\",\n      \"auto_detect\": \"自动检测\",\n      \"needs_review\": \"需要检查\",\n      \"archive\": \"归档\",\n      \"unarchive\": \"取消归档\",\n      \"archived\": \"已归档\",\n      \"archived_section\": \"已归档 ({count})\",\n      \"select_hint\": \"该番剧有多个规则，请选择要编辑的规则：\",\n      \"filter\": \"过滤\",\n      \"unnamed\": \"未命名规则\",\n      \"season\": \"季度\",\n      \"year\": \"年份\",\n      \"yes_btn\": \"是\"\n    }\n  },\n  \"log\": {\n    \"bug_repo\": \"Bug 反馈\",\n    \"clear_filters\": \"清除\",\n    \"contact_info\": \"联系方式\",\n    \"copy\": \"复制\",\n    \"filter_level\": \"级别\",\n    \"go\": \"访问\",\n    \"join\": \"加入\",\n    \"reset\": \"重置\",\n    \"title\": \"日志\",\n    \"update_now\": \"立即更新\"\n  },\n  \"login\": {\n    \"login_btn\": \"登录\",\n    \"passkey_btn\": \"通行密钥\",\n    \"password\": \"密码\",\n    \"title\": \"登录\",\n    \"username\": \"用户名\"\n  },\n  \"passkey\": {\n    \"add_new\": \"添加 Passkey\",\n    \"created_at\": \"创建于\",\n    \"delete_confirm\": \"确定删除此 Passkey？\",\n    \"delete_failed\": \"删除失败\",\n    \"delete_success\": \"Passkey 已删除\",\n    \"device_name\": \"设备名称\",\n    \"device_name_placeholder\": \"例如：iPhone 15, MacBook Pro\",\n    \"last_used\": \"最后使用\",\n    \"loading\": \"加载中...\",\n    \"login_failed\": \"Passkey 登录失败\",\n    \"no_passkeys\": \"尚未注册任何 Passkey\",\n    \"not_supported\": \"您的浏览器不支持 Passkey\",\n    \"register_failed\": \"注册失败\",\n    \"register_hint\": \"点击确认后，请按照浏览器提示完成身份验证。\",\n    \"register_success\": \"Passkey 注册成功\",\n    \"register_title\": \"添加新的 Passkey\",\n    \"synced\": \"已同步到多设备\",\n    \"title\": \"Passkey 设置\"\n  },\n  \"notify\": {\n    \"copy_failed\": \"您的浏览器不支持剪贴板操作!\",\n    \"copy_success\": \"复制成功!\",\n    \"login_failed\": \"登录失败!\",\n    \"login_success\": \"登录成功!\",\n    \"password_length_error\": \"密码长度必须至少为8个字符!\",\n    \"please_enter\": \"请输入{0}!\",\n    \"please_update\": \"请更新AutoBangumi!\",\n    \"rss_link\": \"RSS链接\",\n    \"update_failed\": \"更新失败!\",\n    \"update_success\": \"更新成功!\"\n  },\n  \"player\": {\n    \"hit\": \"请设置媒体播放器地址\",\n    \"empty\": {\n      \"title\": \"播放器未配置\",\n      \"subtitle\": \"连接媒体服务器以在此直接播放\",\n      \"step1_title\": \"打开设置\",\n      \"step1_desc\": \"前往设置页面，找到播放器设置部分。\",\n      \"step2_title\": \"设置播放器地址\",\n      \"step2_desc\": \"输入媒体服务器的 URL（Jellyfin、Emby、Plex 等）。\",\n      \"step3_title\": \"开始观看\",\n      \"step3_desc\": \"播放器将嵌入此处，方便随时访问。\"\n    }\n  },\n  \"rss\": {\n    \"connected\": \"已连接\",\n    \"delete\": \"删除\",\n    \"disable\": \"禁用\",\n    \"enable\": \"启用\",\n    \"error\": \"错误\",\n    \"name\": \"名称\",\n    \"selectbox\": \"选择\",\n    \"status\": \"状态\",\n    \"title\": \"RSS 条目\",\n    \"url\": \"链接\"\n  },\n  \"calendar\": {\n    \"title\": \"放送表\",\n    \"subtitle\": \"本季度放送时间表\",\n    \"days\": {\n      \"mon\": \"周一\",\n      \"tue\": \"周二\",\n      \"wed\": \"周三\",\n      \"thu\": \"周四\",\n      \"fri\": \"周五\",\n      \"sat\": \"周六\",\n      \"sun\": \"周日\"\n    },\n    \"days_short\": {\n      \"mon\": \"一\",\n      \"tue\": \"二\",\n      \"wed\": \"三\",\n      \"thu\": \"四\",\n      \"fri\": \"五\",\n      \"sat\": \"六\",\n      \"sun\": \"日\"\n    },\n    \"unknown\": \"未知\",\n    \"unknown_title\": \"未知标题\",\n    \"today\": \"今天\",\n    \"empty\": \"今日无番\",\n    \"refresh\": \"刷新放送表\",\n    \"no_data\": \"暂无放送数据\",\n    \"loading\": \"加载中...\",\n    \"tracked\": \"已追踪\",\n    \"tracked_count\": \"已追踪 {count} 部\",\n    \"total_count\": \"共 {count} 部\",\n    \"search_title\": \"搜索并添加番剧\",\n    \"no_search_results\": \"未找到搜索结果，请尝试其他关键词\",\n    \"empty_state\": {\n      \"title\": \"暂无放送表\",\n      \"subtitle\": \"点击刷新按钮获取本季度放送数据\"\n    },\n    \"drag_hint\": \"拖拽以设置放送日\",\n    \"pinned\": \"手动设置\",\n    \"unpin\": \"重置为未知\",\n    \"drop_here\": \"拖放到此处\"\n  },\n  \"setup\": {\n    \"welcome\": {\n      \"title\": \"欢迎使用 AutoBangumi\",\n      \"subtitle\": \"让我们来设置你的自动追番系统。\",\n      \"description\": \"本向导将引导你完成初始配置。你可以跳过可选步骤，稍后再进行配置。\",\n      \"start\": \"开始设置\"\n    },\n    \"account\": {\n      \"title\": \"创建账户\",\n      \"subtitle\": \"为了安全，请修改默认登录凭证。\",\n      \"username\": \"用户名\",\n      \"password\": \"密码\",\n      \"confirm_password\": \"确认密码\",\n      \"password_mismatch\": \"两次输入的密码不一致\",\n      \"password_too_short\": \"密码长度不能少于 8 个字符\"\n    },\n    \"downloader\": {\n      \"title\": \"下载客户端\",\n      \"subtitle\": \"连接到你的 qBittorrent 实例。\",\n      \"test\": \"测试连接\",\n      \"testing\": \"测试中...\",\n      \"test_success\": \"连接成功\",\n      \"test_failed\": \"连接失败\"\n    },\n    \"rss\": {\n      \"title\": \"RSS 订阅源\",\n      \"subtitle\": \"添加你的第一个番剧 RSS 源。\",\n      \"url\": \"RSS 源地址\",\n      \"feed_name\": \"源名称\",\n      \"test\": \"测试订阅源\",\n      \"test_failed\": \"获取订阅源失败\",\n      \"feed_title\": \"源标题\",\n      \"item_count\": \"条目数量\"\n    },\n    \"media\": {\n      \"title\": \"媒体库\",\n      \"subtitle\": \"配置下载和整理路径。\",\n      \"path\": \"下载路径\",\n      \"path_hint\": \"剧集将自动整理到子目录中。\"\n    },\n    \"notification\": {\n      \"title\": \"通知设置\",\n      \"subtitle\": \"获取新集数的通知推送（可选）。\",\n      \"test\": \"发送测试\",\n      \"test_failed\": \"通知测试失败\"\n    },\n    \"review\": {\n      \"title\": \"确认设置\",\n      \"subtitle\": \"在完成设置前确认你的配置。\",\n      \"complete\": \"完成设置\",\n      \"completing\": \"设置中...\",\n      \"success\": \"设置完成！请使用新凭证登录。\",\n      \"failed\": \"设置失败，请重试。\"\n    },\n    \"nav\": {\n      \"next\": \"下一步\",\n      \"previous\": \"上一步\",\n      \"skip\": \"跳过\",\n      \"step\": \"第 {current} 步，共 {total} 步\"\n    }\n  },\n  \"sidebar\": {\n    \"calendar\": \"番剧日历\",\n    \"config\": \"设置\",\n    \"downloader\": \"下载器\",\n    \"homepage\": \"主页\",\n    \"log\": \"日志\",\n    \"logout\": \"退出\",\n    \"player\": \"播放器\",\n    \"rss\": \"RSS 管理\",\n    \"title\": \"菜单\"\n  },\n  \"common\": {\n    \"cancel\": \"取消\",\n    \"confirm\": \"确认\",\n    \"select\": \"选择\",\n    \"selectAll\": \"全选\",\n    \"items\": \"项\"\n  },\n  \"theme\": {\n    \"light\": \"浅色\",\n    \"dark\": \"深色\"\n  },\n  \"offset\": {\n    \"dialog_title\": \"检测到季度/集数不匹配\",\n    \"parsed_result\": \"RSS 解析结果\",\n    \"tmdb_data\": \"TMDB 数据\",\n    \"season\": \"季度\",\n    \"episode\": \"集数\",\n    \"total_seasons\": \"总季数\",\n    \"season_episodes\": \"S{season} 集数\",\n    \"suggested_offset\": \"建议偏移量\",\n    \"season_offset\": \"季度偏移\",\n    \"episode_offset\": \"集数偏移\",\n    \"preview\": \"预览\",\n    \"apply\": \"应用建议\",\n    \"keep\": \"保持原样\",\n    \"cancel\": \"取消\",\n    \"badge_tooltip\": \"需要检查: {reason}\",\n    \"reason_season_mismatch\": \"RSS显示S{parsed}，但TMDB只有{total}季\",\n    \"reason_episode_exceeded\": \"集数{ep}超出TMDB该季的{count}集\",\n    \"needs_review\": \"需要检查偏移量\",\n    \"dismiss\": \"忽略\",\n    \"suggestion_applied\": \"偏移量建议已应用\",\n    \"no_mismatch\": \"未检测到季度/集数不匹配\",\n    \"review_dismissed\": \"检查提醒已忽略\"\n  },\n  \"search\": {\n    \"subscribe\": \"订阅\",\n    \"no_results\": \"未找到相关结果，试试其他关键词\",\n    \"start_typing\": \"输入关键词开始搜索\",\n    \"filter\": {\n      \"group\": \"字幕组\",\n      \"resolution\": \"分辨率\",\n      \"subtitle_type\": \"字幕语言\",\n      \"season\": \"季度\",\n      \"clear\": \"清除筛选\",\n      \"results\": \"个结果\",\n      \"active\": \"筛选中\",\n      \"collapse\": \"收起\"\n    },\n    \"confirm\": {\n      \"title\": \"添加订阅\",\n      \"rss\": \"RSS 源\",\n      \"group\": \"字幕组\",\n      \"resolution\": \"分辨率\",\n      \"subtitle\": \"字幕类型\",\n      \"season\": \"季度\",\n      \"filter\": \"过滤规则\",\n      \"filter_hint\": \"用于排除不需要的种子，支持正则表达式\",\n      \"save_path\": \"保存路径\",\n      \"save_path_placeholder\": \"留空使用默认路径\",\n      \"advanced\": \"高级设置\",\n      \"subscribe\": \"确认订阅\"\n    }\n  },\n  \"topbar\": {\n    \"add\": {\n      \"aggregate\": \"聚合 RSS\",\n      \"button\": \"添加\",\n      \"collect\": \"收集\",\n      \"name\": \"名称\",\n      \"parser\": \"解析器\",\n      \"placeholder_link\": \"请输入 RSS 链接\",\n      \"placeholder_name\": \"可选\",\n      \"rss_link\": \"RSS 链接\",\n      \"subscribe\": \"订阅\",\n      \"title\": \"添加 RSS\"\n    },\n    \"pause\": \"暂停\",\n    \"profile\": {\n      \"password\": \"密码\",\n      \"pop_title\": \"修改账户\",\n      \"title\": \"账户设置\",\n      \"update_btn\": \"更新\",\n      \"username\": \"用户名\"\n    },\n    \"refresh_poster\": \"刷新海报\",\n    \"reset_rule\": \"重置规则\",\n    \"restart\": \"重启\",\n    \"search\": {\n      \"placeholder\": \"输入关键字搜索\",\n      \"click_to_search\": \"点击搜索\"\n    },\n    \"shutdown\": \"关闭\",\n    \"start\": \"启动\"\n  }\n}\n"
  },
  {
    "path": "webui/src/main.ts",
    "content": "import { createApp } from 'vue';\nimport { createPinia } from 'pinia';\nimport { router } from './router';\nimport { i18n } from './hooks/useMyI18n';\nimport App from './App.vue';\n\nimport '@unocss/reset/tailwind-compat.css';\nimport 'virtual:uno.css';\n\nconst pinia = createPinia();\n\nconst app = createApp(App);\napp.use(router);\napp.use(pinia);\napp.use(i18n);\napp.mount('#app');\n"
  },
  {
    "path": "webui/src/pages/index/bangumi.vue",
    "content": "<script lang=\"ts\" setup>\nimport type { BangumiRule } from '#/bangumi';\n\ndefinePage({\n  name: 'Bangumi List',\n});\n\nconst { bangumi, showArchived, isLoading, hasLoaded, activeBangumi, archivedBangumi } = storeToRefs(useBangumiStore());\nconst { getAll, openEditPopup } = useBangumiStore();\nconst { openAddRss } = useAddRss();\n\n// Show skeleton when initially loading (not yet loaded and loading)\nconst showSkeleton = computed(() => !hasLoaded.value && isLoading.value);\nconst skeletonCount = 8; // Number of skeleton cards to show\n\nconst refreshing = ref(false);\n\nasync function onRefresh() {\n  refreshing.value = true;\n  try {\n    await getAll();\n  } finally {\n    refreshing.value = false;\n  }\n}\n\nonActivated(() => {\n  getAll();\n});\n\n// Group bangumi by official_title + season\ninterface BangumiGroup {\n  key: string;\n  primary: BangumiRule;\n  rules: BangumiRule[];\n}\n\nfunction groupBangumi(items: BangumiRule[]): BangumiGroup[] {\n  if (!items) return [];\n  const map = new Map<string, BangumiRule[]>();\n  for (const item of items) {\n    const key = `${item.official_title}::${item.season}`;\n    if (!map.has(key)) {\n      map.set(key, []);\n    }\n    map.get(key)!.push(item);\n  }\n  const groups: BangumiGroup[] = [];\n  for (const [key, rules] of map) {\n    groups.push({ key, primary: rules[0], rules });\n  }\n  return groups;\n}\n\nconst groupedBangumi = computed<BangumiGroup[]>(() => groupBangumi(activeBangumi.value));\nconst groupedArchivedBangumi = computed<BangumiGroup[]>(() => groupBangumi(archivedBangumi.value));\n\n// Rule list popup state\nconst ruleListPopup = reactive<{\n  show: boolean;\n  group: BangumiGroup | null;\n}>({\n  show: false,\n  group: null,\n});\n\nfunction onCardClick(group: BangumiGroup) {\n  if (group.rules.length === 1) {\n    openEditPopup(group.primary);\n  } else {\n    ruleListPopup.group = group;\n    ruleListPopup.show = true;\n  }\n}\n\nfunction onRuleSelect(rule: BangumiRule) {\n  ruleListPopup.show = false;\n  openEditPopup(rule);\n}\n\n// Check if any rule in group needs review\nfunction groupNeedsReview(group: BangumiGroup): boolean {\n  return group.rules.some(r => r.needs_review);\n}\n</script>\n\n<template>\n  <ab-pull-refresh :loading=\"refreshing\" @refresh=\"onRefresh\">\n  <div class=\"page-bangumi\">\n    <!-- Skeleton loading state -->\n    <div v-if=\"showSkeleton\" class=\"bangumi-grid\">\n      <div\n        v-for=\"i in skeletonCount\"\n        :key=\"`skeleton-${i}`\"\n        class=\"skeleton-card\"\n      >\n        <div class=\"skeleton-poster\"></div>\n        <div class=\"skeleton-title\"></div>\n      </div>\n    </div>\n\n    <!-- Empty state guide -->\n    <div v-else-if=\"!bangumi || bangumi.length === 0\" class=\"empty-guide\">\n      <div class=\"empty-guide-header anim-fade-in\">\n        <div class=\"empty-guide-title\">{{ $t('homepage.empty.title') }}</div>\n        <div class=\"empty-guide-subtitle\">{{ $t('homepage.empty.subtitle') }}</div>\n      </div>\n\n      <div class=\"empty-guide-steps\">\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.15s\">\n          <div class=\"empty-guide-step-number\">1</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('homepage.empty.step1_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('homepage.empty.step1_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.3s\">\n          <div class=\"empty-guide-step-number\">2</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('homepage.empty.step2_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('homepage.empty.step2_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.45s\">\n          <div class=\"empty-guide-step-number\">3</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('homepage.empty.step3_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('homepage.empty.step3_desc') }}</div>\n          </div>\n        </div>\n      </div>\n\n      <div class=\"empty-guide-action anim-slide-up\" style=\"--delay: 0.6s\">\n        <ab-button type=\"primary\" size=\"big\" @click=\"openAddRss\">\n          {{ $t('homepage.empty.add_rss_btn') }}\n        </ab-button>\n      </div>\n    </div>\n\n    <!-- Bangumi grid -->\n    <template v-else>\n      <transition-group\n        name=\"bangumi\"\n        tag=\"div\"\n        class=\"bangumi-grid\"\n      >\n        <div\n          v-for=\"group in groupedBangumi\"\n          :key=\"group.key\"\n          class=\"bangumi-group-wrapper\"\n          :class=\"[group.rules.every(r => r.deleted) && 'grayscale']\"\n        >\n          <ab-bangumi-card\n            :bangumi=\"group.primary\"\n            type=\"primary\"\n            @click=\"() => onCardClick(group)\"\n          />\n          <!-- Combined notification badge -->\n          <div\n            v-if=\"groupNeedsReview(group) || group.rules.length > 1\"\n            class=\"group-badge\"\n            :class=\"{ 'group-badge--warning': groupNeedsReview(group) }\"\n          >\n            <template v-if=\"groupNeedsReview(group)\">\n              <span class=\"badge-icon\">!</span>\n              <template v-if=\"group.rules.length > 1\">\n                <span class=\"badge-divider\"></span>\n                <span class=\"badge-count\">{{ group.rules.length }}</span>\n              </template>\n            </template>\n            <template v-else>\n              {{ group.rules.length }}\n            </template>\n          </div>\n        </div>\n      </transition-group>\n\n      <!-- Archived section -->\n      <div v-if=\"groupedArchivedBangumi.length > 0\" class=\"archived-section\">\n        <button\n          type=\"button\"\n          class=\"archived-header\"\n          :aria-expanded=\"showArchived\"\n          @click=\"showArchived = !showArchived\"\n        >\n          <span class=\"archived-title\">\n            {{ $t('homepage.rule.archived_section', { count: archivedBangumi.length }) }}\n          </span>\n          <span class=\"archived-toggle\" aria-hidden=\"true\">{{ showArchived ? '−' : '+' }}</span>\n        </button>\n\n        <transition-group\n          v-show=\"showArchived\"\n          name=\"bangumi\"\n          tag=\"div\"\n          class=\"bangumi-grid archived-grid\"\n        >\n          <div\n            v-for=\"group in groupedArchivedBangumi\"\n            :key=\"group.key\"\n            class=\"bangumi-group-wrapper archived-item\"\n          >\n            <ab-bangumi-card\n              :bangumi=\"group.primary\"\n              type=\"primary\"\n              @click=\"() => onCardClick(group)\"\n            />\n            <!-- Combined notification badge -->\n            <div\n              v-if=\"groupNeedsReview(group) || group.rules.length > 1\"\n              class=\"group-badge\"\n              :class=\"{ 'group-badge--warning': groupNeedsReview(group) }\"\n            >\n              <template v-if=\"groupNeedsReview(group)\">\n                <span class=\"badge-icon\">!</span>\n                <template v-if=\"group.rules.length > 1\">\n                  <span class=\"badge-divider\"></span>\n                  <span class=\"badge-count\">{{ group.rules.length }}</span>\n                </template>\n              </template>\n              <template v-else>\n                {{ group.rules.length }}\n              </template>\n            </div>\n            <div class=\"archived-badge\">{{ $t('homepage.rule.archived') }}</div>\n          </div>\n        </transition-group>\n      </div>\n    </template>\n\n    <!-- Rule list popup for grouped items -->\n    <ab-popup\n      v-model:show=\"ruleListPopup.show\"\n      :title=\"ruleListPopup.group?.primary.official_title || ''\"\n    >\n      <div v-if=\"ruleListPopup.group\" class=\"rule-list\">\n        <div class=\"rule-list-hint\">{{ $t('homepage.rule.select_hint') }}</div>\n        <div\n          v-for=\"rule in ruleListPopup.group.rules\"\n          :key=\"rule.id\"\n          class=\"rule-list-item\"\n          :class=\"[\n            rule.deleted && 'rule-list-item--disabled',\n            rule.needs_review && 'rule-list-item--warning'\n          ]\"\n          @click=\"onRuleSelect(rule)\"\n        >\n          <div class=\"rule-list-item-info\">\n            <div class=\"rule-list-item-title\">\n              <span v-if=\"rule.needs_review\" class=\"warning-text\">! </span>{{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}\n            </div>\n            <div class=\"rule-list-item-tags\">\n              <ab-tag v-if=\"rule.dpi\" :title=\"rule.dpi\" type=\"primary\" />\n              <ab-tag v-if=\"rule.subtitle\" :title=\"rule.subtitle\" type=\"primary\" />\n              <ab-tag v-if=\"rule.source\" :title=\"rule.source\" type=\"primary\" />\n            </div>\n            <div v-if=\"rule.filter && rule.filter.length > 0\" class=\"rule-list-item-filter\">\n              <span class=\"rule-list-item-filter-label\">{{ $t('homepage.rule.filter') }}:</span>\n              <span class=\"rule-list-item-filter-value\">{{ rule.filter.join(', ') }}</span>\n            </div>\n            <div v-if=\"rule.title_raw\" class=\"rule-list-item-raw\">\n              {{ rule.title_raw }}\n            </div>\n          </div>\n          <div class=\"rule-list-item-arrow\">›</div>\n        </div>\n      </div>\n    </ab-popup>\n\n  </div>\n  </ab-pull-refresh>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-bangumi {\n  overflow: auto;\n  flex-grow: 1;\n}\n\n.bangumi-grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n  gap: 12px;\n  padding: 12px;\n  justify-items: center;\n\n  @include forTablet {\n    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));\n    gap: 16px;\n  }\n\n  @include forDesktop {\n    grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));\n    gap: 20px;\n  }\n}\n\n// Skeleton loading cards\n.skeleton-card {\n  width: 150px;\n}\n\n.skeleton-poster {\n  width: 100%;\n  aspect-ratio: 5 / 7;\n  border-radius: var(--radius-md);\n  background: linear-gradient(\n    90deg,\n    var(--color-surface-hover) 25%,\n    var(--color-border) 50%,\n    var(--color-surface-hover) 75%\n  );\n  background-size: 200% 100%;\n  animation: skeleton-shimmer 1.5s ease-in-out infinite;\n}\n\n.skeleton-title {\n  height: 16px;\n  margin-top: 8px;\n  border-radius: 4px;\n  background: linear-gradient(\n    90deg,\n    var(--color-surface-hover) 25%,\n    var(--color-border) 50%,\n    var(--color-surface-hover) 75%\n  );\n  background-size: 200% 100%;\n  animation: skeleton-shimmer 1.5s ease-in-out infinite;\n  animation-delay: 0.1s;\n}\n\n@keyframes skeleton-shimmer {\n  0% {\n    background-position: 200% 0;\n  }\n  100% {\n    background-position: -200% 0;\n  }\n}\n\n.bangumi-group-wrapper {\n  position: relative;\n  overflow: visible;\n  width: fit-content;\n}\n\n.group-badge {\n  position: absolute;\n  top: -8px;\n  right: -8px;\n  min-width: 20px;\n  height: 20px;\n  padding: 0 6px;\n  border-radius: 10px;\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 12px;\n  font-weight: 700;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 3px;\n  z-index: 10;\n  pointer-events: none;\n  box-shadow: 0 2px 6px rgba(124, 77, 255, 0.4);\n\n  // Warning variant - yellow with purple border\n  &--warning {\n    background: #fbbf24;\n    border: 2px solid var(--color-primary);\n    color: var(--color-primary);\n    box-shadow: 0 2px 8px rgba(251, 191, 36, 0.5);\n  }\n\n  .badge-icon {\n    font-weight: 800;\n  }\n\n  .badge-divider {\n    width: 1px;\n    height: 10px;\n    background: currentColor;\n    opacity: 0.5;\n  }\n\n  .badge-count {\n    font-weight: 700;\n  }\n}\n\n.archived-section {\n  margin-top: 24px;\n  padding: 0 8px;\n}\n\n.archived-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n  width: 100%;\n  padding: 12px 8px;\n  margin-bottom: 12px;\n  cursor: pointer;\n  border: none;\n  border-radius: var(--radius-md);\n  background: transparent;\n  font: inherit;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n.archived-title {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n}\n\n.archived-toggle {\n  font-size: 18px;\n  font-weight: 500;\n  color: var(--color-text-muted);\n}\n\n.archived-grid {\n  opacity: 0.7;\n}\n\n.archived-item {\n  filter: grayscale(30%);\n}\n\n.archived-badge {\n  position: absolute;\n  bottom: 36px; // Above the card title area\n  left: 4px;\n  padding: 2px 6px;\n  border-radius: 4px;\n  background: var(--color-overlay);\n  backdrop-filter: blur(4px);\n  color: var(--color-white);\n  font-size: 10px;\n  font-weight: 500;\n  pointer-events: none;\n}\n\n.rule-list {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 8px;\n  min-width: 300px;\n}\n\n.rule-list-hint {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  padding: 4px 12px 8px;\n  border-bottom: 1px solid var(--color-border);\n  margin-bottom: 4px;\n}\n\n.rule-list-item {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n  min-height: var(--touch-target);\n  padding: 12px;\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: -2px;\n  }\n\n  &--disabled {\n    opacity: 0.5;\n  }\n\n  // Warning variant - needs review\n  &--warning {\n    background: linear-gradient(135deg, rgba(251, 191, 36, 0.15) 0%, rgba(251, 191, 36, 0.08) 100%);\n    border-left: 3px solid #fbbf24;\n\n    &:hover {\n      background: linear-gradient(135deg, rgba(251, 191, 36, 0.22) 0%, rgba(251, 191, 36, 0.12) 100%);\n    }\n  }\n\n  // Add spacing between items\n  & + & {\n    margin-top: 8px;\n  }\n}\n\n.warning-text {\n  color: #d97706;\n  font-weight: 700;\n}\n\n.rule-list-item-info {\n  flex: 1;\n  min-width: 0;\n  text-align: left;\n}\n\n.rule-list-item-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rule-list-item-tags {\n  display: flex;\n  gap: 6px;\n  flex-wrap: wrap;\n  margin-bottom: 4px;\n}\n\n.rule-list-item-filter {\n  font-size: 11px;\n  color: var(--color-text-muted);\n  margin-top: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rule-list-item-filter-label {\n  color: var(--color-text-secondary);\n}\n\n.rule-list-item-filter-value {\n  font-family: var(--font-mono, monospace);\n}\n\n.rule-list-item-raw {\n  font-size: 11px;\n  color: var(--color-text-muted);\n  margin-top: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  font-style: italic;\n}\n\n.rule-list-item-arrow {\n  font-size: 18px;\n  color: var(--color-text-muted);\n  flex-shrink: 0;\n  margin-top: 2px;\n}\n\n.empty-guide {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  min-height: 60vh;\n  padding: 24px;\n}\n\n.empty-guide-header {\n  text-align: center;\n  margin-bottom: 32px;\n}\n\n.empty-guide-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 6px;\n}\n\n.empty-guide-subtitle {\n  font-size: 14px;\n  color: var(--color-text-secondary);\n}\n\n.empty-guide-steps {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  max-width: 400px;\n  width: 100%;\n}\n\n.empty-guide-action {\n  margin-top: 24px;\n}\n\n.empty-guide-step {\n  display: flex;\n  align-items: flex-start;\n  gap: 14px;\n  padding: 14px 16px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.empty-guide-step-number {\n  flex-shrink: 0;\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  background: var(--color-primary);\n  color: var(--color-white);\n  font-size: 13px;\n  font-weight: 600;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.empty-guide-step-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.empty-guide-step-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 4px;\n}\n\n.empty-guide-step-desc {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  line-height: 1.4;\n}\n\n.anim-fade-in {\n  animation: fadeIn 0.5s ease both;\n}\n\n.anim-slide-up {\n  animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;\n  animation-delay: var(--delay, 0s);\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: translateY(-8px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(16px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n</style>\n\n<style>\n.bangumi-enter-active,\n.bangumi-leave-active {\n  transition: opacity var(--transition-normal), transform var(--transition-normal);\n}\n.bangumi-enter-from,\n.bangumi-leave-to {\n  opacity: 0;\n  transform: translateY(8px);\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/calendar.vue",
    "content": "<script lang=\"ts\" setup>\nimport { ErrorPicture, Pin, Refresh } from '@icon-park/vue-next';\nimport draggable from 'vuedraggable';\nimport type { BangumiRule } from '#/bangumi';\n\ndefinePage({\n  name: 'Calendar',\n});\n\nconst { t } = useMyI18n();\nconst posterSrc = (link: string | null | undefined) => resolvePosterUrl(link);\nconst { bangumi } = storeToRefs(useBangumiStore());\nconst { getAll, openEditPopup, setWeekday } = useBangumiStore();\nconst { isMobile } = useBreakpointQuery();\n\nconst refreshing = ref(false);\n\nasync function refreshCalendar() {\n  refreshing.value = true;\n  try {\n    await apiBangumi.refreshCalendar();\n    await getAll();\n  } finally {\n    refreshing.value = false;\n  }\n}\n\nonActivated(() => {\n  if (refreshing.value) return;\n  refreshCalendar();\n});\n\nconst DAY_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;\n\nconst todayIndex = computed(() => {\n  // JS getDay(): 0=Sun, 1=Mon, ..., 6=Sat\n  // We want: 0=Mon, 1=Tue, ..., 6=Sun\n  const jsDay = new Date().getDay();\n  return jsDay === 0 ? 6 : jsDay - 1;\n});\n\n// Group bangumi by official_title + season (same logic as main page)\ninterface BangumiGroup {\n  key: string;\n  primary: BangumiRule;\n  rules: BangumiRule[];\n}\n\nfunction groupBangumiList(items: BangumiRule[]): BangumiGroup[] {\n  if (!items) return [];\n  const map = new Map<string, BangumiRule[]>();\n  for (const item of items) {\n    const key = `${item.official_title}::${item.season}`;\n    if (!map.has(key)) {\n      map.set(key, []);\n    }\n    map.get(key)!.push(item);\n  }\n  const groups: BangumiGroup[] = [];\n  for (const [key, rules] of map) {\n    groups.push({ key, primary: rules[0], rules });\n  }\n  return groups;\n}\n\nconst groupedBangumiByDay = computed(() => {\n  const result: Record<string, BangumiGroup[]> = {};\n  DAY_KEYS.forEach((key) => (result[key] = []));\n  result.unknown = [];\n\n  // First, collect items by day\n  const itemsByDay: Record<string, BangumiRule[]> = {};\n  DAY_KEYS.forEach((key) => (itemsByDay[key] = []));\n  itemsByDay.unknown = [];\n\n  bangumi.value?.forEach((item) => {\n    if (item.deleted) return;\n    const weekday = item.air_weekday;\n    if (weekday != null && weekday >= 0 && weekday <= 6) {\n      itemsByDay[DAY_KEYS[weekday]].push(item);\n    } else {\n      itemsByDay.unknown.push(item);\n    }\n  });\n\n  // Then group each day's items\n  for (const key of [...DAY_KEYS, 'unknown']) {\n    result[key] = groupBangumiList(itemsByDay[key]);\n  }\n\n  return result;\n});\n\nconst hasBangumi = computed(() => {\n  return bangumi.value && bangumi.value.some((b) => !b.deleted);\n});\n\nfunction getDayLabel(key: string): string {\n  if (key === 'unknown') return t('calendar.unknown');\n  return isMobile.value\n    ? t(`calendar.days.${key}`)\n    : t(`calendar.days_short.${key}`);\n}\n\nfunction isToday(index: number): boolean {\n  return index === todayIndex.value;\n}\n\n// Rule list popup state (same as main page)\nconst ruleListPopup = reactive<{\n  show: boolean;\n  group: BangumiGroup | null;\n}>({\n  show: false,\n  group: null,\n});\n\nfunction onCardClick(group: BangumiGroup) {\n  if (group.rules.length === 1) {\n    openEditPopup(group.primary);\n  } else {\n    ruleListPopup.group = group;\n    ruleListPopup.show = true;\n  }\n}\n\nfunction onRuleSelect(rule: BangumiRule) {\n  ruleListPopup.show = false;\n  openEditPopup(rule);\n}\n\n// Drag-and-drop state (desktop only)\nconst isDragging = ref(false);\n\nasync function onDropToDay(dayIndex: number, evt: any) {\n  if (evt.added) {\n    const group: BangumiGroup = evt.added.element;\n    for (const rule of group.rules) {\n      await setWeekday(rule.id, dayIndex);\n    }\n  }\n}\n\nasync function onUnpin(group: BangumiGroup, event: Event) {\n  event.stopPropagation();\n  for (const rule of group.rules) {\n    await setWeekday(rule.id, null);\n  }\n}\n\nconst unknownGroups = computed({\n  get: () => groupedBangumiByDay.value.unknown || [],\n  set: () => {\n    // No-op: actual update happens via API in onDropToDay\n  },\n});\n\nfunction getDayGroups(key: string) {\n  return groupedBangumiByDay.value[key] || [];\n}\n</script>\n\n<template>\n  <div class=\"page-calendar\">\n    <!-- Header -->\n    <div class=\"calendar-header anim-fade-in\">\n      <div class=\"calendar-header-text\">\n        <h2 class=\"calendar-title\">{{ $t('calendar.title') }}</h2>\n        <p class=\"calendar-subtitle\">{{ $t('calendar.subtitle') }}</p>\n      </div>\n      <button\n        class=\"calendar-refresh-btn\"\n        :class=\"{ 'calendar-refresh-btn--spinning': refreshing }\"\n        :disabled=\"refreshing\"\n        :title=\"$t('calendar.refresh')\"\n        @click=\"refreshCalendar\"\n      >\n        <Refresh :size=\"18\" />\n      </button>\n    </div>\n\n    <!-- Empty state -->\n    <div v-if=\"!hasBangumi\" class=\"empty-guide\">\n      <div class=\"empty-guide-header anim-fade-in\">\n        <div class=\"empty-guide-title\">{{ $t('calendar.empty_state.title') }}</div>\n        <div class=\"empty-guide-subtitle\">{{ $t('calendar.empty_state.subtitle') }}</div>\n      </div>\n    </div>\n\n    <!-- Desktop: Grid columns -->\n    <div v-else-if=\"!isMobile\" class=\"calendar-desktop\">\n      <div class=\"calendar-grid\">\n        <div\n          v-for=\"(key, index) in DAY_KEYS\"\n          :key=\"key\"\n          class=\"calendar-column anim-slide-up\"\n          :class=\"{\n            'calendar-column--today': isToday(index),\n            'calendar-column--drop-active': isDragging,\n          }\"\n          :style=\"{ '--delay': `${index * 0.05}s` }\"\n        >\n          <!-- Day header -->\n          <div\n            class=\"calendar-day-header\"\n            :class=\"{ 'calendar-day-header--today': isToday(index) }\"\n          >\n            <span class=\"calendar-day-label\">{{ getDayLabel(key) }}</span>\n            <span\n              v-if=\"isToday(index)\"\n              class=\"calendar-today-badge\"\n            >\n              {{ $t('calendar.today') }}\n            </span>\n          </div>\n\n          <!-- Anime cards (grouped) - drop target -->\n          <draggable\n            :model-value=\"getDayGroups(key)\"\n            group=\"calendar\"\n            item-key=\"key\"\n            :sort=\"false\"\n            ghost-class=\"sortable-ghost\"\n            drag-class=\"sortable-drag\"\n            class=\"calendar-column-items\"\n            @change=\"onDropToDay(index, $event)\"\n          >\n            <template #item=\"{ element: group }\">\n              <div class=\"calendar-card-wrapper\">\n                <div\n                  class=\"calendar-card\"\n                  :class=\"{ 'calendar-card--pinned': group.primary.weekday_locked }\"\n                  role=\"button\"\n                  tabindex=\"0\"\n                  :aria-label=\"`Edit ${group.primary.official_title}`\"\n                  @click=\"onCardClick(group)\"\n                  @keydown.enter=\"onCardClick(group)\"\n                >\n                  <div class=\"calendar-card-poster\">\n                    <img\n                      v-if=\"group.primary.poster_link\"\n                      :src=\"posterSrc(group.primary.poster_link)\"\n                      :alt=\"group.primary.official_title\"\n                      class=\"calendar-card-img\"\n                      loading=\"lazy\"\n                    />\n                    <div v-else class=\"calendar-card-placeholder\">\n                      <ErrorPicture theme=\"outline\" size=\"20\" />\n                    </div>\n                    <div class=\"calendar-card-overlay\">\n                      <div class=\"calendar-card-overlay-tags\">\n                        <ab-tag :title=\"`S${group.primary.season}`\" type=\"primary\" />\n                        <ab-tag\n                          v-if=\"group.primary.group_name\"\n                          :title=\"group.primary.group_name\"\n                          type=\"primary\"\n                        />\n                      </div>\n                      <div class=\"calendar-card-overlay-title\">{{ group.primary.official_title }}</div>\n                    </div>\n                    <!-- Pin indicator for manually assigned -->\n                    <div v-if=\"group.primary.weekday_locked\" class=\"calendar-card-pin\">\n                      <Pin theme=\"filled\" size=\"12\" />\n                    </div>\n                  </div>\n                </div>\n                <!-- Unpin button -->\n                <button\n                  v-if=\"group.primary.weekday_locked\"\n                  class=\"calendar-unpin-btn\"\n                  :title=\"$t('calendar.unpin')\"\n                  @click=\"onUnpin(group, $event)\"\n                >\n                  &times;\n                </button>\n                <div v-if=\"group.rules.length > 1\" class=\"group-badge\">\n                  {{ group.rules.length }}\n                </div>\n              </div>\n            </template>\n\n            <template #footer>\n              <div v-if=\"getDayGroups(key).length === 0\" class=\"calendar-empty-day\">\n                {{ isDragging ? $t('calendar.drop_here') : $t('calendar.empty') }}\n              </div>\n            </template>\n          </draggable>\n        </div>\n      </div>\n\n      <!-- Unknown air day section (draggable source) -->\n      <div\n        v-if=\"unknownGroups.length > 0\"\n        class=\"calendar-unknown-section anim-slide-up\"\n        :style=\"{ '--delay': '0.4s' }\"\n      >\n        <div class=\"calendar-unknown-header\">\n          <span class=\"calendar-day-label\">{{ getDayLabel('unknown') }}</span>\n          <span class=\"calendar-drag-hint\">{{ $t('calendar.drag_hint') }}</span>\n        </div>\n        <draggable\n          v-model=\"unknownGroups\"\n          :group=\"{ name: 'calendar', pull: 'clone', put: false }\"\n          item-key=\"key\"\n          :sort=\"false\"\n          ghost-class=\"sortable-ghost\"\n          drag-class=\"sortable-drag\"\n          class=\"calendar-unknown-items\"\n          @start=\"isDragging = true\"\n          @end=\"isDragging = false\"\n        >\n          <template #item=\"{ element: group }\">\n            <div class=\"calendar-card-wrapper\">\n              <div\n                class=\"calendar-card\"\n                role=\"button\"\n                tabindex=\"0\"\n                :aria-label=\"`Edit ${group.primary.official_title}`\"\n                @click=\"onCardClick(group)\"\n                @keydown.enter=\"onCardClick(group)\"\n              >\n                <div class=\"calendar-card-poster\">\n                  <img\n                    v-if=\"group.primary.poster_link\"\n                    :src=\"posterSrc(group.primary.poster_link)\"\n                    :alt=\"group.primary.official_title\"\n                    class=\"calendar-card-img\"\n                    loading=\"lazy\"\n                  />\n                  <div v-else class=\"calendar-card-placeholder\">\n                    <ErrorPicture theme=\"outline\" size=\"20\" />\n                  </div>\n                  <div class=\"calendar-card-overlay\">\n                    <div class=\"calendar-card-overlay-tags\">\n                      <ab-tag :title=\"`S${group.primary.season}`\" type=\"primary\" />\n                      <ab-tag\n                        v-if=\"group.primary.group_name\"\n                        :title=\"group.primary.group_name\"\n                        type=\"primary\"\n                      />\n                    </div>\n                    <div class=\"calendar-card-overlay-title\">{{ group.primary.official_title }}</div>\n                  </div>\n                </div>\n              </div>\n              <div v-if=\"group.rules.length > 1\" class=\"group-badge\">\n                {{ group.rules.length }}\n              </div>\n            </div>\n          </template>\n        </draggable>\n      </div>\n    </div>\n\n    <!-- Mobile: Vertical list -->\n    <div v-else class=\"calendar-list\">\n      <template v-for=\"(key, index) in [...DAY_KEYS, 'unknown']\" :key=\"key\">\n        <div\n          v-if=\"groupedBangumiByDay[key].length > 0\"\n          class=\"calendar-section anim-slide-up\"\n          :style=\"{ '--delay': `${index * 0.05}s` }\"\n        >\n          <!-- Day divider -->\n          <div\n            class=\"calendar-section-header\"\n            :class=\"{ 'calendar-section-header--today': key !== 'unknown' && isToday(index) }\"\n          >\n            <span class=\"calendar-section-label\">{{ getDayLabel(key) }}</span>\n            <span\n              v-if=\"key !== 'unknown' && isToday(index)\"\n              class=\"calendar-today-badge calendar-today-badge--small\"\n            >\n              {{ $t('calendar.today') }}\n            </span>\n          </div>\n\n          <!-- Anime rows (grouped) -->\n          <div class=\"calendar-section-items\">\n            <div\n              v-for=\"group in groupedBangumiByDay[key]\"\n              :key=\"group.key\"\n              class=\"calendar-row\"\n              role=\"button\"\n              tabindex=\"0\"\n              :aria-label=\"`Edit ${group.primary.official_title}`\"\n              @click=\"onCardClick(group)\"\n              @keydown.enter=\"onCardClick(group)\"\n            >\n              <div class=\"calendar-row-poster\">\n                <img\n                  v-if=\"group.primary.poster_link\"\n                  :src=\"posterSrc(group.primary.poster_link)\"\n                  :alt=\"group.primary.official_title\"\n                  class=\"calendar-row-img\"\n                  loading=\"lazy\"\n                />\n                <div v-else class=\"calendar-row-placeholder\">\n                  <ErrorPicture theme=\"outline\" size=\"16\" />\n                </div>\n              </div>\n              <div class=\"calendar-row-info\">\n                <div class=\"calendar-row-title\">\n                  {{ group.primary.official_title }}\n                  <span v-if=\"group.rules.length > 1\" class=\"calendar-row-badge\">\n                    {{ group.rules.length }}\n                  </span>\n                </div>\n                <div class=\"calendar-row-meta\">\n                  <ab-tag :title=\"`S${group.primary.season}`\" type=\"primary\" />\n                  <ab-tag\n                    v-if=\"group.primary.group_name\"\n                    :title=\"group.primary.group_name\"\n                    type=\"primary\"\n                  />\n                </div>\n              </div>\n            </div>\n          </div>\n        </div>\n      </template>\n\n      <!-- All days empty on mobile -->\n      <div v-if=\"!hasBangumi\" class=\"calendar-empty-day calendar-empty-day--mobile\">\n        {{ $t('calendar.no_data') }}\n      </div>\n    </div>\n\n    <!-- Rule list popup for grouped items -->\n    <ab-popup\n      v-model:show=\"ruleListPopup.show\"\n      :title=\"ruleListPopup.group?.primary.official_title || ''\"\n    >\n      <div v-if=\"ruleListPopup.group\" class=\"rule-list\">\n        <div class=\"rule-list-hint\">{{ $t('homepage.rule.select_hint') }}</div>\n        <div\n          v-for=\"rule in ruleListPopup.group.rules\"\n          :key=\"rule.id\"\n          class=\"rule-list-item\"\n          :class=\"[rule.deleted && 'rule-list-item--disabled']\"\n          @click=\"onRuleSelect(rule)\"\n        >\n          <div class=\"rule-list-item-info\">\n            <div class=\"rule-list-item-title\">\n              {{ rule.group_name || rule.rule_name || $t('homepage.rule.unnamed') }}\n            </div>\n            <div class=\"rule-list-item-tags\">\n              <ab-tag v-if=\"rule.dpi\" :title=\"rule.dpi\" type=\"primary\" />\n              <ab-tag v-if=\"rule.subtitle\" :title=\"rule.subtitle\" type=\"primary\" />\n              <ab-tag v-if=\"rule.source\" :title=\"rule.source\" type=\"primary\" />\n            </div>\n            <div v-if=\"rule.filter && rule.filter.length > 0\" class=\"rule-list-item-filter\">\n              <span class=\"rule-list-item-filter-label\">{{ $t('homepage.rule.filter') }}:</span>\n              <span class=\"rule-list-item-filter-value\">{{ rule.filter.join(', ') }}</span>\n            </div>\n            <div v-if=\"rule.title_raw\" class=\"rule-list-item-raw\">\n              {{ rule.title_raw }}\n            </div>\n          </div>\n          <div class=\"rule-list-item-arrow\">›</div>\n        </div>\n      </div>\n    </ab-popup>\n\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-calendar {\n  overflow: auto;\n  flex-grow: 1;\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n}\n\n// Header\n.calendar-header {\n  display: flex;\n  align-items: center;\n  justify-content: space-between;\n}\n\n.calendar-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0;\n  transition: color var(--transition-normal);\n}\n\n.calendar-subtitle {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  margin: 4px 0 0;\n  transition: color var(--transition-normal);\n}\n\n.calendar-refresh-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  width: 34px;\n  height: 34px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  color: var(--color-text-secondary);\n  cursor: pointer;\n  transition: color var(--transition-fast),\n              border-color var(--transition-fast),\n              background-color var(--transition-fast);\n\n  &:hover:not(:disabled) {\n    color: var(--color-primary);\n    border-color: var(--color-primary);\n    background: var(--color-primary-light);\n  }\n\n  &:disabled {\n    opacity: 0.6;\n    cursor: not-allowed;\n  }\n\n  &--spinning {\n    :deep(svg) {\n      animation: spin 1s linear infinite;\n    }\n  }\n}\n\n@keyframes spin {\n  from { transform: rotate(0deg); }\n  to { transform: rotate(360deg); }\n}\n\n// Desktop layout\n.calendar-desktop {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  flex: 1;\n}\n\n// Desktop grid\n.calendar-grid {\n  display: grid;\n  grid-template-columns: repeat(7, 1fr);\n  gap: 10px;\n}\n\n.calendar-column {\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-lg);\n  padding: 10px;\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n\n  &--today {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 1px var(--color-primary-light);\n  }\n}\n\n// Unknown air day section\n.calendar-unknown-section {\n  background: var(--color-surface-hover);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-lg);\n  padding: 12px;\n}\n\n.calendar-unknown-header {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 6px;\n  margin-bottom: 10px;\n}\n\n.calendar-unknown-items {\n  display: grid;\n  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));\n  gap: 10px;\n}\n\n.calendar-day-header {\n  display: flex;\n  align-items: center;\n  gap: 6px;\n  padding: 4px 6px;\n  border-radius: var(--radius-sm);\n  transition: background-color var(--transition-fast);\n\n  &--today {\n    background: var(--color-primary-light);\n  }\n}\n\n.calendar-day-label {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--color-text-secondary);\n  transition: color var(--transition-normal);\n\n  .calendar-day-header--today & {\n    color: var(--color-primary);\n  }\n}\n\n.calendar-today-badge {\n  font-size: 11px;\n  font-weight: 500;\n  color: var(--color-primary);\n  background: var(--color-primary-light);\n  padding: 1px 6px;\n  border-radius: var(--radius-full);\n\n  &--small {\n    font-size: 10px;\n    padding: 0 5px;\n  }\n}\n\n.calendar-column-items {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n  flex: 1;\n}\n\n// Card wrapper for badge positioning\n.calendar-card-wrapper {\n  position: relative;\n}\n\n.group-badge {\n  position: absolute;\n  top: -8px;\n  right: -8px;\n  min-width: 20px;\n  height: 20px;\n  padding: 0 5px;\n  border-radius: 10px;\n  background: var(--color-badge-bg);\n  color: var(--color-white);\n  font-size: 11px;\n  font-weight: 600;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: var(--z-dropdown);\n  pointer-events: none;\n  box-shadow: 0 2px 4px var(--color-badge-shadow);\n}\n\n// Desktop card\n.calendar-card {\n  cursor: pointer;\n  user-select: none;\n  border-radius: var(--radius-md);\n  transition: transform var(--transition-fast),\n              box-shadow var(--transition-fast);\n\n  &:hover {\n    transform: translateY(-2px);\n    box-shadow: var(--shadow-md);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n.calendar-card-poster {\n  position: relative;\n  border-radius: var(--radius-sm);\n  overflow: hidden;\n  aspect-ratio: 2 / 3;\n}\n\n.calendar-card-img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n.calendar-card-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-surface-hover);\n  color: var(--color-text-muted);\n  transition: background-color var(--transition-normal);\n}\n\n.calendar-card-overlay {\n  position: absolute;\n  inset: 0;\n  opacity: 0;\n  background: rgba(0, 0, 0, 0.3);\n  backdrop-filter: blur(2px);\n  transition: opacity var(--transition-normal);\n\n  .calendar-card:hover & {\n    opacity: 1;\n  }\n}\n\n.calendar-card-overlay-title {\n  position: absolute;\n  top: 6px;\n  left: 6px;\n  right: 6px;\n  font-size: 11px;\n  font-weight: 500;\n  color: #fff;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);\n}\n\n.calendar-card-overlay-tags {\n  position: absolute;\n  bottom: 5px;\n  left: 5px;\n  right: 5px;\n  display: flex;\n  gap: 3px;\n  flex-wrap: wrap;\n\n  :deep(.tag) {\n    background: rgba(0, 0, 0, 0.5);\n    border-color: rgba(255, 255, 255, 0.4);\n    color: #fff;\n    font-size: 9px;\n    padding: 1px 5px;\n  }\n}\n\n// Drag hint text\n.calendar-drag-hint {\n  font-size: 11px;\n  color: var(--color-text-muted);\n  font-style: italic;\n}\n\n// Drop active state\n.calendar-column--drop-active {\n  border-color: var(--color-primary);\n  border-style: dashed;\n  background: var(--color-primary-light);\n}\n\n// Pin indicator\n.calendar-card-pin {\n  position: absolute;\n  top: 4px;\n  right: 4px;\n  width: 20px;\n  height: 20px;\n  border-radius: 50%;\n  background: var(--color-primary);\n  color: #fff;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: 2;\n  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);\n}\n\n// Unpin button\n.calendar-unpin-btn {\n  position: absolute;\n  top: -6px;\n  left: -6px;\n  width: 18px;\n  height: 18px;\n  border-radius: 50%;\n  background: var(--color-danger, #e74c3c);\n  color: #fff;\n  border: none;\n  font-size: 12px;\n  line-height: 1;\n  cursor: pointer;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  z-index: var(--z-dropdown);\n  opacity: 0;\n  transition: opacity var(--transition-fast);\n\n  .calendar-card-wrapper:hover & {\n    opacity: 1;\n  }\n}\n\n// Pinned card subtle styling\n.calendar-card--pinned {\n  box-shadow: 0 0 0 1.5px var(--color-primary);\n  border-radius: var(--radius-md);\n}\n\n// vuedraggable ghost and drag classes\n.sortable-ghost {\n  opacity: 0.4;\n}\n\n.sortable-drag {\n  opacity: 0.9;\n  box-shadow: var(--shadow-lg);\n  transform: rotate(2deg);\n}\n\n// Empty day\n.calendar-empty-day {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  text-align: center;\n  padding: 12px 4px;\n  transition: color var(--transition-normal);\n\n  &--mobile {\n    padding: 32px 16px;\n    font-size: 14px;\n  }\n}\n\n// Mobile list\n.calendar-list {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  flex: 1;\n}\n\n.calendar-section {\n  margin-bottom: 8px;\n}\n\n.calendar-section-header {\n  display: flex;\n  align-items: center;\n  gap: 8px;\n  padding: 8px 0 6px;\n  border-bottom: 1px solid var(--color-border);\n  margin-bottom: 6px;\n  transition: border-color var(--transition-normal);\n\n  &--today {\n    border-bottom-color: var(--color-primary);\n  }\n}\n\n.calendar-section-label {\n  font-size: 13px;\n  font-weight: 600;\n  color: var(--color-text-muted);\n  letter-spacing: 0.3px;\n  transition: color var(--transition-normal);\n\n  .calendar-section-header--today & {\n    color: var(--color-primary);\n  }\n}\n\n.calendar-section-items {\n  display: flex;\n  flex-direction: column;\n}\n\n.calendar-row {\n  display: flex;\n  align-items: center;\n  gap: 12px;\n  padding: 8px;\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  user-select: none;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n}\n\n.calendar-row-poster {\n  width: 44px;\n  height: 62px;\n  border-radius: var(--radius-sm);\n  overflow: hidden;\n  flex-shrink: 0;\n}\n\n.calendar-row-img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n.calendar-row-placeholder {\n  width: 100%;\n  height: 100%;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-surface-hover);\n  color: var(--color-text-muted);\n  transition: background-color var(--transition-normal);\n}\n\n.calendar-row-info {\n  flex: 1;\n  min-width: 0;\n}\n\n.calendar-row-title {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  margin-bottom: 4px;\n  transition: color var(--transition-normal);\n}\n\n.calendar-row-meta {\n  display: flex;\n  gap: 4px;\n  flex-wrap: wrap;\n}\n\n.calendar-row-badge {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  min-width: 18px;\n  height: 18px;\n  padding: 0 5px;\n  margin-left: 6px;\n  border-radius: 9px;\n  background: var(--color-badge-bg);\n  color: var(--color-white);\n  font-size: 11px;\n  font-weight: 600;\n  vertical-align: middle;\n}\n\n// Rule list popup (same as main page)\n.rule-list {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n  padding: 8px;\n  min-width: 300px;\n}\n\n.rule-list-hint {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  padding: 4px 12px 8px;\n  border-bottom: 1px solid var(--color-border);\n  margin-bottom: 4px;\n}\n\n.rule-list-item {\n  display: flex;\n  align-items: flex-start;\n  justify-content: space-between;\n  gap: 12px;\n  min-height: var(--touch-target);\n  padding: 12px;\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-surface-hover);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: -2px;\n  }\n\n  &--disabled {\n    opacity: 0.5;\n  }\n}\n\n.rule-list-item-info {\n  flex: 1;\n  min-width: 0;\n  text-align: left;\n}\n\n.rule-list-item-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rule-list-item-tags {\n  display: flex;\n  gap: 6px;\n  flex-wrap: wrap;\n  margin-bottom: 4px;\n}\n\n.rule-list-item-filter {\n  font-size: 11px;\n  color: var(--color-text-muted);\n  margin-top: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rule-list-item-filter-label {\n  color: var(--color-text-secondary);\n}\n\n.rule-list-item-filter-value {\n  font-family: var(--font-mono, monospace);\n}\n\n.rule-list-item-raw {\n  font-size: 11px;\n  color: var(--color-text-muted);\n  margin-top: 4px;\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n  font-style: italic;\n}\n\n.rule-list-item-arrow {\n  font-size: 18px;\n  color: var(--color-text-muted);\n  flex-shrink: 0;\n  margin-top: 2px;\n}\n\n// Empty state (reuse pattern from bangumi page)\n.empty-guide {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  min-height: 40vh;\n  padding: 24px;\n}\n\n.empty-guide-header {\n  text-align: center;\n}\n\n.empty-guide-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 6px;\n  transition: color var(--transition-normal);\n}\n\n.empty-guide-subtitle {\n  font-size: 14px;\n  color: var(--color-text-secondary);\n  transition: color var(--transition-normal);\n}\n\n// Animations\n.anim-fade-in {\n  animation: fadeIn 0.5s ease both;\n}\n\n.anim-slide-up {\n  animation: slideUp 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;\n  animation-delay: var(--delay, 0s);\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: translateY(-6px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(12px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .anim-fade-in,\n  .anim-slide-up {\n    animation: none;\n  }\n\n  .calendar-card {\n    &:hover {\n      transform: none;\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/config.vue",
    "content": "<script lang=\"ts\" setup>\ndefinePage({\n  name: 'Config',\n});\n\nconst { getConfig, setConfig } = useConfigStore();\nconst { isMobileOrTablet } = useBreakpointQuery();\n\nconst isSaving = ref(false);\nconst isResetting = ref(false);\n\nasync function handleSave() {\n  isSaving.value = true;\n  try {\n    await setConfig();\n  } finally {\n    isSaving.value = false;\n  }\n}\n\nasync function handleReset() {\n  isResetting.value = true;\n  try {\n    await getConfig();\n  } finally {\n    isResetting.value = false;\n  }\n}\n\nonActivated(() => {\n  getConfig();\n});\n</script>\n\n<template>\n  <div class=\"page-config\">\n    <div class=\"config-grid\">\n      <div class=\"config-col\">\n        <config-normal></config-normal>\n        <config-parser></config-parser>\n        <config-download></config-download>\n        <config-manage></config-manage>\n      </div>\n\n      <div class=\"config-col\">\n        <config-notification></config-notification>\n        <config-proxy></config-proxy>\n        <config-search-provider></config-search-provider>\n        <config-player></config-player>\n        <config-openai></config-openai>\n        <config-passkey></config-passkey>\n        <config-security></config-security>\n      </div>\n    </div>\n\n    <div class=\"config-actions\">\n      <ab-button\n        :size=\"isMobileOrTablet ? 'big' : 'normal'\"\n        :class=\"[{ 'flex-1': isMobileOrTablet }]\"\n        type=\"secondary\"\n        :loading=\"isResetting\"\n        :disabled=\"isResetting || isSaving\"\n        @click=\"handleReset\"\n      >\n        {{ $t('config.cancel') }}\n      </ab-button>\n      <ab-button\n        :size=\"isMobileOrTablet ? 'big' : 'normal'\"\n        :class=\"[{ 'flex-1': isMobileOrTablet }]\"\n        type=\"primary\"\n        :loading=\"isSaving\"\n        :disabled=\"isResetting || isSaving\"\n        @click=\"handleSave\"\n      >\n        {{ $t('config.apply') }}\n      </ab-button>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-config {\n  overflow-x: hidden;\n  overflow-y: auto;\n  flex-grow: 1;\n  display: flex;\n  flex-direction: column;\n}\n\n.config-grid {\n  display: grid;\n  grid-template-columns: 1fr;\n  gap: 12px;\n  flex: 1;\n  min-width: 0; // Allow grid to shrink below content size\n  width: 100%;\n\n  @include forDesktop {\n    grid-template-columns: 1fr 1fr;\n  }\n}\n\n.config-col {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  min-width: 0; // Allow column to shrink below content size\n  width: 100%;\n}\n\n.config-actions {\n  position: sticky;\n  bottom: 0;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 12px;\n  margin-top: 16px;\n  padding: 12px;\n  border-radius: var(--radius-md);\n  backdrop-filter: blur(12px);\n  background: color-mix(in srgb, var(--color-surface) 90%, transparent);\n  border: 1px solid var(--color-border);\n\n  // Override button max-width on mobile to allow flex grow\n  :deep(.btn) {\n    max-width: none;\n  }\n\n  @include forTablet {\n    justify-content: flex-end;\n    padding: 12px 0;\n    border-radius: 0;\n    border: none;\n    background: color-mix(in srgb, var(--color-bg) 80%, transparent);\n\n    // Restore button max-width on tablet+\n    :deep(.btn) {\n      max-width: 170px;\n\n      &.btn--big {\n        max-width: 276px;\n      }\n    }\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/downloader.vue",
    "content": "<script lang=\"tsx\" setup>\nimport { type DataTableColumns, NDataTable, NProgress } from 'naive-ui';\nimport type { QbTorrentInfo, TorrentGroup } from '#/downloader';\n\ndefinePage({\n  name: 'Downloader',\n});\n\nconst { t } = useMyI18n();\nconst { config } = storeToRefs(useConfigStore());\nconst { getConfig } = useConfigStore();\nconst { groups, selectedHashes, loading } = storeToRefs(useDownloaderStore());\nconst {\n  getAll,\n  pauseSelected,\n  resumeSelected,\n  deleteSelected,\n  toggleHash,\n  toggleGroup,\n  clearSelection,\n} = useDownloaderStore();\n\nconst isNull = computed(() => {\n  return config.value.downloader.host === '';\n});\n\nconst isActive = ref(false);\nconst { pause, resume } = useIntervalFn(getAll, 5000, { immediate: false });\n\nonActivated(async () => {\n  isActive.value = true;\n  await getConfig();\n  if (isActive.value && !isNull.value) {\n    getAll();\n    resume();\n  }\n});\n\nonDeactivated(() => {\n  isActive.value = false;\n  pause();\n  clearSelection();\n});\n\nfunction formatSize(bytes: number): string {\n  if (bytes === 0) return '0 B';\n  const units = ['B', 'KB', 'MB', 'GB', 'TB'];\n  const i = Math.floor(Math.log(bytes) / Math.log(1024));\n  return `${(bytes / 1024**i).toFixed(1)  } ${  units[i]}`;\n}\n\nfunction formatSpeed(bytesPerSec: number): string {\n  if (bytesPerSec === 0) return '-';\n  return `${formatSize(bytesPerSec)  }/s`;\n}\n\nfunction formatEta(seconds: number): string {\n  if (seconds <= 0 || seconds === 8640000) return '-';\n  if (seconds < 60) return `${seconds}s`;\n  if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;\n  const h = Math.floor(seconds / 3600);\n  const m = Math.floor((seconds % 3600) / 60);\n  return `${h}h${m}m`;\n}\n\nfunction stateLabel(state: string): string {\n  const map: Record<string, string> = {\n    downloading: t('downloader.state.downloading'),\n    uploading: t('downloader.state.seeding'),\n    pausedDL: t('downloader.state.paused'),\n    pausedUP: t('downloader.state.paused'),\n    stalledDL: t('downloader.state.stalled'),\n    stalledUP: t('downloader.state.seeding'),\n    queuedDL: t('downloader.state.queued'),\n    queuedUP: t('downloader.state.queued'),\n    checkingDL: t('downloader.state.checking'),\n    checkingUP: t('downloader.state.checking'),\n    error: t('downloader.state.error'),\n    missingFiles: t('downloader.state.error'),\n    metaDL: t('downloader.state.metadata'),\n  };\n  return map[state] || state;\n}\n\nfunction stateType(state: string): string {\n  if (state.includes('paused')) return 'inactive';\n  if (state === 'downloading' || state === 'forcedDL') return 'active';\n  if (state.includes('UP') || state === 'uploading') return 'primary';\n  if (state === 'error' || state === 'missingFiles') return 'warn';\n  return 'primary';\n}\n\nfunction isGroupAllSelected(group: TorrentGroup): boolean {\n  return group.torrents.every((t) => selectedHashes.value.includes(t.hash));\n}\n\nconst tableColumnsValue = computed<DataTableColumns<QbTorrentInfo>>(() => [\n  {\n    type: 'selection',\n  },\n  {\n    title: t('downloader.torrent.name'),\n    key: 'name',\n    ellipsis: { tooltip: true },\n    minWidth: 200,\n  },\n  {\n    title: t('downloader.torrent.progress'),\n    key: 'progress',\n    width: 160,\n    render(row: QbTorrentInfo) {\n      return (\n        <NProgress\n          type=\"line\"\n          percentage={Math.round(row.progress * 100)}\n          indicator-placement=\"inside\"\n          processing={row.state === 'downloading' || row.state === 'forcedDL'}\n        />\n      );\n    },\n  },\n  {\n    title: t('downloader.torrent.status'),\n    key: 'state',\n    width: 100,\n    render(row: QbTorrentInfo) {\n      return <ab-tag type={stateType(row.state)} title={stateLabel(row.state)} />;\n    },\n  },\n  {\n    title: t('downloader.torrent.size'),\n    key: 'size',\n    width: 100,\n    render(row: QbTorrentInfo) {\n      return formatSize(row.size);\n    },\n  },\n  {\n    title: t('downloader.torrent.dlspeed'),\n    key: 'dlspeed',\n    width: 110,\n    render(row: QbTorrentInfo) {\n      return formatSpeed(row.dlspeed);\n    },\n  },\n  {\n    title: t('downloader.torrent.upspeed'),\n    key: 'upspeed',\n    width: 110,\n    render(row: QbTorrentInfo) {\n      return formatSpeed(row.upspeed);\n    },\n  },\n  {\n    title: 'ETA',\n    key: 'eta',\n    width: 80,\n    render(row: QbTorrentInfo) {\n      return formatEta(row.eta);\n    },\n  },\n  {\n    title: t('downloader.torrent.peers'),\n    key: 'peers',\n    width: 90,\n    render(row: QbTorrentInfo) {\n      return `${row.num_seeds} / ${row.num_leechs}`;\n    },\n  },\n]);\n\nfunction tableRowKey(row: QbTorrentInfo) {\n  return row.hash;\n}\n\nfunction onCheckedChange(group: TorrentGroup, keys: string[]) {\n  const groupHashes = group.torrents.map((t) => t.hash);\n  const otherSelected = selectedHashes.value.filter(\n    (h) => !groupHashes.includes(h)\n  );\n  selectedHashes.value = [...otherSelected, ...keys];\n}\n\nfunction groupCheckedKeys(group: TorrentGroup): string[] {\n  return group.torrents\n    .filter((t) => selectedHashes.value.includes(t.hash))\n    .map((t) => t.hash);\n}\n</script>\n\n<template>\n  <div class=\"page-downloader\">\n    <div v-if=\"isNull\" class=\"empty-guide\">\n      <div class=\"empty-guide-header anim-fade-in\">\n        <div class=\"empty-guide-title\">{{ $t('downloader.empty.title') }}</div>\n        <div class=\"empty-guide-subtitle\">{{ $t('downloader.empty.subtitle') }}</div>\n      </div>\n\n      <div class=\"empty-guide-steps\">\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.15s\">\n          <div class=\"empty-guide-step-number\">1</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('downloader.empty.step1_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('downloader.empty.step1_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.3s\">\n          <div class=\"empty-guide-step-number\">2</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('downloader.empty.step2_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('downloader.empty.step2_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.45s\">\n          <div class=\"empty-guide-step-number\">3</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('downloader.empty.step3_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('downloader.empty.step3_desc') }}</div>\n          </div>\n        </div>\n      </div>\n\n      <RouterLink to=\"/config\" class=\"empty-guide-action anim-slide-up\" style=\"--delay: 0.6s\">\n        {{ $t('sidebar.config') }}\n      </RouterLink>\n    </div>\n\n    <div v-else class=\"downloader-content\">\n      <div v-if=\"groups.length === 0 && !loading\" class=\"downloader-empty\">\n        {{ $t('downloader.empty_torrents') }}\n      </div>\n\n      <div v-else class=\"downloader-groups\">\n        <ab-fold-panel\n          v-for=\"group in groups\"\n          :key=\"group.savePath\"\n          :title=\"`${group.name} (${group.count})`\"\n          :default-open=\"true\"\n        >\n          <NDataTable\n            :columns=\"tableColumnsValue\"\n            :data=\"group.torrents\"\n            :row-key=\"tableRowKey\"\n            :pagination=\"false\"\n            :bordered=\"false\"\n            :checked-row-keys=\"groupCheckedKeys(group)\"\n            size=\"small\"\n            @update:checked-row-keys=\"(keys: any) => onCheckedChange(group, keys as string[])\"\n          />\n        </ab-fold-panel>\n      </div>\n\n      <Transition name=\"fade\">\n        <div v-if=\"selectedHashes.length > 0\" class=\"action-bar\">\n          <span class=\"action-bar-count\">\n            {{ selectedHashes.length }} {{ $t('downloader.selected') }}\n          </span>\n          <div class=\"action-bar-buttons\">\n            <ab-button size=\"small\" @click=\"resumeSelected\">{{ $t('downloader.action.resume') }}</ab-button>\n            <ab-button size=\"small\" @click=\"pauseSelected\">{{ $t('downloader.action.pause') }}</ab-button>\n            <ab-button size=\"small\" type=\"warn\" @click=\"deleteSelected(false)\">{{ $t('downloader.action.delete') }}</ab-button>\n          </div>\n        </div>\n      </Transition>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-downloader {\n  overflow: auto;\n  flex-grow: 1;\n  display: flex;\n  flex-direction: column;\n}\n\n.downloader-content {\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  gap: 12px;\n  padding-bottom: 60px;\n}\n\n.downloader-groups {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.downloader-empty {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  flex: 1;\n  color: var(--color-text-secondary);\n  font-size: 14px;\n}\n\n.action-bar {\n  position: fixed;\n  bottom: calc(24px + env(safe-area-inset-bottom, 0px));\n  left: 50%;\n  transform: translateX(-50%);\n  display: flex;\n  align-items: center;\n  gap: 16px;\n  padding: 10px 20px;\n  border-radius: var(--radius-md);\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);\n  z-index: 100;\n  max-width: calc(100vw - 32px);\n\n  @include forMobile {\n    bottom: calc(72px + env(safe-area-inset-bottom, 0px));\n    left: 16px;\n    right: 16px;\n    transform: none;\n    flex-direction: column;\n    gap: 8px;\n    padding: 12px 16px;\n  }\n}\n\n.action-bar-count {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  white-space: nowrap;\n}\n\n.action-bar-buttons {\n  display: flex;\n  gap: 8px;\n\n  @include forMobile {\n    width: 100%;\n\n    :deep(.btn) {\n      flex: 1;\n    }\n  }\n}\n\n.fade-enter-active,\n.fade-leave-active {\n  transition: opacity 0.2s ease, transform 0.2s ease;\n}\n\n.fade-enter-from,\n.fade-leave-to {\n  opacity: 0;\n  transform: translateX(-50%) translateY(8px);\n}\n\n.empty-guide {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  flex: 1;\n  padding: 24px;\n}\n\n.empty-guide-header {\n  text-align: center;\n  margin-bottom: 32px;\n}\n\n.empty-guide-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 6px;\n}\n\n.empty-guide-subtitle {\n  font-size: 14px;\n  color: var(--color-text-secondary);\n}\n\n.empty-guide-steps {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  max-width: 400px;\n  width: 100%;\n}\n\n.empty-guide-step {\n  display: flex;\n  align-items: flex-start;\n  gap: 14px;\n  padding: 14px 16px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.empty-guide-step-number {\n  flex-shrink: 0;\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 13px;\n  font-weight: 600;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.empty-guide-step-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.empty-guide-step-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 4px;\n}\n\n.empty-guide-step-desc {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  line-height: 1.4;\n}\n\n.empty-guide-action {\n  margin-top: 24px;\n  padding: 8px 24px;\n  border-radius: var(--radius-md);\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 14px;\n  font-weight: 500;\n  text-decoration: none;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-primary-hover);\n  }\n}\n\n.anim-fade-in {\n  animation: fadeIn 0.5s ease both;\n}\n\n.anim-slide-up {\n  animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;\n  animation-delay: var(--delay, 0s);\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: translateY(-8px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(16px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/log.vue",
    "content": "<script lang=\"ts\" setup>\nimport { watchOnce } from '@vueuse/core';\n\ndefinePage({\n  name: 'Log',\n});\n\nconst { onUpdate, offUpdate, reset, copy, getLog } = useLogStore();\nconst { log } = storeToRefs(useLogStore());\nconst { version } = useAppInfo();\n\n// Filter states\nconst selectedLevels = ref<string[]>([]);\n\n// Log levels\nconst logLevels = ['INFO', 'WARNING', 'ERROR', 'DEBUG'];\n\nconst formatLog = computed(() => {\n  const list = log.value\n    .trim()\n    .split('\\n')\n    .filter((i) => i !== '');\n  const startIndex = list.findIndex((i) => /Version/.test(i));\n\n  return list.slice(startIndex === -1 ? 0 : startIndex).map((i, index) => {\n    const date = i.match(/\\[\\d+-\\d+-\\d+\\ \\d+:\\d+:\\d+\\]/)?.[0] || '';\n    const type = i.match(/(INFO)|(WARNING)|(ERROR)|(DEBUG)/)?.[0] || '';\n    const content = i.replace(date, '').replace(`${type}:`, '').trim();\n\n    return {\n      index,\n      date,\n      type,\n      content,\n    };\n  });\n});\n\n// Filtered logs based on selected levels\nconst filteredLog = computed(() => {\n  if (selectedLevels.value.length === 0) {\n    return formatLog.value;\n  }\n  return formatLog.value.filter((entry) =>\n    selectedLevels.value.includes(entry.type)\n  );\n});\n\n// Toggle level filter\nfunction toggleLevel(level: string) {\n  const index = selectedLevels.value.indexOf(level);\n  if (index === -1) {\n    selectedLevels.value.push(level);\n  } else {\n    selectedLevels.value.splice(index, 1);\n  }\n}\n\n// Clear all filters\nfunction clearFilters() {\n  selectedLevels.value = [];\n}\n\nfunction typeColor(type: string) {\n  const M: Record<string, string> = {\n    INFO: 'var(--color-primary)',\n    WARNING: 'var(--color-warning)',\n    ERROR: 'var(--color-danger)',\n    DEBUG: 'var(--color-text-muted)',\n  };\n  return M[type] || 'var(--color-text)';\n}\n\nconst logContainer = ref<HTMLElement | null>(null);\n\nfunction backToBottom() {\n  if (logContainer.value) {\n    logContainer.value.scrollTop = logContainer.value.scrollHeight;\n  }\n}\n\nonActivated(() => {\n  onUpdate();\n\n  if (log.value) {\n    backToBottom();\n  } else {\n    watchOnce(\n      () => log.value,\n      () => {\n        nextTick(() => {\n          backToBottom();\n        });\n      }\n    );\n  }\n});\n\nonDeactivated(() => {\n  offUpdate();\n});\n</script>\n\n<template>\n  <div class=\"page-log\">\n    <div class=\"log-layout\">\n      <ab-container :title=\"$t('log.title')\" class=\"log-main\">\n        <!-- Level Filter Section -->\n        <div class=\"log-filters\">\n          <div class=\"filter-group\">\n            <span class=\"filter-label\">{{ $t('log.filter_level') }}</span>\n            <div class=\"filter-chips\">\n              <button\n                v-for=\"level in logLevels\"\n                :key=\"level\"\n                class=\"filter-chip\"\n                :class=\"{\n                  active: selectedLevels.includes(level),\n                  [`level-${level.toLowerCase()}`]: true,\n                }\"\n                @click=\"toggleLevel(level)\"\n              >\n                {{ level }}\n              </button>\n            </div>\n          </div>\n\n          <button\n            v-if=\"selectedLevels.length > 0\"\n            class=\"clear-filters\"\n            @click=\"clearFilters\"\n          >\n            {{ $t('log.clear_filters') }}\n          </button>\n        </div>\n\n        <div ref=\"logContainer\" class=\"log-viewer\">\n          <div class=\"log-content\">\n            <template v-for=\"i in filteredLog\" :key=\"i.index\">\n              <div\n                class=\"log-entry\"\n                :style=\"{ color: typeColor(i.type) }\"\n              >\n                <div class=\"log-meta\">\n                  <div class=\"log-type\">{{ i.type }}</div>\n                  <div class=\"log-date\">{{ i.date }}</div>\n                </div>\n                <div class=\"log-message\">{{ i.content }}</div>\n              </div>\n            </template>\n          </div>\n        </div>\n\n        <div class=\"log-actions\">\n          <ab-button size=\"small\" @click=\"getLog\">\n            {{ $t('log.update_now') }}\n          </ab-button>\n\n          <ab-button type=\"warn\" size=\"small\" @click=\"reset\">\n            {{ $t('log.reset') }}\n          </ab-button>\n\n          <ab-button size=\"small\" @click=\"copy\">\n            {{ $t('log.copy') }}\n          </ab-button>\n        </div>\n      </ab-container>\n\n      <div class=\"log-sidebar\">\n        <ab-container :title=\"$t('log.contact_info')\">\n          <div class=\"contact-list\">\n            <ab-label label=\"Github\">\n              <ab-button\n                size=\"small\"\n                link=\"https://github.com/EstrellaXD/Auto_Bangumi\"\n                target=\"_blank\"\n              >\n                {{ $t('log.go') }}\n              </ab-button>\n            </ab-label>\n\n            <ab-label label=\"Official Website\">\n              <ab-button\n                size=\"small\"\n                link=\"https://autobangumi.org\"\n                target=\"_blank\"\n              >\n                {{ $t('log.go') }}\n              </ab-button>\n            </ab-label>\n\n            <div class=\"divider\"></div>\n\n            <ab-label label=\"X\">\n              <ab-button\n                size=\"small\"\n                link=\"https://twitter.com/Estrella_Pan\"\n                target=\"_blank\"\n              >\n                {{ $t('log.go') }}\n              </ab-button>\n            </ab-label>\n\n            <ab-label label=\"Telegram Group\">\n              <ab-button\n                size=\"small\"\n                link=\"https://t.me/autobangumi\"\n                target=\"_blank\"\n              >\n                {{ $t('log.join') }}\n              </ab-button>\n            </ab-label>\n          </div>\n        </ab-container>\n\n        <ab-container :title=\"$t('log.bug_repo')\">\n          <div class=\"bug-section\">\n            <ab-button\n              class=\"issues-btn\"\n              link=\"https://github.com/EstrellaXD/Auto_Bangumi/issues\"\n            >\n              Github Issues\n            </ab-button>\n\n            <div class=\"divider\"></div>\n\n            <div class=\"version-info\">\n              <span>Version: </span>\n              <span>{{ version }}</span>\n            </div>\n          </div>\n        </ab-container>\n      </div>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-log {\n  overflow: auto;\n  flex-grow: 1;\n}\n\n.log-layout {\n  display: grid;\n  grid-template-columns: 1fr;\n  gap: 12px;\n  align-items: start;\n\n  @include forDesktop {\n    grid-template-columns: 3fr 2fr;\n  }\n}\n\n.log-main {\n  min-width: 0;\n}\n\n.log-viewer {\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  overflow: auto;\n  padding: 10px;\n  max-height: 60vh;\n  transition: border-color var(--transition-normal);\n}\n\n.log-content {\n  min-width: 0;\n}\n\n.log-entry {\n  padding: 10px 0;\n  line-height: 1.5;\n  border-bottom: 1px solid var(--color-border);\n  display: flex;\n  align-items: flex-start;\n  gap: 12px;\n\n  @include forDesktop {\n    align-items: center;\n    gap: 20px;\n  }\n\n  &:last-child {\n    border-bottom: none;\n  }\n}\n\n.log-meta {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  gap: 10px;\n  white-space: nowrap;\n}\n\n.log-type {\n  text-align: center;\n  font-weight: 500;\n  font-size: 12px;\n}\n\n.log-date {\n  font-size: 11px;\n  opacity: 0.8;\n}\n\n.log-message {\n  flex: 1;\n  word-break: break-all;\n  font-size: 13px;\n}\n\n.log-filters {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  margin-bottom: 12px;\n  padding-bottom: 12px;\n  border-bottom: 1px solid var(--color-border);\n}\n\n.filter-group {\n  display: flex;\n  flex-direction: column;\n  gap: 8px;\n\n  @include forDesktop {\n    flex-direction: row;\n    align-items: center;\n  }\n}\n\n.filter-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-muted);\n  min-width: 60px;\n}\n\n.filter-chips {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 6px;\n}\n\n.filter-chip {\n  padding: 4px 12px;\n  border-radius: var(--radius-sm);\n  border: 1px solid var(--color-border);\n  background: transparent;\n  font-size: 12px;\n  cursor: pointer;\n  transition: all var(--transition-fast);\n  color: var(--color-text);\n\n  &:hover {\n    border-color: var(--color-primary);\n  }\n\n  &.active {\n    background: var(--color-primary);\n    border-color: var(--color-primary);\n    color: white;\n  }\n\n  &.level-info {\n    &:hover,\n    &.active {\n      border-color: var(--color-primary);\n    }\n    &.active {\n      background: var(--color-primary);\n    }\n  }\n\n  &.level-warning {\n    &:hover,\n    &.active {\n      border-color: var(--color-warning);\n    }\n    &.active {\n      background: var(--color-warning);\n    }\n  }\n\n  &.level-error {\n    &:hover,\n    &.active {\n      border-color: var(--color-danger);\n    }\n    &.active {\n      background: var(--color-danger);\n    }\n  }\n\n  &.level-debug {\n    &:hover,\n    &.active {\n      border-color: var(--color-text-muted);\n    }\n    &.active {\n      background: var(--color-text-muted);\n    }\n  }\n}\n\n.clear-filters {\n  align-self: flex-start;\n  padding: 4px 12px;\n  border-radius: var(--radius-sm);\n  border: none;\n  background: var(--color-danger-light);\n  color: var(--color-danger);\n  font-size: 12px;\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover {\n    background: var(--color-danger);\n    color: white;\n  }\n}\n\n.log-actions {\n  display: flex;\n  justify-content: flex-end;\n  gap: 10px;\n  margin-top: 12px;\n}\n\n.log-sidebar {\n  min-width: 0;\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.contact-list {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n}\n\n.divider {\n  width: 100%;\n  height: 1px;\n  background: var(--color-border);\n}\n\n.bug-section {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  align-items: center;\n}\n\n.issues-btn {\n  width: 100%;\n  max-width: 300px;\n  height: 46px;\n  font-size: 16px;\n  border-radius: var(--radius-md);\n}\n\n.version-info {\n  text-align: center;\n  color: var(--color-primary);\n  font-size: 16px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/player.vue",
    "content": "<script lang=\"ts\" setup>\ndefinePage({\n  name: 'Player',\n});\n\nconst { url } = storeToRefs(usePlayerStore());\n</script>\n\n<template>\n  <div class=\"page-embed\">\n    <div v-if=\"url === ''\" class=\"empty-guide\">\n      <div class=\"empty-guide-header anim-fade-in\">\n        <div class=\"empty-guide-title\">{{ $t('player.empty.title') }}</div>\n        <div class=\"empty-guide-subtitle\">{{ $t('player.empty.subtitle') }}</div>\n      </div>\n\n      <div class=\"empty-guide-steps\">\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.15s\">\n          <div class=\"empty-guide-step-number\">1</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('player.empty.step1_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('player.empty.step1_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.3s\">\n          <div class=\"empty-guide-step-number\">2</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('player.empty.step2_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('player.empty.step2_desc') }}</div>\n          </div>\n        </div>\n\n        <div class=\"empty-guide-step anim-slide-up\" style=\"--delay: 0.45s\">\n          <div class=\"empty-guide-step-number\">3</div>\n          <div class=\"empty-guide-step-content\">\n            <div class=\"empty-guide-step-title\">{{ $t('player.empty.step3_title') }}</div>\n            <div class=\"empty-guide-step-desc\">{{ $t('player.empty.step3_desc') }}</div>\n          </div>\n        </div>\n      </div>\n\n      <RouterLink to=\"/config\" class=\"empty-guide-action anim-slide-up\" style=\"--delay: 0.6s\">\n        {{ $t('sidebar.config') }}\n      </RouterLink>\n    </div>\n\n    <iframe\n      v-else\n      :src=\"url\"\n      frameborder=\"0\"\n      allowfullscreen=\"true\"\n      class=\"embed-frame\"\n    ></iframe>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-embed {\n  overflow: auto;\n  flex-grow: 1;\n  display: flex;\n  flex-direction: column;\n}\n\n.embed-frame {\n  width: 100%;\n  height: 100%;\n  flex: 1;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n}\n\n.empty-guide {\n  display: flex;\n  flex-direction: column;\n  align-items: center;\n  justify-content: center;\n  flex: 1;\n  padding: 24px;\n}\n\n.empty-guide-header {\n  text-align: center;\n  margin-bottom: 32px;\n}\n\n.empty-guide-title {\n  font-size: 20px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 6px;\n}\n\n.empty-guide-subtitle {\n  font-size: 14px;\n  color: var(--color-text-secondary);\n}\n\n.empty-guide-steps {\n  display: flex;\n  flex-direction: column;\n  gap: 16px;\n  max-width: 400px;\n  width: 100%;\n}\n\n.empty-guide-step {\n  display: flex;\n  align-items: flex-start;\n  gap: 14px;\n  padding: 14px 16px;\n  border-radius: var(--radius-md);\n  border: 1px solid var(--color-border);\n  background: var(--color-surface);\n  transition: background-color var(--transition-normal),\n              border-color var(--transition-normal);\n}\n\n.empty-guide-step-number {\n  flex-shrink: 0;\n  width: 24px;\n  height: 24px;\n  border-radius: 50%;\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 13px;\n  font-weight: 600;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n}\n\n.empty-guide-step-content {\n  flex: 1;\n  min-width: 0;\n}\n\n.empty-guide-step-title {\n  font-size: 14px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin-bottom: 4px;\n}\n\n.empty-guide-step-desc {\n  font-size: 13px;\n  color: var(--color-text-secondary);\n  line-height: 1.4;\n}\n\n.empty-guide-action {\n  margin-top: 24px;\n  padding: 8px 24px;\n  border-radius: var(--radius-md);\n  background: var(--color-primary);\n  color: #fff;\n  font-size: 14px;\n  font-weight: 500;\n  text-decoration: none;\n  transition: background-color var(--transition-fast);\n\n  &:hover {\n    background: var(--color-primary-hover);\n  }\n}\n\n.anim-fade-in {\n  animation: fadeIn 0.5s ease both;\n}\n\n.anim-slide-up {\n  animation: slideUp 0.45s cubic-bezier(0.16, 1, 0.3, 1) both;\n  animation-delay: var(--delay, 0s);\n}\n\n@keyframes fadeIn {\n  from {\n    opacity: 0;\n    transform: translateY(-8px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n\n@keyframes slideUp {\n  from {\n    opacity: 0;\n    transform: translateY(16px);\n  }\n  to {\n    opacity: 1;\n    transform: translateY(0);\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index/rss.vue",
    "content": "<script lang=\"tsx\" setup>\nimport { type DataTableColumns, NDataTable, NTooltip } from 'naive-ui';\nimport type { RSS } from '#/rss';\n\ndefinePage({\n  name: 'RSS',\n});\n\nconst { t } = useMyI18n();\nconst { isMobile } = useBreakpointQuery();\nconst { rss, selectedRSS } = storeToRefs(useRSSStore());\nconst { getAll, deleteSelected, disableSelected, enableSelected } =\n  useRSSStore();\n\nonActivated(() => {\n  getAll();\n});\n\nconst rssColumns = computed<DataTableColumns<RSS>>(() => [\n  {\n    type: 'selection',\n  },\n  {\n    title: t('rss.name'),\n    key: 'name',\n    className: 'text-h3',\n    ellipsis: {\n      tooltip: true,\n    },\n  },\n  {\n    title: t('rss.url'),\n    key: 'url',\n    className: 'text-h3',\n    minWidth: 400,\n    align: 'center',\n    ellipsis: {\n      tooltip: true,\n    },\n  },\n  {\n    title: t('rss.status'),\n    key: 'status',\n    className: 'text-h3',\n    align: 'right',\n    minWidth: 200,\n    render(rss: RSS) {\n      return (\n        <div flex=\"~ justify-end gap-x-8\">\n          {rss.parser && <ab-tag type=\"primary\" title={rss.parser} />}\n          {rss.aggregate && <ab-tag type=\"primary\" title=\"aggregate\" />}\n          {rss.connection_status === 'healthy' && (\n            <ab-tag type=\"active\" title={t('rss.connected')} />\n          )}\n          {rss.connection_status === 'error' && (\n            <NTooltip>\n              {{\n                trigger: () => <ab-tag type=\"warn\" title={t('rss.error')} />,\n                default: () => rss.last_error || 'Unknown error',\n              }}\n            </NTooltip>\n          )}\n          {rss.enabled ? (\n            <ab-tag type=\"active\" title=\"active\" />\n          ) : (\n            <ab-tag type=\"inactive\" title=\"inactive\" />\n          )}\n        </div>\n      );\n    },\n  },\n]);\n\nconst rssRowKey = (row: RSS) => row.id;\n</script>\n\n<template>\n  <div class=\"page-rss\">\n    <ab-container :title=\"$t('rss.title')\">\n      <!-- Mobile: Card-based list -->\n      <ab-data-list\n        v-if=\"isMobile\"\n        :items=\"rss || []\"\n        :columns=\"[\n          { key: 'name', title: t('rss.name') },\n          { key: 'url', title: t('rss.url') },\n        ]\"\n        :selectable=\"true\"\n        key-field=\"id\"\n        @select=\"(keys) => (selectedRSS = keys as number[])\"\n      >\n        <template #item=\"{ item }\">\n          <div class=\"rss-card-content\">\n            <div class=\"rss-card-name\">{{ item.name }}</div>\n            <div class=\"rss-card-url\">{{ item.url }}</div>\n            <div class=\"rss-card-tags\">\n              <ab-tag v-if=\"item.parser\" type=\"primary\" :title=\"item.parser\" />\n              <ab-tag v-if=\"item.aggregate\" type=\"primary\" title=\"aggregate\" />\n              <ab-tag\n                v-if=\"item.connection_status === 'healthy'\"\n                type=\"active\"\n                :title=\"$t('rss.connected')\"\n              />\n              <NTooltip v-if=\"item.connection_status === 'error'\">\n                <template #trigger>\n                  <ab-tag type=\"warn\" :title=\"$t('rss.error')\" />\n                </template>\n                {{ item.last_error || 'Unknown error' }}\n              </NTooltip>\n              <ab-tag\n                :type=\"item.enabled ? 'active' : 'inactive'\"\n                :title=\"item.enabled ? 'active' : 'inactive'\"\n              />\n            </div>\n          </div>\n        </template>\n      </ab-data-list>\n\n      <!-- Desktop: Data table -->\n      <NDataTable\n        v-else\n        :columns=\"rssColumns\"\n        :data=\"rss\"\n        :row-key=\"rssRowKey\"\n        :pagination=\"false\"\n        :bordered=\"false\"\n        :max-height=\"500\"\n        @update:checked-row-keys=\"(e) => (selectedRSS = (e as number[]))\"\n      ></NDataTable>\n\n      <div v-if=\"selectedRSS.length > 0\">\n        <div class=\"divider\"></div>\n        <div class=\"rss-actions\">\n          <ab-button @click=\"enableSelected\">{{ $t('rss.enable') }}</ab-button>\n          <ab-button @click=\"disableSelected\">{{\n            $t('rss.disable')\n          }}</ab-button>\n          <ab-button type=\"warn\" @click=\"deleteSelected\">{{\n            $t('rss.delete')\n          }}</ab-button>\n        </div>\n      </div>\n    </ab-container>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-rss {\n  overflow: auto;\n  flex-grow: 1;\n}\n\n.divider {\n  width: 100%;\n  height: 1px;\n  background: var(--color-border);\n  margin: 12px 0;\n}\n\n.rss-actions {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: flex-end;\n  gap: 8px;\n\n  @include forTablet {\n    gap: 10px;\n  }\n}\n\n// Mobile RSS card styles\n.rss-card-content {\n  display: flex;\n  flex-direction: column;\n  gap: 4px;\n}\n\n.rss-card-name {\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--color-text);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rss-card-url {\n  font-size: 12px;\n  color: var(--color-text-muted);\n  overflow: hidden;\n  text-overflow: ellipsis;\n  white-space: nowrap;\n}\n\n.rss-card-tags {\n  display: flex;\n  gap: 4px;\n  flex-wrap: wrap;\n  margin-top: 4px;\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/index.vue",
    "content": "<script lang=\"ts\" setup>\ndefinePage({\n  name: 'Index',\n  redirect: '/bangumi',\n});\n\nconst { editRule } = storeToRefs(useBangumiStore());\nconst { updateRule, enableRule, archiveRule, unarchiveRule, ruleManage } = useBangumiStore();\n</script>\n\n<template>\n  <div class=\"layout-container\">\n    <a href=\"#main-content\" class=\"skip-link\">Skip to content</a>\n\n    <ab-topbar />\n\n    <main class=\"layout-main\">\n      <ab-sidebar />\n\n      <div id=\"main-content\" class=\"layout-content\">\n        <ab-page-title :title=\"$route.name\"></ab-page-title>\n\n        <RouterView v-slot=\"{ Component }\">\n          <transition name=\"page\" mode=\"out-in\">\n            <KeepAlive>\n              <component :is=\"Component\" />\n            </KeepAlive>\n          </transition>\n        </RouterView>\n      </div>\n    </main>\n\n    <ab-edit-rule\n      v-model:show=\"editRule.show\"\n      v-model:rule=\"editRule.item\"\n      @enable=\"(id) => enableRule(id)\"\n      @archive=\"(id) => archiveRule(id)\"\n      @unarchive=\"(id) => unarchiveRule(id)\"\n      @delete-file=\"(type, { id, deleteFile }) => ruleManage(type, id, deleteFile)\"\n      @apply=\"(rule) => updateRule(rule.id, rule)\"\n    />\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.layout-container {\n  width: 100%;\n  height: 100dvh;\n  overflow: hidden;\n\n  padding: var(--layout-padding);\n  padding-left: calc(var(--layout-padding) + env(safe-area-inset-left, 0px));\n  padding-right: calc(var(--layout-padding) + env(safe-area-inset-right, 0px));\n  gap: var(--layout-gap);\n\n  display: flex;\n  flex-direction: column;\n\n  background: var(--color-bg);\n  transition: background-color var(--transition-normal);\n\n  @include forDesktop {\n    min-width: 1024px;\n    min-height: 768px;\n  }\n}\n\n.layout-main {\n  display: flex;\n  flex-direction: column;\n  gap: var(--layout-gap);\n  overflow: hidden;\n  flex: 1;\n  min-height: 0;\n\n  @include forTablet {\n    flex-direction: row;\n  }\n\n  @include forDesktop {\n    flex-direction: row;\n  }\n}\n\n.layout-content {\n  overflow: hidden;\n  display: flex;\n  flex-direction: column;\n  flex: 1;\n  min-height: 0;\n  gap: var(--layout-gap);\n}\n\n.skip-link {\n  position: absolute;\n  top: -100%;\n  left: 16px;\n  z-index: 100;\n  padding: 8px 16px;\n  background: var(--color-primary);\n  color: #fff;\n  border-radius: var(--radius-sm);\n  font-size: 14px;\n  text-decoration: none;\n  transition: top var(--transition-fast);\n\n  &:focus {\n    top: 16px;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/login.vue",
    "content": "<script lang=\"ts\" setup>\nimport { Fingerprint, Lock, User } from '@icon-park/vue-next';\n\ndefinePage({\n  name: 'Login',\n});\n\nconst { user, login } = useAuth();\nconst { isSupported, loginWithPasskey } = usePasskey();\n\nconst isPasskeyLoading = ref(false);\nconst isLoginLoading = ref(false);\n\nasync function handlePasskeyLogin() {\n  isPasskeyLoading.value = true;\n  try {\n    // Pass username if provided, otherwise use discoverable credentials mode\n    await loginWithPasskey(user.username || undefined);\n  } finally {\n    isPasskeyLoading.value = false;\n  }\n}\n\nasync function handleLogin() {\n  isLoginLoading.value = true;\n  try {\n    await login();\n  } finally {\n    isLoginLoading.value = false;\n  }\n}\n</script>\n\n<template>\n  <div class=\"page-login\">\n    <!-- Animated background -->\n    <div class=\"login-bg\">\n      <div class=\"login-bg-gradient\"></div>\n      <div class=\"login-bg-pattern\"></div>\n    </div>\n\n    <!-- Login card -->\n    <div class=\"login-card\">\n      <!-- Logo / Brand -->\n      <div class=\"login-header\">\n        <div class=\"login-logo\">\n          <!-- Light mode: colored logo, Dark mode: light logo -->\n          <img src=\"/images/logo.svg\" alt=\"AutoBangumi\" class=\"logo-dark\" />\n          <img src=\"/images/logo-light.svg\" alt=\"AutoBangumi\" class=\"logo-light\" />\n        </div>\n        <h1 class=\"login-title\">AutoBangumi</h1>\n        <p class=\"login-subtitle\">{{ $t('login.title') }}</p>\n      </div>\n\n      <!-- Form -->\n      <form class=\"login-form\" @submit.prevent=\"handleLogin\">\n        <div class=\"input-group\">\n          <label for=\"login-username\" class=\"input-label\">\n            {{ $t('login.username') }}\n          </label>\n          <div class=\"input-wrapper\">\n            <User class=\"input-icon\" size=\"18\" />\n            <input\n              id=\"login-username\"\n              v-model=\"user.username\"\n              type=\"text\"\n              autocomplete=\"username\"\n              :placeholder=\"$t('login.username')\"\n              class=\"login-input\"\n            />\n          </div>\n        </div>\n\n        <div class=\"input-group\">\n          <label for=\"login-password\" class=\"input-label\">\n            {{ $t('login.password') }}\n          </label>\n          <div class=\"input-wrapper\">\n            <Lock class=\"input-icon\" size=\"18\" />\n            <input\n              id=\"login-password\"\n              v-model=\"user.password\"\n              type=\"password\"\n              autocomplete=\"current-password\"\n              :placeholder=\"$t('login.password')\"\n              class=\"login-input\"\n            />\n          </div>\n        </div>\n\n        <!-- Actions -->\n        <div class=\"login-actions\">\n          <ab-button\n            size=\"big\"\n            type=\"primary\"\n            :loading=\"isLoginLoading\"\n            :disabled=\"isLoginLoading\"\n            @click=\"handleLogin\"\n          >\n            {{ $t('login.login_btn') }}\n          </ab-button>\n\n          <button\n            v-if=\"isSupported\"\n            type=\"button\"\n            class=\"passkey-btn\"\n            :disabled=\"isPasskeyLoading\"\n            @click=\"handlePasskeyLogin\"\n          >\n            <Fingerprint size=\"20\" />\n            <span>{{ $t('login.passkey_btn') }}</span>\n          </button>\n        </div>\n      </form>\n    </div>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-login {\n  position: relative;\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  overflow: hidden;\n}\n\n// Animated background\n.login-bg {\n  position: absolute;\n  inset: 0;\n  z-index: 0;\n}\n\n.login-bg-gradient {\n  position: absolute;\n  inset: 0;\n  background: var(--color-bg);\n\n  &::before,\n  &::after {\n    content: '';\n    position: absolute;\n    border-radius: 50%;\n    filter: blur(100px);\n    opacity: 0.6;\n    animation: float 20s ease-in-out infinite;\n    will-change: transform;\n  }\n\n  &::before {\n    width: 600px;\n    height: 600px;\n    background: var(--color-primary);\n    top: -200px;\n    right: -100px;\n    opacity: 0.15;\n  }\n\n  &::after {\n    width: 500px;\n    height: 500px;\n    background: var(--color-accent);\n    bottom: -150px;\n    left: -100px;\n    opacity: 0.1;\n    animation-delay: -10s;\n  }\n}\n\n.login-bg-pattern {\n  position: absolute;\n  inset: 0;\n  background-image: radial-gradient(var(--color-border) 1px, transparent 1px);\n  background-size: 32px 32px;\n  opacity: 0.5;\n}\n\n@keyframes float {\n  0%, 100% {\n    transform: translate(0, 0) scale(1);\n  }\n  33% {\n    transform: translate(30px, -30px) scale(1.05);\n  }\n  66% {\n    transform: translate(-20px, 20px) scale(0.95);\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .login-bg-gradient::before,\n  .login-bg-gradient::after {\n    animation: none;\n  }\n}\n\n// Login card\n.login-card {\n  position: relative;\n  z-index: 1;\n  width: 100%;\n  max-width: 400px;\n  margin: 0 var(--layout-padding);\n  padding: 40px 32px;\n  background: color-mix(in srgb, var(--color-surface) 80%, transparent);\n  backdrop-filter: blur(20px);\n  -webkit-backdrop-filter: blur(20px);\n  border-radius: var(--radius-xl);\n  border: 1px solid var(--color-border);\n  box-shadow: var(--shadow-lg),\n              0 0 0 1px color-mix(in srgb, var(--color-white) 5%, transparent) inset;\n\n  @media (max-width: 480px) {\n    padding: 32px 24px;\n    margin: 0 16px;\n  }\n}\n\n// Header\n.login-header {\n  text-align: center;\n  margin-bottom: 32px;\n}\n\n.login-logo {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  width: 72px;\n  height: 72px;\n  margin-bottom: 16px;\n\n  img {\n    width: 100%;\n    height: 100%;\n    object-fit: contain;\n  }\n\n  // Light mode: show colored logo\n  .logo-dark {\n    display: block;\n  }\n  .logo-light {\n    display: none;\n  }\n}\n\n// Dark mode: show white logo\n:global(.dark) .login-logo {\n  .logo-dark {\n    display: none;\n  }\n  .logo-light {\n    display: block;\n  }\n}\n\n.login-title {\n  font-size: 24px;\n  font-weight: 600;\n  color: var(--color-text);\n  margin: 0 0 4px;\n  letter-spacing: -0.02em;\n}\n\n.login-subtitle {\n  font-size: 14px;\n  color: var(--color-text-muted);\n  margin: 0;\n}\n\n// Form\n.login-form {\n  display: flex;\n  flex-direction: column;\n  gap: 20px;\n}\n\n.input-group {\n  display: flex;\n  flex-direction: column;\n  gap: 6px;\n}\n\n.input-label {\n  font-size: 13px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  padding-left: 2px;\n}\n\n.input-wrapper {\n  position: relative;\n  display: flex;\n  align-items: center;\n}\n\n.input-icon {\n  position: absolute;\n  left: 14px;\n  color: var(--color-text-muted);\n  pointer-events: none;\n  transition: color var(--transition-fast);\n}\n\n.login-input {\n  width: 100%;\n  height: 44px;\n  padding: 0 14px 0 44px;\n  font-size: 14px;\n  color: var(--color-text);\n  background: var(--color-surface);\n  border: 1px solid var(--color-border);\n  border-radius: var(--radius-md);\n  outline: none;\n  transition: border-color var(--transition-fast),\n              box-shadow var(--transition-fast),\n              background-color var(--transition-fast);\n\n  &::placeholder {\n    color: var(--color-text-muted);\n  }\n\n  &:hover {\n    border-color: var(--color-border-hover);\n  }\n\n  &:focus {\n    border-color: var(--color-primary);\n    box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);\n    background: var(--color-surface);\n\n    ~ .input-icon,\n    + .input-icon {\n      color: var(--color-primary);\n    }\n  }\n\n  // When input has value, also highlight icon\n  &:not(:placeholder-shown) ~ .input-icon,\n  &:not(:placeholder-shown) + .input-icon {\n    color: var(--color-text-secondary);\n  }\n}\n\n// Fix icon color on focus (sibling selector)\n.input-wrapper:focus-within .input-icon {\n  color: var(--color-primary);\n}\n\n// Actions\n.login-actions {\n  display: flex;\n  flex-direction: column;\n  gap: 12px;\n  margin-top: 8px;\n\n  // Override ab-button max-width to be full width\n  :deep(.btn) {\n    max-width: 100%;\n  }\n}\n\n.passkey-btn {\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  gap: 8px;\n  width: 100%;\n  height: 44px;\n  padding: 0 16px;\n  font-size: 14px;\n  font-weight: 500;\n  color: var(--color-text-secondary);\n  background: transparent;\n  border: 1px dashed var(--color-border);\n  border-radius: var(--radius-md);\n  cursor: pointer;\n  transition: all var(--transition-fast);\n\n  &:hover:not(:disabled) {\n    color: var(--color-primary);\n    border-color: var(--color-primary);\n    border-style: solid;\n    background: color-mix(in srgb, var(--color-primary) 5%, transparent);\n  }\n\n  &:focus-visible {\n    outline: 2px solid var(--color-primary);\n    outline-offset: 2px;\n  }\n\n  &:disabled {\n    opacity: 0.6;\n    cursor: not-allowed;\n  }\n}\n</style>\n"
  },
  {
    "path": "webui/src/pages/setup.vue",
    "content": "<script lang=\"ts\" setup>\ndefinePage({\n  name: 'Setup',\n});\n\nconst setupStore = useSetupStore();\nconst { currentStep, currentStepIndex } = storeToRefs(setupStore);\nconst { steps } = setupStore;\n</script>\n\n<template>\n  <div class=\"page-setup\">\n    <wizard-container\n      :current-step=\"currentStepIndex\"\n      :total-steps=\"steps.length\"\n    >\n      <wizard-step-welcome v-if=\"currentStep === 'welcome'\" />\n      <wizard-step-account v-else-if=\"currentStep === 'account'\" />\n      <wizard-step-downloader v-else-if=\"currentStep === 'downloader'\" />\n      <wizard-step-rss v-else-if=\"currentStep === 'rss'\" />\n      <wizard-step-notification v-else-if=\"currentStep === 'notification'\" />\n      <wizard-step-review v-else-if=\"currentStep === 'review'\" />\n    </wizard-container>\n  </div>\n</template>\n\n<style lang=\"scss\" scoped>\n.page-setup {\n  width: 100vw;\n  height: 100vh;\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  background: var(--color-bg);\n}\n</style>\n"
  },
  {
    "path": "webui/src/router/index.ts",
    "content": "import { createRouter, createWebHashHistory } from 'vue-router/auto';\n\nconst router = createRouter({\n  history: createWebHashHistory(),\n});\n\nlet setupChecked = false;\nlet needSetup = false;\n\nrouter.beforeEach(async (to) => {\n  const { isLoggedIn } = useAuth();\n  const { type, url } = storeToRefs(usePlayerStore());\n\n  // Check setup status once per session\n  if (!setupChecked && to.path !== '/setup') {\n    try {\n      const status = await apiSetup.getStatus();\n      needSetup = status.need_setup;\n      setupChecked = true;\n    } catch {\n      // If check fails, retry on next navigation\n    }\n  }\n\n  // Redirect to setup if needed\n  if (needSetup && to.path !== '/setup') {\n    return { name: 'Setup' };\n  }\n\n  // Prevent going to setup after it's completed\n  if (to.path === '/setup' && setupChecked && !needSetup) {\n    return { name: 'Login' };\n  }\n\n  if (!isLoggedIn.value && to.path !== '/login' && to.path !== '/setup') {\n    return { name: 'Login' };\n  } else if (isLoggedIn.value && to.path === '/login') {\n    return { name: 'Index' };\n  }\n\n  if (type.value === 'jump' && url.value !== '' && to.path === '/player') {\n    open(url.value);\n    return false;\n  }\n});\n\nexport { router };\n"
  },
  {
    "path": "webui/src/services/webauthn.ts",
    "content": "import { apiPasskey } from '@/api/passkey';\n\n/**\n * WebAuthn 浏览器 API 封装\n * 处理 Base64URL 编码和浏览器兼容性\n */\n\n// ============ 工具函数 ============\n\nfunction base64UrlToBuffer(base64url: string): ArrayBuffer {\n  // 补齐 padding\n  const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');\n  const padding = '='.repeat((4 - (base64.length % 4)) % 4);\n  const binary = atob(base64 + padding);\n\n  const buffer = new ArrayBuffer(binary.length);\n  const bytes = new Uint8Array(buffer);\n  for (let i = 0; i < binary.length; i++) {\n    bytes[i] = binary.charCodeAt(i);\n  }\n  return buffer;\n}\n\nfunction bufferToBase64Url(buffer: ArrayBuffer): string {\n  const bytes = new Uint8Array(buffer);\n  let binary = '';\n  for (let i = 0; i < bytes.length; i++) {\n    binary += String.fromCharCode(bytes[i]);\n  }\n  return btoa(binary).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n}\n\n// ============ 注册流程 ============\n\n/**\n * 注册新的 Passkey\n * @param deviceName 设备名称（用户输入）\n */\nexport async function registerPasskey(deviceName: string): Promise<void> {\n  // 1. 获取注册选项\n  const options = await apiPasskey.getRegistrationOptions();\n\n  // 2. 转换选项为浏览器 API 格式\n  const createOptions: PublicKeyCredentialCreationOptions = {\n    challenge: base64UrlToBuffer(options.challenge),\n    rp: options.rp,\n    user: {\n      id: base64UrlToBuffer(options.user.id),\n      name: options.user.name,\n      displayName: options.user.displayName,\n    },\n    pubKeyCredParams: options.pubKeyCredParams.map((p) => ({\n      type: p.type as PublicKeyCredentialType,\n      alg: p.alg,\n    })),\n    timeout: options.timeout || 60000,\n    excludeCredentials: options.excludeCredentials?.map((cred) => ({\n      type: cred.type as PublicKeyCredentialType,\n      id: base64UrlToBuffer(cred.id),\n      transports: cred.transports as AuthenticatorTransport[],\n    })),\n    authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,\n  };\n\n  // 3. 调用浏览器 WebAuthn API\n  let credential: PublicKeyCredential;\n  try {\n    const result = await navigator.credentials.create({\n      publicKey: createOptions,\n    });\n    if (!result) {\n      throw new Error('No credential returned');\n    }\n    credential = result as PublicKeyCredential;\n  } catch (e: unknown) {\n    if (e instanceof DOMException) {\n      if (e.name === 'NotAllowedError') {\n        throw new Error('Authentication was cancelled or timed out');\n      }\n      if (e.name === 'SecurityError') {\n        throw new Error('WebAuthn requires a secure context (HTTPS or localhost)');\n      }\n      throw new Error(`Browser rejected the request: ${e.message}`);\n    }\n    throw e;\n  }\n\n  // 4. 序列化 credential 为 JSON\n  const response = credential.response as AuthenticatorAttestationResponse;\n  const attestationResponse = {\n    id: credential.id,\n    rawId: bufferToBase64Url(credential.rawId),\n    type: credential.type,\n    response: {\n      clientDataJSON: bufferToBase64Url(response.clientDataJSON),\n      attestationObject: bufferToBase64Url(response.attestationObject),\n    },\n  };\n\n  // 5. 提交到后端验证\n  await apiPasskey.verifyRegistration({\n    name: deviceName,\n    attestation_response: attestationResponse,\n  });\n}\n\n// ============ 认证流程 ============\n\n/**\n * 使用 Passkey 登录\n * @param username 用户名（可选，不提供时使用可发现凭证模式）\n */\nexport async function loginWithPasskey(username?: string): Promise<void> {\n  // 1. 获取认证选项\n  const options = await apiPasskey.getLoginOptions(\n    username ? { username } : {}\n  );\n\n  // 2. 转换选项\n  const getOptions: PublicKeyCredentialRequestOptions = {\n    challenge: base64UrlToBuffer(options.challenge),\n    timeout: options.timeout || 60000,\n    rpId: options.rpId,\n    // allowCredentials is undefined for discoverable credentials mode\n    allowCredentials: options.allowCredentials?.map((cred) => ({\n      type: cred.type as PublicKeyCredentialType,\n      id: base64UrlToBuffer(cred.id),\n      transports: cred.transports as AuthenticatorTransport[],\n    })),\n    userVerification: options.userVerification as UserVerificationRequirement,\n  };\n\n  // 3. 调用浏览器 API\n  const credential = (await navigator.credentials.get({\n    publicKey: getOptions,\n  })) as PublicKeyCredential;\n\n  if (!credential) {\n    throw new Error('Failed to get credential');\n  }\n\n  // 4. 序列化响应\n  const response = credential.response as AuthenticatorAssertionResponse;\n  const assertionResponse = {\n    id: credential.id,\n    rawId: bufferToBase64Url(credential.rawId),\n    type: credential.type,\n    response: {\n      clientDataJSON: bufferToBase64Url(response.clientDataJSON),\n      authenticatorData: bufferToBase64Url(response.authenticatorData),\n      signature: bufferToBase64Url(response.signature),\n      userHandle: response.userHandle\n        ? bufferToBase64Url(response.userHandle)\n        : null,\n    },\n  };\n\n  // 5. 提交到后端验证并登录\n  await apiPasskey.loginWithPasskey({\n    ...(username && { username }),\n    credential: assertionResponse,\n  });\n}\n\n// ============ 浏览器支持检测 ============\n\nexport function isWebAuthnSupported(): boolean {\n  return !!(\n    window.PublicKeyCredential &&\n    navigator.credentials &&\n    navigator.credentials.create\n  );\n}\n\nexport async function isPlatformAuthenticatorAvailable(): Promise<boolean> {\n  if (!isWebAuthnSupported()) return false;\n\n  try {\n    return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();\n  } catch {\n    return false;\n  }\n}\n"
  },
  {
    "path": "webui/src/store/__tests__/bangumi.test.ts",
    "content": "/**\n * Tests for Bangumi Store logic\n * Note: These tests focus on pure logic that can be tested without full Vue/Pinia setup\n */\n\nimport { describe, it, expect, vi } from 'vitest';\nimport { mockBangumiRule } from '@/test/mocks/api';\n\ndescribe('Bangumi Store Logic', () => {\n  describe('filter functions', () => {\n    const filterActive = (list: typeof mockBangumiRule[]) =>\n      list.filter((b) => !b.deleted && !b.archived);\n\n    const filterArchived = (list: typeof mockBangumiRule[]) =>\n      list.filter((b) => !b.deleted && b.archived);\n\n    it('filterActive should filter out deleted and archived items', () => {\n      const bangumi = [\n        { ...mockBangumiRule, id: 1, deleted: false, archived: false },\n        { ...mockBangumiRule, id: 2, deleted: true, archived: false },\n        { ...mockBangumiRule, id: 3, deleted: false, archived: true },\n        { ...mockBangumiRule, id: 4, deleted: false, archived: false },\n      ];\n\n      const result = filterActive(bangumi);\n\n      expect(result.length).toBe(2);\n      expect(result.map((b) => b.id)).toEqual([1, 4]);\n    });\n\n    it('filterArchived should return only archived, non-deleted items', () => {\n      const bangumi = [\n        { ...mockBangumiRule, id: 1, deleted: false, archived: false },\n        { ...mockBangumiRule, id: 2, deleted: true, archived: true },\n        { ...mockBangumiRule, id: 3, deleted: false, archived: true },\n        { ...mockBangumiRule, id: 4, deleted: false, archived: true },\n      ];\n\n      const result = filterArchived(bangumi);\n\n      expect(result.length).toBe(2);\n      expect(result.map((b) => b.id)).toEqual([3, 4]);\n    });\n\n    it('filterActive should return empty when all are deleted', () => {\n      const bangumi = [\n        { ...mockBangumiRule, id: 1, deleted: true, archived: false },\n        { ...mockBangumiRule, id: 2, deleted: true, archived: false },\n      ];\n\n      const result = filterActive(bangumi);\n\n      expect(result.length).toBe(0);\n    });\n  });\n\n  describe('sort functions', () => {\n    it('should sort by id descending', () => {\n      const bangumi = [\n        { ...mockBangumiRule, id: 1, deleted: false },\n        { ...mockBangumiRule, id: 3, deleted: false },\n        { ...mockBangumiRule, id: 2, deleted: false },\n      ];\n\n      const sorted = bangumi.sort((a, b) => b.id - a.id);\n\n      expect(sorted.map((b) => b.id)).toEqual([3, 2, 1]);\n    });\n\n    it('should separate enabled and disabled items', () => {\n      const bangumi = [\n        { ...mockBangumiRule, id: 1, deleted: false },\n        { ...mockBangumiRule, id: 2, deleted: true },\n        { ...mockBangumiRule, id: 3, deleted: false },\n        { ...mockBangumiRule, id: 4, deleted: true },\n      ];\n\n      const enabled = bangumi.filter((e) => !e.deleted).sort((a, b) => b.id - a.id);\n      const disabled = bangumi.filter((e) => e.deleted).sort((a, b) => b.id - a.id);\n      const sorted = [...enabled, ...disabled];\n\n      expect(sorted.map((b) => b.id)).toEqual([3, 1, 4, 2]);\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/store/__tests__/rss.test.ts",
    "content": "/**\n * Tests for RSS Store logic\n * Note: These tests focus on pure logic that can be tested without full Vue/Pinia setup\n */\n\nimport { describe, it, expect } from 'vitest';\nimport { mockRSSList } from '@/test/mocks/api';\n\ndescribe('RSS Store Logic', () => {\n  describe('sort and filter functions', () => {\n    it('should sort enabled feeds first then by id descending', () => {\n      const mixedList = [\n        { id: 1, name: 'Feed 1', url: 'url1', enabled: false },\n        { id: 2, name: 'Feed 2', url: 'url2', enabled: true },\n        { id: 3, name: 'Feed 3', url: 'url3', enabled: false },\n        { id: 4, name: 'Feed 4', url: 'url4', enabled: true },\n      ];\n\n      // Apply the same sorting logic as the store\n      const enabled = mixedList.filter((e) => e.enabled).sort((a, b) => b.id - a.id);\n      const disabled = mixedList.filter((e) => !e.enabled).sort((a, b) => b.id - a.id);\n      const sorted = [...enabled, ...disabled];\n\n      // Enabled should come first (sorted by id desc)\n      expect(sorted[0].id).toBe(4);\n      expect(sorted[1].id).toBe(2);\n      // Then disabled (sorted by id desc)\n      expect(sorted[2].id).toBe(3);\n      expect(sorted[3].id).toBe(1);\n    });\n\n    it('should handle all enabled feeds', () => {\n      const allEnabled = [\n        { id: 1, name: 'Feed 1', url: 'url1', enabled: true },\n        { id: 3, name: 'Feed 3', url: 'url3', enabled: true },\n        { id: 2, name: 'Feed 2', url: 'url2', enabled: true },\n      ];\n\n      const enabled = allEnabled.filter((e) => e.enabled).sort((a, b) => b.id - a.id);\n      const disabled = allEnabled.filter((e) => !e.enabled).sort((a, b) => b.id - a.id);\n      const sorted = [...enabled, ...disabled];\n\n      expect(sorted.map((s) => s.id)).toEqual([3, 2, 1]);\n    });\n\n    it('should handle all disabled feeds', () => {\n      const allDisabled = [\n        { id: 1, name: 'Feed 1', url: 'url1', enabled: false },\n        { id: 3, name: 'Feed 3', url: 'url3', enabled: false },\n        { id: 2, name: 'Feed 2', url: 'url2', enabled: false },\n      ];\n\n      const enabled = allDisabled.filter((e) => e.enabled).sort((a, b) => b.id - a.id);\n      const disabled = allDisabled.filter((e) => !e.enabled).sort((a, b) => b.id - a.id);\n      const sorted = [...enabled, ...disabled];\n\n      expect(sorted.map((s) => s.id)).toEqual([3, 2, 1]);\n    });\n\n    it('should handle empty list', () => {\n      const emptyList: typeof mockRSSList = [];\n\n      const enabled = emptyList.filter((e) => e.enabled).sort((a, b) => b.id - a.id);\n      const disabled = emptyList.filter((e) => !e.enabled).sort((a, b) => b.id - a.id);\n      const sorted = [...enabled, ...disabled];\n\n      expect(sorted).toEqual([]);\n    });\n  });\n\n  describe('selection management logic', () => {\n    it('should track selected items in array', () => {\n      const selectedRSS: number[] = [];\n\n      selectedRSS.push(1);\n      selectedRSS.push(2);\n      selectedRSS.push(3);\n\n      expect(selectedRSS).toEqual([1, 2, 3]);\n    });\n\n    it('should clear selection by reassigning empty array', () => {\n      let selectedRSS = [1, 2, 3];\n\n      selectedRSS = [];\n\n      expect(selectedRSS).toEqual([]);\n    });\n\n    it('should remove specific item from selection', () => {\n      const selectedRSS = [1, 2, 3];\n\n      const filtered = selectedRSS.filter((id) => id !== 2);\n\n      expect(filtered).toEqual([1, 3]);\n    });\n  });\n});\n"
  },
  {
    "path": "webui/src/store/bangumi.ts",
    "content": "import type { BangumiRule } from '#/bangumi';\nimport { ruleTemplate } from '#/bangumi';\n\nexport const useBangumiStore = defineStore('bangumi', () => {\n  const bangumi = ref<BangumiRule[]>([]);\n  const showArchived = ref(false);\n  const isLoading = ref(false);\n  const hasLoaded = ref(false);\n  const editRule = reactive<{\n    show: boolean;\n    item: BangumiRule;\n  }>({\n    show: false,\n    item: { ...ruleTemplate },\n  });\n\n  // Computed: active bangumi (not deleted, not archived)\n  const activeBangumi = computed(() =>\n    bangumi.value.filter((b) => !b.deleted && !b.archived)\n  );\n\n  // Computed: archived bangumi (not deleted, archived)\n  const archivedBangumi = computed(() =>\n    bangumi.value.filter((b) => !b.deleted && b.archived)\n  );\n\n  async function getAll() {\n    isLoading.value = true;\n    try {\n      const res = await apiBangumi.getAll();\n      const sort = (arr: BangumiRule[]) => arr.sort((a, b) => b.id - a.id);\n\n      const enabled = sort(res.filter((e) => !e.deleted));\n      const disabled = sort(res.filter((e) => e.deleted));\n\n      bangumi.value = [...enabled, ...disabled];\n      hasLoaded.value = true;\n    } finally {\n      isLoading.value = false;\n    }\n  }\n\n  function refreshData() {\n    editRule.show = false;\n    getAll();\n  }\n\n  const opts = {\n    showMessage: true,\n    onSuccess() {\n      refreshData();\n    },\n  };\n\n  const { execute: updateRule } = useApi(apiBangumi.updateRule, opts);\n  const { execute: enableRule } = useApi(apiBangumi.enableRule, opts);\n  const { execute: disableRule } = useApi(apiBangumi.disableRule, opts);\n  const { execute: deleteRule } = useApi(apiBangumi.deleteRule, opts);\n  const { execute: refreshPoster } = useApi(apiBangumi.refreshPoster, opts);\n  const { execute: archiveRule } = useApi(apiBangumi.archiveRule, opts);\n  const { execute: unarchiveRule } = useApi(apiBangumi.unarchiveRule, opts);\n  const { execute: refreshMetadata } = useApi(apiBangumi.refreshMetadata, opts);\n\n  function openEditPopup(data: BangumiRule) {\n    editRule.show = true;\n    editRule.item = data;\n  }\n\n  function ruleManage(\n    type: 'disable' | 'delete',\n    id: number,\n    deleteFile: boolean\n  ) {\n    switch (type) {\n      case 'disable':\n        disableRule(id, deleteFile);\n        break;\n\n      case 'delete':\n        deleteRule(id, deleteFile);\n        break;\n    }\n  }\n\n  async function setWeekday(bangumiId: number, weekday: number | null) {\n    await apiBangumi.setWeekday(bangumiId, weekday);\n    const item = bangumi.value.find((b) => b.id === bangumiId);\n    if (item) {\n      item.air_weekday = weekday;\n      item.weekday_locked = weekday !== null;\n    }\n  }\n\n  return {\n    bangumi,\n    showArchived,\n    isLoading,\n    hasLoaded,\n    activeBangumi,\n    archivedBangumi,\n    editRule,\n\n    getAll,\n    updateRule,\n    enableRule,\n    disableRule,\n    deleteRule,\n    refreshPoster,\n    archiveRule,\n    unarchiveRule,\n    refreshMetadata,\n    openEditPopup,\n    ruleManage,\n    setWeekday,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/config.ts",
    "content": "import { type Config, initConfig } from '#/config';\n\nexport const useConfigStore = defineStore('config', () => {\n  const config = ref<Config>(initConfig);\n\n  async function getConfig() {\n    const res = await apiConfig.getConfig();\n    config.value = res;\n  }\n\n  const { execute: set } = useApi(apiConfig.updateConfig, {\n    showMessage: true,\n    onSuccess() {\n      // 保存 config 后重启，以应用最新配置\n      const { restart } = useProgramStore();\n      restart();\n    },\n  });\n\n  const setConfig = () => set(config.value);\n\n  function getSettingGroup<Tkey extends keyof Config>(key: Tkey) {\n    return computed<Config[Tkey]>({\n      get() {\n        return config.value[key];\n      },\n      set(newVal) {\n        config.value[key] = newVal;\n      },\n    });\n  }\n\n  return {\n    config,\n    getConfig,\n    setConfig,\n    getSettingGroup,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/downloader.ts",
    "content": "import type { QbTorrentInfo, TorrentGroup } from '#/downloader';\n\nexport const useDownloaderStore = defineStore('downloader', () => {\n  const torrents = shallowRef<QbTorrentInfo[]>([]);\n  const selectedHashes = ref<string[]>([]);\n  const loading = ref(false);\n\n  const groups = computed<TorrentGroup[]>(() => {\n    const map = new Map<string, QbTorrentInfo[]>();\n    for (const t of torrents.value) {\n      const key = t.save_path;\n      if (!map.has(key)) {\n        map.set(key, []);\n      }\n      map.get(key)!.push(t);\n    }\n\n    const result: TorrentGroup[] = [];\n    // Regex to detect season-only folder names like \"Season 1\", \"S01\", \"第1季\", etc.\n    const seasonOnlyRegex = /^(Season\\s*\\d+|S\\d+|第\\d+季)$/i;\n\n    for (const [savePath, items] of map) {\n      const parts = savePath.replace(/\\/$/, '').split('/').filter(Boolean);\n      let name = parts[parts.length - 1] || savePath;\n      // If the last part is just a season folder, include the parent folder too\n      if (parts.length >= 2 && seasonOnlyRegex.test(name)) {\n        name = `${parts[parts.length - 2]} / ${name}`;\n      }\n      const totalSize = items.reduce((sum, t) => sum + t.size, 0);\n      const overallProgress =\n        totalSize > 0\n          ? items.reduce((sum, t) => sum + t.size * t.progress, 0) / totalSize\n          : 0;\n      result.push({\n        name,\n        savePath,\n        totalSize,\n        overallProgress,\n        count: items.length,\n        torrents: items.sort((a, b) => b.added_on - a.added_on),\n      });\n    }\n\n    return result.sort((a, b) => a.name.localeCompare(b.name));\n  });\n\n  async function getAll() {\n    loading.value = true;\n    try {\n      torrents.value = await apiDownloader.getTorrents();\n    } catch {\n      torrents.value = [];\n    } finally {\n      loading.value = false;\n    }\n  }\n\n  const opts = {\n    showMessage: true,\n    onSuccess() {\n      getAll();\n      selectedHashes.value = [];\n    },\n  };\n\n  const { execute: pauseSelected } = useApi(\n    () => apiDownloader.pause(selectedHashes.value),\n    opts\n  );\n  const { execute: resumeSelected } = useApi(\n    () => apiDownloader.resume(selectedHashes.value),\n    opts\n  );\n  const { execute: deleteSelected } = useApi(\n    (deleteFiles = false) =>\n      apiDownloader.deleteTorrents(selectedHashes.value, deleteFiles),\n    opts\n  );\n\n  function toggleHash(hash: string) {\n    const idx = selectedHashes.value.indexOf(hash);\n    if (idx === -1) {\n      selectedHashes.value.push(hash);\n    } else {\n      selectedHashes.value.splice(idx, 1);\n    }\n  }\n\n  function toggleGroup(group: TorrentGroup) {\n    const groupHashes = group.torrents.map((t) => t.hash);\n    const allSelected = groupHashes.every((h) =>\n      selectedHashes.value.includes(h)\n    );\n    if (allSelected) {\n      selectedHashes.value = selectedHashes.value.filter(\n        (h) => !groupHashes.includes(h)\n      );\n    } else {\n      const toAdd = groupHashes.filter(\n        (h) => !selectedHashes.value.includes(h)\n      );\n      selectedHashes.value.push(...toAdd);\n    }\n  }\n\n  function clearSelection() {\n    selectedHashes.value = [];\n  }\n\n  return {\n    torrents,\n    groups,\n    selectedHashes,\n    loading,\n\n    getAll,\n    pauseSelected,\n    resumeSelected,\n    deleteSelected,\n    toggleHash,\n    toggleGroup,\n    clearSelection,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/log.ts",
    "content": "import { useClipboard, useIntervalFn } from '@vueuse/core';\n\nexport const useLogStore = defineStore('log', () => {\n  const message = useMessage();\n  const { isLoggedIn } = useAuth();\n  const { t } = useMyI18n();\n\n  const log = ref('');\n\n  function getLog() {\n    if (isLoggedIn.value) {\n      apiLog.getLog().then((res) => {\n        log.value = res;\n      });\n    }\n  }\n\n  const { execute: reset } = useApi(apiLog.clearLog, {\n    showMessage: true,\n    onSuccess() {\n      log.value = '';\n    },\n  });\n\n  const { pause: offUpdate, resume: onUpdate } = useIntervalFn(getLog, 10000, {\n    immediate: false,\n    immediateCallback: true,\n  });\n\n  watch(isLoggedIn, (loggedIn) => {\n    if (!loggedIn) {\n      offUpdate();\n    }\n  });\n\n  const { copy: clipboardCopy, isSupported: clipboardSupported } = useClipboard({\n    legacy: true,\n  });\n\n  function copy() {\n    if (clipboardSupported.value) {\n      clipboardCopy(log.value);\n      message.success(t('notify.copy_success'));\n    } else {\n      message.error(t('notify.copy_failed'));\n    }\n  }\n\n  return {\n    log,\n    getLog,\n    reset,\n    onUpdate,\n    offUpdate,\n    copy,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/player.ts",
    "content": "import { useLocalStorage } from '@vueuse/core';\n\ntype MediaPlayerType = 'jump' | 'iframe';\n\nfunction normalizeUrl(url: string): string {\n  if (!url) return '';\n  const trimmed = url.trim();\n  if (!trimmed) return '';\n  // If URL already has a protocol, return as-is\n  if (/^https?:\\/\\//i.test(trimmed)) {\n    return trimmed;\n  }\n  // Otherwise, prepend http://\n  return `http://${trimmed}`;\n}\n\nexport const usePlayerStore = defineStore('player', () => {\n  const types = ref<MediaPlayerType[]>(['jump', 'iframe']);\n  const type = useLocalStorage<MediaPlayerType>('media-player-type', 'jump');\n  const rawUrl = useLocalStorage('media-player-url', '');\n\n  const url = computed(() => normalizeUrl(rawUrl.value));\n\n  function setUrl(value: string) {\n    rawUrl.value = value;\n  }\n\n  return {\n    types,\n    type,\n    url,\n    rawUrl,\n    setUrl,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/program.ts",
    "content": "export const useProgramStore = defineStore('program', () => {\n  const { execute: start } = useApi(apiProgram.start);\n  const { execute: pause } = useApi(apiProgram.stop);\n  const { execute: shutdown } = useApi(apiProgram.shutdown);\n  const { execute: restart } = useApi(apiProgram.restart);\n  const { execute: resetRule } = useApi(apiBangumi.resetAll);\n\n  return {\n    start,\n    pause,\n    shutdown,\n    restart,\n    resetRule,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/rss.ts",
    "content": "import type { RSS } from '#/rss';\n\nexport const useRSSStore = defineStore('rss', () => {\n  const rss = ref<RSS[]>([]);\n  const selectedRSS = ref<number[]>([]);\n\n  async function getAll() {\n    const res = await apiRSS.get();\n\n    function sort(arr: RSS[]) {\n      return arr.sort((a, b) => b.id - a.id);\n    }\n\n    const enabled = sort(res.filter((e) => e.enabled));\n    const disabled = sort(res.filter((e) => !e.enabled));\n\n    rss.value = [...enabled, ...disabled];\n  }\n\n  const opts = {\n    showMessage: true,\n    onSuccess() {\n      getAll();\n      selectedRSS.value = [];\n    },\n  };\n\n  const { execute: updateRSS } = useApi(apiRSS.update, opts);\n  const { execute: disableRSS } = useApi(apiRSS.disableMany, opts);\n  const { execute: deleteRSS } = useApi(apiRSS.deleteMany, opts);\n  const { execute: enableRSS } = useApi(apiRSS.enableMany, opts);\n\n  const disableSelected = () => disableRSS(selectedRSS.value);\n  const deleteSelected = () => deleteRSS(selectedRSS.value);\n  const enableSelected = () => enableRSS(selectedRSS.value);\n\n  return {\n    rss,\n    selectedRSS,\n\n    getAll,\n    updateRSS,\n    disableRSS,\n    deleteRSS,\n    enableRSS,\n    disableSelected,\n    deleteSelected,\n    enableSelected,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/search.ts",
    "content": "import type { BangumiRule, SearchResult } from '#/bangumi';\n\n// Filter types\nexport interface SearchFilters {\n  group: string[];\n  resolution: string[];\n  subtitleType: string[];\n  season: string[];\n}\n\nexport interface FilterOptions {\n  group: string[];\n  resolution: string[];\n  subtitleType: string[];\n  season: string[];\n}\n\n// Grouped bangumi result\nexport interface GroupedBangumi {\n  key: string;\n  official_title: string;\n  poster_link: string;\n  year?: string | null;\n  variants: BangumiRule[];\n}\n\n// Helper to parse metadata from title/bangumi\nfunction parseResolution(bangumi: BangumiRule): string {\n  // Check dpi field first\n  if (bangumi.dpi) {\n    return bangumi.dpi;\n  }\n  // Parse from title_raw\n  const title = bangumi.title_raw || '';\n  const resMatch = title.match(/\\b(4K|2160p|1080p|720p|480p)\\b/i);\n  return resMatch ? resMatch[1].toLowerCase() : '';\n}\n\nfunction parseSubtitleType(bangumi: BangumiRule): string {\n  // Check subtitle field first\n  if (bangumi.subtitle) {\n    const sub = bangumi.subtitle.toLowerCase();\n    if (sub.includes('简') || sub.includes('chs') || sub.includes('sc')) return '简中';\n    if (sub.includes('繁') || sub.includes('cht') || sub.includes('tc')) return '繁中';\n    if (sub.includes('双语') || sub.includes('dual')) return '双语';\n  }\n  // Parse from title_raw\n  const title = bangumi.title_raw || '';\n  if (/简体|简中|CHS|SC/i.test(title)) return '简中';\n  if (/繁體|繁中|CHT|TC/i.test(title)) return '繁中';\n  if (/双语|Dual/i.test(title)) return '双语';\n  if (/内嵌|内封/i.test(title)) return '内嵌';\n  if (/外挂|ASS|SRT/i.test(title)) return '外挂';\n  return '';\n}\n\nfunction parseSeason(bangumi: BangumiRule): string {\n  if (bangumi.season_raw) {\n    return bangumi.season_raw;\n  }\n  if (bangumi.season) {\n    return `S${bangumi.season}`;\n  }\n  // Parse from title\n  const title = bangumi.title_raw || '';\n  if (/剧场版|Movie|劇場版/i.test(title)) return '剧场版';\n  if (/OVA/i.test(title)) return 'OVA';\n  const seasonMatch = title.match(/S(\\d+)|Season\\s*(\\d+)|第(\\d+)季/i);\n  if (seasonMatch) {\n    const num = seasonMatch[1] || seasonMatch[2] || seasonMatch[3];\n    return `S${num}`;\n  }\n  return '';\n}\n\nexport const useSearchStore = defineStore('search', () => {\n  const providers = ref<string[]>(['mikan', 'dmhy', 'nyaa']);\n\n  const {\n    keyword,\n    provider,\n    open: openSearch,\n    close: closeSearch,\n    data: searchData,\n    status,\n  } = apiSearch.get();\n\n  provider.value = providers.value[0];\n\n  // Modal state\n  const showModal = ref(false);\n  const selectedResult = ref<BangumiRule | null>(null);\n  const selectedGroup = ref<GroupedBangumi | null>(null);\n\n  // Filter state for selected group variants\n  const variantFilters = ref<SearchFilters>({\n    group: [],\n    resolution: [],\n    subtitleType: [],\n    season: [],\n  });\n\n  const loading = computed(() => status.value !== 'CLOSED');\n\n  // Raw bangumi list with order\n  const bangumiList = computed<SearchResult[]>(() =>\n    searchData.value.map((item, index) => ({ order: index, value: item }))\n  );\n\n  // Group results by official_title\n  const groupedResults = computed<GroupedBangumi[]>(() => {\n    const map = new Map<string, BangumiRule[]>();\n\n    for (const item of searchData.value) {\n      const key = item.official_title || item.title_raw || '';\n      if (!map.has(key)) {\n        map.set(key, []);\n      }\n      map.get(key)!.push(item);\n    }\n\n    const groups: GroupedBangumi[] = [];\n    for (const [key, variants] of map) {\n      // Use the first variant's poster and info\n      const first = variants[0];\n      groups.push({\n        key,\n        official_title: first.official_title || first.title_raw || '',\n        poster_link: first.poster_link || '',\n        year: first.year,\n        variants,\n      });\n    }\n\n    return groups;\n  });\n\n  // Extract filter options from selected group's variants\n  const variantFilterOptions = computed<FilterOptions>(() => {\n    if (!selectedGroup.value) {\n      return { group: [], resolution: [], subtitleType: [], season: [] };\n    }\n\n    const groups = new Set<string>();\n    const resolutions = new Set<string>();\n    const subtitleTypes = new Set<string>();\n    const seasons = new Set<string>();\n\n    for (const item of selectedGroup.value.variants) {\n      if (item.group_name) groups.add(item.group_name);\n\n      const res = parseResolution(item);\n      if (res) resolutions.add(res);\n\n      const subType = parseSubtitleType(item);\n      if (subType) subtitleTypes.add(subType);\n\n      const season = parseSeason(item);\n      if (season) seasons.add(season);\n    }\n\n    return {\n      group: Array.from(groups).sort(),\n      resolution: Array.from(resolutions).sort((a, b) => {\n        const order = ['4k', '2160p', '1080p', '720p', '480p'];\n        return order.indexOf(a.toLowerCase()) - order.indexOf(b.toLowerCase());\n      }),\n      subtitleType: Array.from(subtitleTypes),\n      season: Array.from(seasons).sort(),\n    };\n  });\n\n  // Filtered variants based on selected filters\n  const filteredVariants = computed<BangumiRule[]>(() => {\n    if (!selectedGroup.value) return [];\n\n    const hasFilters = Object.values(variantFilters.value).some((arr) => arr.length > 0);\n    if (!hasFilters) {\n      return selectedGroup.value.variants;\n    }\n\n    return selectedGroup.value.variants.filter((bangumi) => {\n      // Group filter\n      if (variantFilters.value.group.length > 0) {\n        if (!bangumi.group_name || !variantFilters.value.group.includes(bangumi.group_name)) {\n          return false;\n        }\n      }\n\n      // Resolution filter\n      if (variantFilters.value.resolution.length > 0) {\n        const res = parseResolution(bangumi);\n        if (!res || !variantFilters.value.resolution.includes(res)) {\n          return false;\n        }\n      }\n\n      // Subtitle type filter\n      if (variantFilters.value.subtitleType.length > 0) {\n        const subType = parseSubtitleType(bangumi);\n        if (!subType || !variantFilters.value.subtitleType.includes(subType)) {\n          return false;\n        }\n      }\n\n      // Season filter\n      if (variantFilters.value.season.length > 0) {\n        const season = parseSeason(bangumi);\n        if (!season || !variantFilters.value.season.includes(season)) {\n          return false;\n        }\n      }\n\n      return true;\n    });\n  });\n\n  async function getProviders() {\n    providers.value = await apiSearch.getProvider();\n    provider.value = providers.value[0];\n  }\n\n  function openModal() {\n    showModal.value = true;\n  }\n\n  function closeModal() {\n    showModal.value = false;\n    selectedResult.value = null;\n    closeSearch();\n  }\n\n  function toggleModal() {\n    if (showModal.value) {\n      closeModal();\n    } else {\n      openModal();\n    }\n  }\n\n  function clearSearch() {\n    keyword.value = '';\n    searchData.value = [];\n    variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };\n    selectedGroup.value = null;\n    closeSearch();\n  }\n\n  function toggleVariantFilter(category: keyof SearchFilters, value: string) {\n    const arr = variantFilters.value[category];\n    const index = arr.indexOf(value);\n    if (index === -1) {\n      arr.push(value);\n    } else {\n      arr.splice(index, 1);\n    }\n  }\n\n  function clearVariantFilters() {\n    variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };\n  }\n\n  function selectGroup(group: GroupedBangumi) {\n    selectedGroup.value = group;\n    variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };\n  }\n\n  function clearSelectedGroup() {\n    selectedGroup.value = null;\n    variantFilters.value = { group: [], resolution: [], subtitleType: [], season: [] };\n  }\n\n  function selectResult(bangumi: BangumiRule) {\n    selectedResult.value = bangumi;\n  }\n\n  function clearSelectedResult() {\n    selectedResult.value = null;\n  }\n\n  function onSearch() {\n    if (!keyword.value.trim()) {\n      return;\n    }\n    openSearch();\n  }\n\n  return {\n    // State\n    inputValue: keyword,\n    loading,\n    provider,\n    providers,\n    bangumiList,\n    groupedResults,\n    selectedGroup,\n    variantFilters,\n    variantFilterOptions,\n    filteredVariants,\n    showModal,\n    selectedResult,\n\n    // Actions\n    clearSearch,\n    getProviders,\n    onSearch,\n    closeSearch,\n    openModal,\n    closeModal,\n    toggleModal,\n    toggleVariantFilter,\n    clearVariantFilters,\n    selectGroup,\n    clearSelectedGroup,\n    selectResult,\n    clearSelectedResult,\n  };\n});\n"
  },
  {
    "path": "webui/src/store/setup.ts",
    "content": "import type { SetupCompleteRequest, WizardStep } from '#/setup';\n\nexport const useSetupStore = defineStore('setup', () => {\n  const steps: WizardStep[] = [\n    'welcome',\n    'account',\n    'downloader',\n    'rss',\n    'notification',\n    'review',\n  ];\n\n  const currentStepIndex = ref(0);\n  const currentStep = computed(() => steps[currentStepIndex.value]);\n  const isLoading = ref(false);\n\n  // Form data\n  const accountData = reactive({\n    username: '',\n    password: '',\n    confirmPassword: '',\n  });\n\n  const downloaderData = reactive({\n    type: 'qbittorrent',\n    host: '',\n    username: '',\n    password: '',\n    path: '/downloads/Bangumi',\n    ssl: false,\n  });\n\n  const rssData = reactive({\n    url: '',\n    name: '',\n    skipped: false,\n  });\n\n  const notificationData = reactive({\n    enable: false,\n    type: 'telegram',\n    token: '',\n    chat_id: '',\n    skipped: false,\n  });\n\n  // Validation states\n  const validation = reactive({\n    downloaderTested: false,\n    rssTested: false,\n    notificationTested: false,\n  });\n\n  // Navigation\n  function nextStep() {\n    if (currentStepIndex.value < steps.length - 1) {\n      currentStepIndex.value++;\n    }\n  }\n\n  function prevStep() {\n    if (currentStepIndex.value > 0) {\n      currentStepIndex.value--;\n    }\n  }\n\n  function goToStep(index: number) {\n    if (index >= 0 && index < steps.length) {\n      currentStepIndex.value = index;\n    }\n  }\n\n  // Build final request\n  function buildCompleteRequest(): SetupCompleteRequest {\n    return {\n      username: accountData.username,\n      password: accountData.password,\n      downloader_type: downloaderData.type,\n      downloader_host: downloaderData.host,\n      downloader_username: downloaderData.username,\n      downloader_password: downloaderData.password,\n      downloader_path: downloaderData.path,\n      downloader_ssl: downloaderData.ssl,\n      rss_url: rssData.skipped ? '' : rssData.url,\n      rss_name: rssData.skipped ? '' : rssData.name,\n      notification_enable: !notificationData.skipped && notificationData.enable,\n      notification_type: notificationData.type,\n      notification_token: notificationData.token,\n      notification_chat_id: notificationData.chat_id,\n    };\n  }\n\n  function $reset() {\n    currentStepIndex.value = 0;\n    isLoading.value = false;\n    Object.assign(accountData, { username: '', password: '', confirmPassword: '' });\n    Object.assign(downloaderData, {\n      type: 'qbittorrent',\n      host: '',\n      username: '',\n      password: '',\n      path: '/downloads/Bangumi',\n      ssl: false,\n    });\n    Object.assign(rssData, { url: '', name: '', skipped: false });\n    Object.assign(notificationData, {\n      enable: false,\n      type: 'telegram',\n      token: '',\n      chat_id: '',\n      skipped: false,\n    });\n    Object.assign(validation, {\n      downloaderTested: false,\n      rssTested: false,\n      notificationTested: false,\n    });\n  }\n\n  return {\n    steps,\n    currentStepIndex,\n    currentStep,\n    isLoading,\n    accountData,\n    downloaderData,\n    rssData,\n    notificationData,\n    validation,\n    nextStep,\n    prevStep,\n    goToStep,\n    buildCompleteRequest,\n    $reset,\n  };\n});\n"
  },
  {
    "path": "webui/src/style/global.scss",
    "content": "// Base styles\n\nhtml {\n  color-scheme: light;\n\n  &.dark {\n    color-scheme: dark;\n  }\n}\n\nbody {\n  font-family: var(--font-family);\n  color: var(--color-text);\n  background-color: var(--color-bg);\n  transition: color var(--transition-normal), background-color var(--transition-normal);\n  -webkit-font-smoothing: antialiased;\n  -moz-osx-font-smoothing: grayscale;\n}\n\n// Scrollbar\n::-webkit-scrollbar {\n  width: var(--scrollbar-size);\n  height: var(--scrollbar-size);\n}\n\n::-webkit-scrollbar-track {\n  background: var(--scrollbar-color);\n}\n\n::-webkit-scrollbar-track-piece {\n  opacity: 0;\n}\n\n::-webkit-scrollbar-thumb {\n  border-radius: calc(var(--scrollbar-size) / 2);\n  background: var(--scrollbar-thumb-color);\n\n  &:hover {\n    background: var(--scrollbar-thumb-hover-color);\n  }\n}\n\n::-webkit-scrollbar-button {\n  display: none;\n}\n\n::-webkit-scrollbar-corner {\n  background-color: transparent;\n}\n\n// Remove number input spinners\ninput::-webkit-outer-spin-button,\ninput::-webkit-inner-spin-button {\n  -webkit-appearance: none;\n  margin: 0;\n}\n\n// Reduced motion\n@media (prefers-reduced-motion: reduce) {\n  *,\n  *::before,\n  *::after {\n    animation-duration: 0.01ms !important;\n    animation-iteration-count: 1 !important;\n    transition-duration: 0.01ms !important;\n    scroll-behavior: auto !important;\n  }\n}\n\n// Focus visible ring\n:focus-visible {\n  outline: 2px solid var(--color-primary);\n  outline-offset: 2px;\n  border-radius: var(--radius-sm);\n}\n\n// iOS Safari auto-zoom prevention\n// iOS Safari zooms in when focusing inputs with font-size < 16px\n// This rule ensures all form inputs are at least 16px on touch devices\n@media (hover: none) and (pointer: coarse) {\n  input:not([type=\"checkbox\"]):not([type=\"radio\"]),\n  textarea,\n  select {\n    font-size: 16px !important;\n  }\n}\n"
  },
  {
    "path": "webui/src/style/mixin.scss",
    "content": "// Breakpoints\n$bp-tablet: 640px;\n$bp-desktop: 1024px;\n\n// Legacy alias\n$min-pc: $bp-desktop;\n\n// Mobile-first breakpoint mixins\n@mixin forTablet {\n  @media screen and (min-width: $bp-tablet) {\n    @content;\n  }\n}\n\n@mixin forDesktop {\n  @media screen and (min-width: $bp-desktop) {\n    @content;\n  }\n}\n\n// Legacy aliases (backward compat)\n@mixin forMobile {\n  @media screen and (max-width: ($bp-desktop - 1)) {\n    @content;\n  }\n}\n\n@mixin forPC {\n  @media screen and (min-width: $bp-desktop) {\n    @content;\n  }\n}\n\n// Touch device detection\n@mixin forTouch {\n  @media (hover: none) and (pointer: coarse) {\n    @content;\n  }\n}\n\n// Safe area support for notched devices\n@mixin safeAreaBottom($property: padding-bottom, $extra: 0px) {\n  #{$property}: calc(#{$extra} + env(safe-area-inset-bottom, 0px));\n}\n\n@mixin safeAreaTop($property: padding-top, $extra: 0px) {\n  #{$property}: calc(#{$extra} + env(safe-area-inset-top, 0px));\n}\n\n@mixin bg-mouse-event($normal, $hover, $active) {\n  background: $normal;\n  transition: background-color var(--transition-normal);\n\n  &:hover {\n    background: $hover;\n  }\n\n  &:active {\n    transition: none;\n    background: $active;\n  }\n}\n"
  },
  {
    "path": "webui/src/style/transition.scss",
    "content": "// Transitions\n\n.fade {\n  &-enter-active,\n  &-leave-active {\n    transition: opacity var(--transition-normal);\n  }\n\n  &-enter-from,\n  &-leave-to {\n    position: absolute;\n    opacity: 0;\n  }\n}\n\n.fade-list-enter-active,\n.fade-list-leave-active {\n  transition: all var(--transition-slow);\n}\n\n.fade-list-enter-from,\n.fade-list-leave-to {\n  opacity: 0;\n}\n\n// Slide transitions for sidebar/panels\n.slide {\n  &-enter-active,\n  &-leave-active {\n    transition: transform var(--transition-normal), opacity var(--transition-normal);\n  }\n\n  &-enter-from {\n    transform: translateX(-8px);\n    opacity: 0;\n  }\n\n  &-leave-to {\n    transform: translateX(8px);\n    opacity: 0;\n  }\n}\n\n// Page route transition\n.page {\n  &-enter-active {\n    transition: opacity var(--transition-normal), transform var(--transition-normal);\n  }\n\n  &-leave-active {\n    transition: opacity 100ms ease-in;\n  }\n\n  &-enter-from {\n    opacity: 0;\n    transform: translateY(6px);\n  }\n\n  &-leave-to {\n    opacity: 0;\n  }\n}\n\n// Scale-fade for dropdowns/menus\n.dropdown {\n  &-enter-active {\n    transition: opacity var(--transition-fast), transform var(--transition-fast);\n  }\n\n  &-leave-active {\n    transition: opacity 100ms ease-in, transform 100ms ease-in;\n  }\n\n  &-enter-from {\n    opacity: 0;\n    transform: translateY(-4px) scale(0.97);\n  }\n\n  &-leave-to {\n    opacity: 0;\n    transform: translateY(-4px) scale(0.97);\n  }\n}\n\n// Bottom sheet slide-up\n.sheet {\n  &-enter-active,\n  &-leave-active {\n    transition: transform var(--transition-slow), opacity var(--transition-normal);\n  }\n\n  &-enter-from {\n    transform: translateY(100%);\n    opacity: 0;\n  }\n\n  &-leave-to {\n    transform: translateY(100%);\n    opacity: 0;\n  }\n}\n\n// Backdrop overlay fade\n.overlay {\n  &-enter-active,\n  &-leave-active {\n    transition: opacity var(--transition-normal);\n  }\n\n  &-enter-from,\n  &-leave-to {\n    opacity: 0;\n  }\n}\n\n// Horizontal swipe\n.swipe-left {\n  &-enter-active,\n  &-leave-active {\n    transition: transform var(--transition-normal), opacity var(--transition-normal);\n  }\n\n  &-enter-from {\n    transform: translateX(100%);\n    opacity: 0;\n  }\n\n  &-leave-to {\n    transform: translateX(-100%);\n    opacity: 0;\n  }\n}\n\n.swipe-right {\n  &-enter-active,\n  &-leave-active {\n    transition: transform var(--transition-normal), opacity var(--transition-normal);\n  }\n\n  &-enter-from {\n    transform: translateX(-100%);\n    opacity: 0;\n  }\n\n  &-leave-to {\n    transform: translateX(100%);\n    opacity: 0;\n  }\n}\n"
  },
  {
    "path": "webui/src/style/var.scss",
    "content": "// Design System Variables\n// Light theme (default) + Dark theme (.dark class on html)\n\n:root {\n  // --- Colors ---\n  --color-primary: #6C4AB6;\n  --color-primary-hover: #563A92;\n  --color-primary-light: #E8DEF8;\n  --color-accent: #F97316;\n  --color-success: #22C55E;\n  --color-danger: #EF4444;\n  --color-warning: #F59E0B;\n\n  --color-bg: #FAFAFA;\n  --color-surface: #FFFFFF;\n  --color-surface-hover: #F5F5F5;\n\n  --color-text: #1E293B;\n  --color-text-secondary: #64748B;\n  --color-text-muted: #94A3B8;\n\n  --color-border: #E2E8F0;\n  --color-border-hover: #CBD5E1;\n\n  // --- Shadows ---\n  --shadow-color: rgba(0, 0, 0, 0.08);\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);\n  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);\n\n  // --- Radius ---\n  --radius-sm: 6px;\n  --radius-md: 8px;\n  --radius-lg: 12px;\n  --radius-xl: 16px;\n  --radius-full: 9999px;\n\n  // --- Transitions ---\n  --transition-fast: 150ms ease-out;\n  --transition-normal: 200ms ease-out;\n  --transition-slow: 300ms ease-out;\n\n  // --- Scrollbar ---\n  --scrollbar-size: 6px;\n  --scrollbar-color: transparent;\n  --scrollbar-thumb-color: rgba(108, 74, 182, 0.3);\n  --scrollbar-thumb-hover-color: rgba(108, 74, 182, 0.6);\n\n  // --- Layout (mobile-first) ---\n  --layout-padding: 12px;\n  --layout-gap: 10px;\n  --topbar-height: 48px;\n  --nav-height: 56px;\n  --touch-target: 44px;\n\n  // --- Typography ---\n  --font-family: 'Inter', -apple-system, 'Noto Sans SC', 'Microsoft YaHei', system-ui, sans-serif;\n\n  // --- Z-Index Scale ---\n  --z-dropdown: 10;\n  --z-sticky: 20;\n  --z-fixed: 30;\n  --z-modal-backdrop: 40;\n  --z-modal: 50;\n  --z-popover: 60;\n  --z-tooltip: 70;\n\n  // --- Overlay Colors ---\n  --color-overlay: rgba(0, 0, 0, 0.5);\n  --color-overlay-light: rgba(0, 0, 0, 0.3);\n  --color-badge-bg: #ff3b30;\n  --color-badge-shadow: rgba(255, 59, 48, 0.4);\n  --color-white: #ffffff;\n\n  @include forTablet {\n    --layout-padding: 14px;\n    --layout-gap: 12px;\n  }\n\n  @include forDesktop {\n    --layout-padding: 16px;\n    --layout-gap: 12px;\n    --topbar-height: 56px;\n  }\n}\n\n// Dark theme\n.dark {\n  --color-primary: #8B6CC7;\n  --color-primary-hover: #A78BDB;\n  --color-primary-light: #2D2250;\n  --color-accent: #FB923C;\n  --color-success: #4ADE80;\n  --color-danger: #F87171;\n  --color-warning: #FBBF24;\n\n  --color-bg: #0F172A;\n  --color-surface: #1E293B;\n  --color-surface-hover: #334155;\n\n  --color-text: #F1F5F9;\n  --color-text-secondary: #94A3B8;\n  --color-text-muted: #64748B;\n\n  --color-border: #334155;\n  --color-border-hover: #475569;\n\n  // --- Shadows (darker for dark mode) ---\n  --shadow-color: rgba(0, 0, 0, 0.3);\n  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);\n  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -2px rgba(0, 0, 0, 0.2);\n  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -4px rgba(0, 0, 0, 0.3);\n\n  // --- Scrollbar ---\n  --scrollbar-thumb-color: rgba(139, 108, 199, 0.3);\n  --scrollbar-thumb-hover-color: rgba(139, 108, 199, 0.6);\n\n  // --- Overlay Colors (dark) ---\n  --color-overlay: rgba(0, 0, 0, 0.7);\n  --color-overlay-light: rgba(0, 0, 0, 0.5);\n  --color-badge-bg: #ff453a;\n  --color-badge-shadow: rgba(255, 69, 58, 0.5);\n  --color-white: #ffffff;\n}\n"
  },
  {
    "path": "webui/src/test/mocks/api.ts",
    "content": "/**\n * Mock API responses for testing\n */\n\nimport type { BangumiAPI, BangumiRule } from '#/bangumi';\nimport type { RSS } from '#/rss';\nimport type { ApiSuccess } from '#/api';\nimport type { LoginSuccess } from '#/auth';\n\n// ============================================================================\n// Auth Mocks\n// ============================================================================\n\nexport const mockLoginSuccess: LoginSuccess = {\n  access_token: 'mock_access_token_123',\n  token_type: 'bearer',\n  expire: Date.now() + 86400000, // 24 hours from now\n};\n\nexport const mockApiSuccess: ApiSuccess = {\n  msg_en: 'Success',\n  msg_zh: '成功',\n};\n\n// ============================================================================\n// Bangumi Mocks\n// ============================================================================\n\nexport const mockBangumiAPI: BangumiAPI = {\n  id: 1,\n  official_title: 'Test Anime',\n  year: '2024',\n  title_raw: '[TestGroup] Test Anime',\n  season: 1,\n  season_raw: '',\n  group_name: 'TestGroup',\n  dpi: '1080p',\n  source: 'Web',\n  subtitle: 'CHT',\n  eps_collect: false,\n  episode_offset: 0,\n  season_offset: 0,\n  filter: '720',\n  rss_link: 'https://mikanani.me/RSS/test',\n  poster_link: '/posters/test.jpg',\n  added: true,\n  rule_name: '[TestGroup] Test Anime S1',\n  save_path: '/downloads/Bangumi/Test Anime (2024)/Season 1',\n  deleted: false,\n  archived: false,\n  air_weekday: 3,\n  weekday_locked: false,\n  needs_review: false,\n  needs_review_reason: null,\n};\n\nexport const mockBangumiRule: BangumiRule = {\n  ...mockBangumiAPI,\n  filter: ['720'],\n  rss_link: ['https://mikanani.me/RSS/test'],\n};\n\nexport const mockBangumiList: BangumiAPI[] = [\n  mockBangumiAPI,\n  {\n    ...mockBangumiAPI,\n    id: 2,\n    official_title: 'Another Anime',\n    title_raw: '[TestGroup] Another Anime',\n    deleted: false,\n    archived: true,\n  },\n  {\n    ...mockBangumiAPI,\n    id: 3,\n    official_title: 'Disabled Anime',\n    title_raw: '[TestGroup] Disabled Anime',\n    deleted: true,\n    archived: false,\n  },\n];\n\n// ============================================================================\n// RSS Mocks\n// ============================================================================\n\nexport const mockRSSItem: RSS = {\n  id: 1,\n  name: 'Test RSS Feed',\n  url: 'https://mikanani.me/RSS/MyBangumi?token=test',\n  aggregate: true,\n  parser: 'mikan',\n  enabled: true,\n  connection_status: null,\n  last_checked_at: null,\n  last_error: null,\n};\n\nexport const mockRSSList: RSS[] = [\n  mockRSSItem,\n  {\n    ...mockRSSItem,\n    id: 2,\n    name: 'Another RSS Feed',\n    enabled: false,\n  },\n];\n\n// ============================================================================\n// Config Mocks\n// ============================================================================\n\nexport const mockConfig = {\n  program: {\n    rss_time: 900,\n    rename_time: 60,\n    webui_port: 7892,\n  },\n  downloader: {\n    type: 'qbittorrent',\n    host: '172.17.0.1:8080',\n    username: 'admin',\n    password: 'adminadmin',\n    path: '/downloads/Bangumi',\n    ssl: false,\n  },\n  rss_parser: {\n    enable: true,\n    filter: ['720', '\\\\d+-\\\\d'],\n    language: 'zh',\n  },\n  bangumi_manage: {\n    enable: true,\n    eps_complete: false,\n    rename_method: 'pn',\n    group_tag: false,\n    remove_bad_torrent: false,\n  },\n  log: {\n    debug_enable: false,\n  },\n  proxy: {\n    enable: false,\n    type: 'http',\n    host: '',\n    port: 0,\n    username: '',\n    password: '',\n  },\n  notification: {\n    enable: false,\n    type: 'telegram',\n    token: '',\n    chat_id: '',\n  },\n  experimental_openai: {\n    enable: false,\n    api_key: '',\n    api_base: 'https://api.openai.com/v1',\n    api_type: 'openai',\n    api_version: '2023-05-15',\n    model: 'gpt-3.5-turbo',\n    deployment_id: '',\n  },\n};\n\n// ============================================================================\n// Program Status Mocks\n// ============================================================================\n\nexport const mockProgramStatus = {\n  status: true,\n  version: '3.2.0',\n  first_run: false,\n};\n\n// ============================================================================\n// Torrent Mocks\n// ============================================================================\n\nexport const mockTorrents = [\n  {\n    hash: 'abc123',\n    name: '[TestGroup] Test Anime - 01.mkv',\n    state: 'downloading',\n    progress: 0.5,\n    size: 1073741824,\n    dlspeed: 1048576,\n  },\n  {\n    hash: 'def456',\n    name: '[TestGroup] Test Anime - 02.mkv',\n    state: 'completed',\n    progress: 1.0,\n    size: 1073741824,\n    dlspeed: 0,\n  },\n];\n\n// ============================================================================\n// Helper Functions\n// ============================================================================\n\n/**\n * Create a mock axios response\n */\nexport function createMockResponse<T>(data: T, status = 200) {\n  return {\n    data,\n    status,\n    statusText: 'OK',\n    headers: {},\n    config: {},\n  };\n}\n\n/**\n * Create a mock axios error\n */\nexport function createMockError(status: number, message: string) {\n  const error = new Error(message) as any;\n  error.response = {\n    status,\n    data: { msg_en: message, msg_zh: message },\n  };\n  error.isAxiosError = true;\n  return error;\n}\n"
  },
  {
    "path": "webui/src/test/setup.ts",
    "content": "/**\n * Vitest test setup file\n * This file runs before all tests\n */\n\nimport { vi } from 'vitest';\nimport { config } from '@vue/test-utils';\nimport { createPinia, setActivePinia } from 'pinia';\n\n// Mock axios globally\nvi.mock('axios', () => ({\n  default: {\n    get: vi.fn(),\n    post: vi.fn(),\n    patch: vi.fn(),\n    delete: vi.fn(),\n    put: vi.fn(),\n    create: vi.fn(() => ({\n      get: vi.fn(),\n      post: vi.fn(),\n      patch: vi.fn(),\n      delete: vi.fn(),\n      put: vi.fn(),\n      interceptors: {\n        request: { use: vi.fn() },\n        response: { use: vi.fn() },\n      },\n    })),\n    interceptors: {\n      request: { use: vi.fn() },\n      response: { use: vi.fn() },\n    },\n  },\n}));\n\n// Mock vue-router\nvi.mock('vue-router', () => ({\n  useRouter: () => ({\n    push: vi.fn(),\n    replace: vi.fn(),\n    go: vi.fn(),\n    back: vi.fn(),\n  }),\n  useRoute: () => ({\n    params: {},\n    query: {},\n    path: '/',\n    name: 'Index',\n  }),\n}));\n\n// Mock vue-i18n\nvi.mock('vue-i18n', () => ({\n  useI18n: () => ({\n    t: (key: string) => key,\n    locale: { value: 'zh-CN' },\n  }),\n  createI18n: vi.fn(),\n}));\n\n// Setup Pinia before each test\nbeforeEach(() => {\n  const pinia = createPinia();\n  setActivePinia(pinia);\n});\n\n// Configure Vue Test Utils globals\nconfig.global.mocks = {\n  $t: (key: string) => key,\n};\n\n// Mock localStorage\nconst localStorageMock = {\n  getItem: vi.fn(),\n  setItem: vi.fn(),\n  removeItem: vi.fn(),\n  clear: vi.fn(),\n};\nObject.defineProperty(window, 'localStorage', { value: localStorageMock });\n\n// Mock matchMedia for responsive tests\nObject.defineProperty(window, 'matchMedia', {\n  writable: true,\n  value: vi.fn().mockImplementation((query) => ({\n    matches: false,\n    media: query,\n    onchange: null,\n    addListener: vi.fn(),\n    removeListener: vi.fn(),\n    addEventListener: vi.fn(),\n    removeEventListener: vi.fn(),\n    dispatchEvent: vi.fn(),\n  })),\n});\n"
  },
  {
    "path": "webui/src/utils/axios.ts",
    "content": "import Axios from 'axios';\nimport type { AxiosError, AxiosResponse } from 'axios';\nimport type { ApiSuccess, StatusCode } from '#/api';\n\nexport const axios = Axios.create({\n  withCredentials: true,\n});\n\naxios.interceptors.response.use(\n  (res: AxiosResponse) => res,\n  (err: AxiosError<ApiSuccess>) => {\n    const status = err.response?.status as StatusCode;\n    const msg_en = err.response?.data?.msg_en ?? '';\n    const msg_zh = err.response?.data?.msg_zh ?? '';\n\n    const message = useMessage();\n    const { returnUserLangText } = useMyI18n();\n\n    const errorMsg = returnUserLangText({\n      en: msg_en,\n      'zh-CN': msg_zh,\n    });\n\n    const { isLoggedIn } = useAuth();\n\n    // Handle network errors (no response from server)\n    if (!err.response) {\n      message.error(\n        returnUserLangText({\n          en: 'Network error. Please check your connection.',\n          'zh-CN': '网络错误，请检查连接。',\n        })\n      );\n      const error = {\n        status: 0,\n        msg_en: 'Network error',\n        msg_zh: '网络错误',\n      };\n      return Promise.reject(error);\n    }\n\n    switch (status) {\n      /** token 过期 - only logout on auth errors */\n      case 401:\n        isLoggedIn.value = false;\n        if (errorMsg) message.error(errorMsg);\n        break;\n      /** 执行失败 */\n      case 406:\n        if (errorMsg) message.error(errorMsg);\n        break;\n      /** 服务器错误 - don't logout, just show error */\n      case 500:\n        message.error(\n          errorMsg ||\n            returnUserLangText({\n              en: 'Server error. Please try again later.',\n              'zh-CN': '服务器错误，请稍后重试。',\n            })\n        );\n        break;\n    }\n\n    const error = {\n      status,\n      msg_en,\n      msg_zh,\n    };\n\n    return Promise.reject(error);\n  }\n);\n"
  },
  {
    "path": "webui/src/utils/poster.ts",
    "content": "export function resolvePosterUrl(link: string | null | undefined): string {\n  if (!link) return '';\n  if (link.startsWith('http://') || link.startsWith('https://')) return link;\n  return `/${link}`;\n}\n"
  },
  {
    "path": "webui/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"useDefineForClassFields\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"strict\": true,\n    \"jsx\": \"preserve\",\n    \"jsxImportSource\": \"vue\",\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"esModuleInterop\": true,\n    \"lib\": [\"ESNext\", \"DOM\"],\n    \"skipLibCheck\": true,\n    \"noImplicitAny\": true,\n    \"baseUrl\": \"./\",\n    \"types\": [\"vite-plugin-pwa/client\"],\n    \"paths\": {\n      \"~/*\": [\"./*\"],\n      \"@/*\": [\"src/*\"],\n      \"#/*\": [\"types/*\"]\n    }\n  },\n  \"include\": [\n    \"src/**/*.ts\",\n    \"src/**/*.d.ts\",\n    \"src/**/*.tsx\",\n    \"src/**/*.vue\",\n    \"types/*.ts\",\n    \"types/dts/*.d.ts\"\n  ],\n  \"references\": [{ \"path\": \"./tsconfig.node.json\" }]\n}\n"
  },
  {
    "path": "webui/tsconfig.node.json",
    "content": "{\n  \"compilerOptions\": {\n    \"composite\": true,\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"Node\",\n    \"allowSyntheticDefaultImports\": true\n  },\n  \"include\": [\"vite.config.ts\"]\n}\n"
  },
  {
    "path": "webui/types/api.ts",
    "content": "export type AuthError = 'Not authenticated';\n\nexport type LoginError = 'Password error' | 'User not found';\n\nexport type ApiErrorMessage = AuthError | LoginError;\n\n/**\n * 401 Token 过期\n * 404 Not Found\n * 406 Not Acceptable\n * 500 Internal Server Error\n */\nexport type StatusCode = 401 | 404 | 406 | 500;\n\nexport interface ApiError {\n  status: StatusCode;\n  msg_en: string;\n  msg_zh: string;\n}\n\nexport interface ApiSuccess {\n  msg_en: string;\n  msg_zh: string;\n}\n"
  },
  {
    "path": "webui/types/auth.ts",
    "content": "export interface LoginSuccess {\n  access_token: string;\n  token_type: string;\n  expire: number;\n}\n\nexport interface Update extends LoginSuccess {\n  message: 'update success';\n}\n\nexport interface User {\n  username: string;\n  password: string;\n}\n"
  },
  {
    "path": "webui/types/bangumi.ts",
    "content": "/**\n * @type `Bangumi` in backend/src/module/models/bangumi.py\n */\nexport interface BangumiRule {\n  added: boolean;\n  deleted: boolean;\n  archived: boolean;\n  dpi: string;\n  eps_collect: boolean;\n  filter: string[];\n  group_name: string;\n  id: number;\n  official_title: string;\n  episode_offset: number;\n  season_offset: number;\n  poster_link: string | null;\n  rss_link: string[];\n  rule_name: string;\n  save_path: string;\n  season: number;\n  season_raw: string;\n  source: string | null;\n  subtitle: string;\n  title_raw: string;\n  year: string | null;\n  air_weekday: number | null; // 0=Mon, 1=Tue, ..., 6=Sun, null=Unknown\n  weekday_locked: boolean;\n  needs_review: boolean;\n  needs_review_reason: string | null;\n}\n\nexport interface BangumiAPI extends Omit<BangumiRule, 'filter' | 'rss_link'> {\n  filter: string;\n  rss_link: string;\n}\n\nexport interface SearchResult {\n  order: number;\n  value: BangumiRule;\n}\n\nexport type BangumiUpdate = Omit<BangumiAPI, 'id'>;\n\nexport const ruleTemplate: BangumiRule = {\n  added: false,\n  deleted: false,\n  archived: false,\n  dpi: '',\n  eps_collect: false,\n  filter: [],\n  group_name: '',\n  id: 0,\n  official_title: '',\n  episode_offset: 0,\n  season_offset: 0,\n  poster_link: '',\n  rss_link: [],\n  rule_name: '',\n  save_path: '',\n  season: 1,\n  season_raw: '',\n  source: null,\n  subtitle: '',\n  title_raw: '',\n  year: null,\n  air_weekday: null,\n  weekday_locked: false,\n  needs_review: false,\n  needs_review_reason: null,\n};\n\n/** Legacy offset suggestion (for backward compatibility) */\nexport interface OffsetSuggestion {\n  suggested_offset: number;\n  reason: string;\n}\n\n/** TMDB summary for display in offset dialog */\nexport interface TMDBSummary {\n  title: string;\n  total_seasons: number;\n  season_episode_counts: Record<number, number>;\n  status: string | null;\n}\n\n/** Detailed offset suggestion from detector */\nexport interface OffsetSuggestionDetail {\n  season_offset: number;\n  episode_offset: number;\n  reason: string;\n  confidence: 'high' | 'medium' | 'low';\n}\n\n/** Request for detect-offset API */\nexport interface DetectOffsetRequest {\n  title: string;\n  parsed_season: number;\n  parsed_episode: number;\n}\n\n/** Response from detect-offset API */\nexport interface DetectOffsetResponse {\n  has_mismatch: boolean;\n  suggestion: OffsetSuggestionDetail | null;\n  tmdb_info: TMDBSummary | null;\n}\n"
  },
  {
    "path": "webui/types/components.ts",
    "content": "export interface SelectItem {\n  id: number;\n  label?: string;\n  value: string;\n  disabled?: boolean;\n}\n\nexport interface AbSettingProps {\n  label: string | (() => string);\n  type: 'input' | 'switch' | 'select' | 'dynamic-tags';\n  css?: string;\n  prop?: any;\n  bottomLine?: boolean;\n}\n\nexport type SettingItem<T> = AbSettingProps & {\n  configKey: keyof T;\n};\n"
  },
  {
    "path": "webui/types/config.ts",
    "content": "import type { TupleToUnion } from './utils';\n\n/** 下载方式 */\nexport type DownloaderType = ['qbittorrent'];\n/** rss parser 语言 */\nexport type RssParserLang = ['zh', 'en', 'jp'];\n/** 重命名方式 */\nexport type RenameMethod = ['normal', 'pn', 'advance', 'none'];\n/** 代理类型 */\nexport type ProxyType = ['http', 'https', 'socks5'];\n/** 通知类型 */\nexport type NotificationType = [\n  'telegram',\n  'discord',\n  'bark',\n  'server-chan',\n  'wecom',\n  'gotify',\n  'pushover',\n  'webhook',\n];\n/** OpenAI Model List */\nexport type OpenAIModel = ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'];\n/** OpenAI API Type */\nexport type OpenAIType = ['openai', 'azure'];\n\nexport interface Program {\n  rss_time: number;\n  rename_time: number;\n  webui_port: number;\n}\n\nexport interface Downloader {\n  type: TupleToUnion<DownloaderType>;\n  host: string;\n  username: string;\n  password: string;\n  path: string;\n  ssl: boolean;\n}\nexport interface RssParser {\n  enable: boolean;\n  filter: Array<string>;\n  language: TupleToUnion<RssParserLang>;\n}\nexport interface BangumiManage {\n  enable: boolean;\n  eps_complete: boolean;\n  rename_method: TupleToUnion<RenameMethod>;\n  group_tag: boolean;\n  remove_bad_torrent: boolean;\n}\nexport interface Log {\n  debug_enable: boolean;\n}\nexport interface Proxy {\n  enable: boolean;\n  type: TupleToUnion<ProxyType>;\n  host: string;\n  port: number;\n  username: string;\n  password: string;\n}\n/** Notification provider configuration */\nexport interface NotificationProviderConfig {\n  type: TupleToUnion<NotificationType>;\n  enabled: boolean;\n  // Common fields\n  token?: string;\n  chat_id?: string;\n  // Provider-specific fields\n  webhook_url?: string;\n  server_url?: string;\n  device_key?: string;\n  user_key?: string;\n  api_token?: string;\n  template?: string;\n  url?: string;\n}\n\nexport interface Notification {\n  enable: boolean;\n  providers: NotificationProviderConfig[];\n  // Legacy fields (deprecated, for backward compatibility)\n  type?: 'telegram' | 'server-chan' | 'bark' | 'wecom';\n  token?: string;\n  chat_id?: string;\n}\nexport interface ExperimentalOpenAI {\n  enable: boolean;\n  api_key: string;\n  api_base: string;\n  model: TupleToUnion<OpenAIModel>;\n  // azure\n  api_type: TupleToUnion<OpenAIType>;\n  api_version?: string;\n  deployment_id?: string;\n}\n\n/** Access control for the login endpoint and MCP server.\n *  Whitelist entries are IPv4/IPv6 CIDR strings (e.g. \"192.168.0.0/16\").\n *  An empty login_whitelist allows all IPs; an empty mcp_whitelist denies all IP-based MCP access.\n */\nexport interface Security {\n  login_whitelist: string[];\n  login_tokens: string[];\n  mcp_whitelist: string[];\n  mcp_tokens: string[];\n}\n\nexport interface Config {\n  program: Program;\n  downloader: Downloader;\n  rss_parser: RssParser;\n  bangumi_manage: BangumiManage;\n  log: Log;\n  proxy: Proxy;\n  notification: Notification;\n  experimental_openai: ExperimentalOpenAI;\n  security: Security;\n}\n\nexport const initConfig: Config = {\n  program: {\n    rss_time: 0,\n    rename_time: 0,\n    webui_port: 0,\n  },\n  downloader: {\n    type: 'qbittorrent',\n    host: '',\n    username: '',\n    password: '',\n    path: '',\n    ssl: false,\n  },\n  rss_parser: {\n    enable: true,\n    filter: [],\n    language: 'zh',\n  },\n  bangumi_manage: {\n    enable: true,\n    eps_complete: true,\n    rename_method: 'normal',\n    group_tag: true,\n    remove_bad_torrent: true,\n  },\n  log: {\n    debug_enable: false,\n  },\n  proxy: {\n    enable: false,\n    type: 'http',\n    host: '',\n    port: 0,\n    username: '',\n    password: '',\n  },\n  notification: {\n    enable: false,\n    providers: [],\n  },\n  experimental_openai: {\n    enable: false,\n    api_key: '',\n    api_base: 'https://api.openai.com/v1/',\n    model: 'gpt-3.5-turbo',\n    // azure\n    api_type: 'openai',\n    api_version: '2020-05-03',\n    deployment_id: '',\n  },\n  security: {\n    login_whitelist: [],\n    login_tokens: [],\n    mcp_whitelist: [],\n    mcp_tokens: [],\n  },\n};\n"
  },
  {
    "path": "webui/types/downloader.ts",
    "content": "export type QbTorrentState =\n  | 'error'\n  | 'missingFiles'\n  | 'uploading'\n  | 'pausedUP'\n  | 'queuedUP'\n  | 'stalledUP'\n  | 'checkingUP'\n  | 'forcedUP'\n  | 'allocating'\n  | 'downloading'\n  | 'metaDL'\n  | 'pausedDL'\n  | 'queuedDL'\n  | 'stalledDL'\n  | 'checkingDL'\n  | 'forcedDL'\n  | 'checkingResumeData'\n  | 'moving'\n  | 'unknown';\n\nexport interface QbTorrentInfo {\n  hash: string;\n  name: string;\n  size: number;\n  progress: number;\n  dlspeed: number;\n  upspeed: number;\n  num_seeds: number;\n  num_leechs: number;\n  state: QbTorrentState;\n  eta: number;\n  category: string;\n  save_path: string;\n  added_on: number;\n}\n\nexport interface TorrentGroup {\n  name: string;\n  savePath: string;\n  totalSize: number;\n  overallProgress: number;\n  count: number;\n  torrents: QbTorrentInfo[];\n}\n"
  },
  {
    "path": "webui/types/dts/auto-imports.d.ts",
    "content": "// Generated by 'unplugin-auto-import'\nexport {}\ndeclare global {\n  const EffectScope: typeof import('vue')['EffectScope']\n  const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']\n  const afterAll: typeof import('vitest')['afterAll']\n  const afterEach: typeof import('vitest')['afterEach']\n  const apiAuth: typeof import('../../src/api/auth')['apiAuth']\n  const apiBangumi: typeof import('../../src/api/bangumi')['apiBangumi']\n  const apiCheck: typeof import('../../src/api/check')['apiCheck']\n  const apiConfig: typeof import('../../src/api/config')['apiConfig']\n  const apiDownload: typeof import('../../src/api/download')['apiDownload']\n  const apiDownloader: typeof import('../../src/api/downloader')['apiDownloader']\n  const apiLog: typeof import('../../src/api/log')['apiLog']\n  const apiNotification: typeof import('../../src/api/notification')['apiNotification']\n  const apiPasskey: typeof import('../../src/api/passkey')['apiPasskey']\n  const apiProgram: typeof import('../../src/api/program')['apiProgram']\n  const apiRSS: typeof import('../../src/api/rss')['apiRSS']\n  const apiSearch: typeof import('../../src/api/search')['apiSearch']\n  const apiSetup: typeof import('../../src/api/setup')['apiSetup']\n  const assert: typeof import('vitest')['assert']\n  const axios: typeof import('../../src/utils/axios')['axios']\n  const beforeAll: typeof import('vitest')['beforeAll']\n  const beforeEach: typeof import('vitest')['beforeEach']\n  const chai: typeof import('vitest')['chai']\n  const computed: typeof import('vue')['computed']\n  const createApp: typeof import('vue')['createApp']\n  const createPinia: typeof import('pinia')['createPinia']\n  const createSharedComposable: typeof import('@vueuse/core')['createSharedComposable']\n  const customRef: typeof import('vue')['customRef']\n  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']\n  const defineComponent: typeof import('vue')['defineComponent']\n  const defineLoader: typeof import('vue-router/auto')['defineLoader']\n  const definePage: typeof import('unplugin-vue-router/runtime')['_definePage']\n  const defineStore: typeof import('pinia')['defineStore']\n  const describe: typeof import('vitest')['describe']\n  const effectScope: typeof import('vue')['effectScope']\n  const expect: typeof import('vitest')['expect']\n  const getActivePinia: typeof import('pinia')['getActivePinia']\n  const getCurrentInstance: typeof import('vue')['getCurrentInstance']\n  const getCurrentScope: typeof import('vue')['getCurrentScope']\n  const h: typeof import('vue')['h']\n  const i18n: typeof import('../../src/hooks/useMyI18n')['i18n']\n  const inject: typeof import('vue')['inject']\n  const isProxy: typeof import('vue')['isProxy']\n  const isReactive: typeof import('vue')['isReactive']\n  const isReadonly: typeof import('vue')['isReadonly']\n  const isRef: typeof import('vue')['isRef']\n  const it: typeof import('vitest')['it']\n  const mapActions: typeof import('pinia')['mapActions']\n  const mapGetters: typeof import('pinia')['mapGetters']\n  const mapState: typeof import('pinia')['mapState']\n  const mapStores: typeof import('pinia')['mapStores']\n  const mapWritableState: typeof import('pinia')['mapWritableState']\n  const markRaw: typeof import('vue')['markRaw']\n  const nextTick: typeof import('vue')['nextTick']\n  const onActivated: typeof import('vue')['onActivated']\n  const onBeforeMount: typeof import('vue')['onBeforeMount']\n  const onBeforeRouteLeave: typeof import('vue-router/auto')['onBeforeRouteLeave']\n  const onBeforeRouteUpdate: typeof import('vue-router/auto')['onBeforeRouteUpdate']\n  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']\n  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']\n  const onDeactivated: typeof import('vue')['onDeactivated']\n  const onErrorCaptured: typeof import('vue')['onErrorCaptured']\n  const onMounted: typeof import('vue')['onMounted']\n  const onRenderTracked: typeof import('vue')['onRenderTracked']\n  const onRenderTriggered: typeof import('vue')['onRenderTriggered']\n  const onScopeDispose: typeof import('vue')['onScopeDispose']\n  const onServerPrefetch: typeof import('vue')['onServerPrefetch']\n  const onUnmounted: typeof import('vue')['onUnmounted']\n  const onUpdated: typeof import('vue')['onUpdated']\n  const provide: typeof import('vue')['provide']\n  const reactive: typeof import('vue')['reactive']\n  const readonly: typeof import('vue')['readonly']\n  const ref: typeof import('vue')['ref']\n  const resolveComponent: typeof import('vue')['resolveComponent']\n  const resolvePosterUrl: typeof import('../../src/utils/poster')['resolvePosterUrl']\n  const setActivePinia: typeof import('pinia')['setActivePinia']\n  const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']\n  const shallowReactive: typeof import('vue')['shallowReactive']\n  const shallowReadonly: typeof import('vue')['shallowReadonly']\n  const shallowRef: typeof import('vue')['shallowRef']\n  const storeToRefs: typeof import('pinia')['storeToRefs']\n  const suite: typeof import('vitest')['suite']\n  const test: typeof import('vitest')['test']\n  const toRaw: typeof import('vue')['toRaw']\n  const toRef: typeof import('vue')['toRef']\n  const toRefs: typeof import('vue')['toRefs']\n  const triggerRef: typeof import('vue')['triggerRef']\n  const unref: typeof import('vue')['unref']\n  const useAddRss: typeof import('../../src/hooks/useAddRss')['useAddRss']\n  const useApi: typeof import('../../src/hooks/useApi')['useApi']\n  const useAppInfo: typeof import('../../src/hooks/useAppInfo')['useAppInfo']\n  const useAttrs: typeof import('vue')['useAttrs']\n  const useAuth: typeof import('../../src/hooks/useAuth')['useAuth']\n  const useBangumiStore: typeof import('../../src/store/bangumi')['useBangumiStore']\n  const useBreakpointQuery: typeof import('../../src/hooks/useBreakpointQuery')['useBreakpointQuery']\n  const useBreakpoints: typeof import('@vueuse/core')['useBreakpoints']\n  const useClipboard: typeof import('@vueuse/core')['useClipboard']\n  const useConfigStore: typeof import('../../src/store/config')['useConfigStore']\n  const useCssModule: typeof import('vue')['useCssModule']\n  const useCssVars: typeof import('vue')['useCssVars']\n  const useDarkMode: typeof import('../../src/hooks/useDarkMode')['useDarkMode']\n  const useDownloaderStore: typeof import('../../src/store/downloader')['useDownloaderStore']\n  const useI18n: typeof import('vue-i18n')['useI18n']\n  const useIntervalFn: typeof import('@vueuse/core')['useIntervalFn']\n  const useLocalStorage: typeof import('@vueuse/core')['useLocalStorage']\n  const useLogStore: typeof import('../../src/store/log')['useLogStore']\n  const useMessage: typeof import('../../src/hooks/useMessage')['useMessage']\n  const useMyI18n: typeof import('../../src/hooks/useMyI18n')['useMyI18n']\n  const usePasskey: typeof import('../../src/hooks/usePasskey')['usePasskey']\n  const usePlayerStore: typeof import('../../src/store/player')['usePlayerStore']\n  const usePreferredDark: typeof import('@vueuse/core')['usePreferredDark']\n  const useProgramStore: typeof import('../../src/store/program')['useProgramStore']\n  const useRSSStore: typeof import('../../src/store/rss')['useRSSStore']\n  const useRoute: typeof import('vue-router/auto')['useRoute']\n  const useRouter: typeof import('vue-router/auto')['useRouter']\n  const useSafeArea: typeof import('../../src/hooks/useSafeArea')['useSafeArea']\n  const useSearchStore: typeof import('../../src/store/search')['useSearchStore']\n  const useSetupStore: typeof import('../../src/store/setup')['useSetupStore']\n  const useSlots: typeof import('vue')['useSlots']\n  const vi: typeof import('vitest')['vi']\n  const vitest: typeof import('vitest')['vitest']\n  const watch: typeof import('vue')['watch']\n  const watchEffect: typeof import('vue')['watchEffect']\n  const watchPostEffect: typeof import('vue')['watchPostEffect']\n  const watchSyncEffect: typeof import('vue')['watchSyncEffect']\n}\n"
  },
  {
    "path": "webui/types/dts/components.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-components\n// Read more: https://github.com/vuejs/core/pull/3399\nimport '@vue/runtime-core'\n\nexport {}\n\ndeclare module '@vue/runtime-core' {\n  export interface GlobalComponents {\n    AbAdaptiveModal: typeof import('./../../src/components/basic/ab-adaptive-modal.vue')['default']\n    AbAdd: typeof import('./../../src/components/basic/ab-add.vue')['default']\n    AbAddRss: typeof import('./../../src/components/ab-add-rss.vue')['default']\n    AbBangumiCard: typeof import('./../../src/components/ab-bangumi-card.vue')['default']\n    AbBottomSheet: typeof import('./../../src/components/basic/ab-bottom-sheet.vue')['default']\n    AbButton: typeof import('./../../src/components/basic/ab-button.vue')['default']\n    AbButtonMulti: typeof import('./../../src/components/basic/ab-button-multi.vue')['default']\n    AbChangeAccount: typeof import('./../../src/components/ab-change-account.vue')['default']\n    AbCheckbox: typeof import('./../../src/components/basic/ab-checkbox.vue')['default']\n    AbContainer: typeof import('./../../src/components/ab-container.vue')['default']\n    AbDataList: typeof import('./../../src/components/basic/ab-data-list.vue')['default']\n    AbEditRule: typeof import('./../../src/components/ab-edit-rule.vue')['default']\n    AbFoldPanel: typeof import('./../../src/components/ab-fold-panel.vue')['default']\n    AbImage: typeof import('./../../src/components/ab-image.vue')['default']\n    AbLabel: typeof import('./../../src/components/ab-label.vue')['default']\n    AbMobileNav: typeof import('./../../src/components/layout/ab-mobile-nav.vue')['default']\n    AbOffsetMismatchDialog: typeof import('./../../src/components/basic/ab-offset-mismatch-dialog.vue')['default']\n    AbPageTitle: typeof import('./../../src/components/basic/ab-page-title.vue')['default']\n    AbPopup: typeof import('./../../src/components/ab-popup.vue')['default']\n    AbPullRefresh: typeof import('./../../src/components/basic/ab-pull-refresh.vue')['default']\n    AbRule: typeof import('./../../src/components/ab-rule.vue')['default']\n    AbSearch: typeof import('./../../src/components/basic/ab-search.vue')['default']\n    AbSearchBar: typeof import('./../../src/components/ab-search-bar.vue')['default']\n    AbSearchCard: typeof import('./../../src/components/search/ab-search-card.vue')['default']\n    AbSearchConfirm: typeof import('./../../src/components/search/ab-search-confirm.vue')['default']\n    AbSearchFilters: typeof import('./../../src/components/search/ab-search-filters.vue')['default']\n    AbSearchModal: typeof import('./../../src/components/search/ab-search-modal.vue')['default']\n    AbSelect: typeof import('./../../src/components/basic/ab-select.vue')['default']\n    AbSetting: typeof import('./../../src/components/ab-setting.vue')['default']\n    AbSidebar: typeof import('./../../src/components/layout/ab-sidebar.vue')['default']\n    AbStatus: typeof import('./../../src/components/basic/ab-status.vue')['default']\n    AbStatusBar: typeof import('./../../src/components/ab-status-bar.vue')['default']\n    AbSwipeContainer: typeof import('./../../src/components/basic/ab-swipe-container.vue')['default']\n    AbSwitch: typeof import('./../../src/components/basic/ab-switch.vue')['default']\n    AbTag: typeof import('./../../src/components/basic/ab-tag.vue')['default']\n    AbTopbar: typeof import('./../../src/components/layout/ab-topbar.vue')['default']\n    ConfigDownload: typeof import('./../../src/components/setting/config-download.vue')['default']\n    ConfigManage: typeof import('./../../src/components/setting/config-manage.vue')['default']\n    ConfigNormal: typeof import('./../../src/components/setting/config-normal.vue')['default']\n    ConfigNotification: typeof import('./../../src/components/setting/config-notification.vue')['default']\n    ConfigOpenai: typeof import('./../../src/components/setting/config-openai.vue')['default']\n    ConfigParser: typeof import('./../../src/components/setting/config-parser.vue')['default']\n    ConfigPasskey: typeof import('./../../src/components/setting/config-passkey.vue')['default']\n    ConfigPlayer: typeof import('./../../src/components/setting/config-player.vue')['default']\n    ConfigProxy: typeof import('./../../src/components/setting/config-proxy.vue')['default']\n    ConfigSearchProvider: typeof import('./../../src/components/setting/config-search-provider.vue')['default']\n    ConfigSecurity: typeof import('./../../src/components/setting/config-security.vue')['default']\n    MediaQuery: typeof import('./../../src/components/media-query.vue')['default']\n    RouterLink: typeof import('vue-router')['RouterLink']\n    RouterView: typeof import('vue-router')['RouterView']\n    WizardContainer: typeof import('./../../src/components/setup/wizard-container.vue')['default']\n    WizardStepAccount: typeof import('./../../src/components/setup/wizard-step-account.vue')['default']\n    WizardStepDownloader: typeof import('./../../src/components/setup/wizard-step-downloader.vue')['default']\n    WizardStepNotification: typeof import('./../../src/components/setup/wizard-step-notification.vue')['default']\n    WizardStepReview: typeof import('./../../src/components/setup/wizard-step-review.vue')['default']\n    WizardStepRss: typeof import('./../../src/components/setup/wizard-step-rss.vue')['default']\n    WizardStepWelcome: typeof import('./../../src/components/setup/wizard-step-welcome.vue')['default']\n  }\n}\n"
  },
  {
    "path": "webui/types/dts/html.d.ts",
    "content": "/**\n * https://unocss.dev/presets/attributify#vue-3\n */\n\nimport type { AttributifyAttributes } from '@unocss/preset-attributify';\n\ndeclare module '@vue/runtime-dom' {\n  interface HTMLAttributes extends AttributifyAttributes {}\n}\n"
  },
  {
    "path": "webui/types/dts/router-type.d.ts",
    "content": "/* eslint-disable */\n/* prettier-ignore */\n// @ts-nocheck\n// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️\n// It's recommended to commit this file.\n// Make sure to add this file to your tsconfig.json file as an \"includes\" or \"files\" entry.\n\n/// <reference types=\"unplugin-vue-router/client\" />\n\nimport type {\n  // type safe route locations\n  RouteLocationTypedList,\n  RouteLocationResolvedTypedList,\n  RouteLocationNormalizedTypedList,\n  RouteLocationNormalizedLoadedTypedList,\n  RouteLocationAsString,\n  RouteLocationAsRelativeTypedList,\n  RouteLocationAsPathTypedList,\n\n  // helper types\n  // route definitions\n  RouteRecordInfo,\n  ParamValue,\n  ParamValueOneOrMore,\n  ParamValueZeroOrMore,\n  ParamValueZeroOrOne,\n\n  // vue-router extensions\n  _RouterTyped,\n  RouterLinkTyped,\n  RouterLinkPropsTyped,\n  NavigationGuard,\n  UseLinkFnTyped,\n\n  // data fetching\n  _DataLoader,\n  _DefineLoaderOptions,\n} from 'unplugin-vue-router/types'\n\ndeclare module 'vue-router/auto/routes' {\n  export interface RouteNamedMap {\n    'Index': RouteRecordInfo<'Index', '/', Record<never, never>, Record<never, never>>,\n    'Bangumi List': RouteRecordInfo<'Bangumi List', '/bangumi', Record<never, never>, Record<never, never>>,\n    'Calendar': RouteRecordInfo<'Calendar', '/calendar', Record<never, never>, Record<never, never>>,\n    'Config': RouteRecordInfo<'Config', '/config', Record<never, never>, Record<never, never>>,\n    'Downloader': RouteRecordInfo<'Downloader', '/downloader', Record<never, never>, Record<never, never>>,\n    'Log': RouteRecordInfo<'Log', '/log', Record<never, never>, Record<never, never>>,\n    'Player': RouteRecordInfo<'Player', '/player', Record<never, never>, Record<never, never>>,\n    'RSS': RouteRecordInfo<'RSS', '/rss', Record<never, never>, Record<never, never>>,\n    'Login': RouteRecordInfo<'Login', '/login', Record<never, never>, Record<never, never>>,\n    'Setup': RouteRecordInfo<'Setup', '/setup', Record<never, never>, Record<never, never>>,\n  }\n}\n\ndeclare module 'vue-router/auto' {\n  import type { RouteNamedMap } from 'vue-router/auto/routes'\n\n  export type RouterTyped = _RouterTyped<RouteNamedMap>\n\n  /**\n   * Type safe version of `RouteLocationNormalized` (the type of `to` and `from` in navigation guards).\n   * Allows passing the name of the route to be passed as a generic.\n   */\n  export type RouteLocationNormalized<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedTypedList<RouteNamedMap>[Name]\n\n  /**\n   * Type safe version of `RouteLocationNormalizedLoaded` (the return type of `useRoute()`).\n   * Allows passing the name of the route to be passed as a generic.\n   */\n  export type RouteLocationNormalizedLoaded<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]\n\n  /**\n   * Type safe version of `RouteLocationResolved` (the returned route of `router.resolve()`).\n   * Allows passing the name of the route to be passed as a generic.\n   */\n  export type RouteLocationResolved<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationResolvedTypedList<RouteNamedMap>[Name]\n\n  /**\n   * Type safe version of `RouteLocation` . Allows passing the name of the route to be passed as a generic.\n   */\n  export type RouteLocation<Name extends keyof RouteNamedMap = keyof RouteNamedMap> = RouteLocationTypedList<RouteNamedMap>[Name]\n\n  /**\n   * Type safe version of `RouteLocationRaw` . Allows passing the name of the route to be passed as a generic.\n   */\n  export type RouteLocationRaw<Name extends keyof RouteNamedMap = keyof RouteNamedMap> =\n    | RouteLocationAsString<RouteNamedMap>\n    | RouteLocationAsRelativeTypedList<RouteNamedMap>[Name]\n    | RouteLocationAsPathTypedList<RouteNamedMap>[Name]\n\n  /**\n   * Generate a type safe params for a route location. Requires the name of the route to be passed as a generic.\n   */\n  export type RouteParams<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['params']\n  /**\n   * Generate a type safe raw params for a route location. Requires the name of the route to be passed as a generic.\n   */\n  export type RouteParamsRaw<Name extends keyof RouteNamedMap> = RouteNamedMap[Name]['paramsRaw']\n\n  export function useRouter(): RouterTyped\n  export function useRoute<Name extends keyof RouteNamedMap = keyof RouteNamedMap>(name?: Name): RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[Name]\n\n  export const useLink: UseLinkFnTyped<RouteNamedMap>\n\n  export function onBeforeRouteLeave(guard: NavigationGuard<RouteNamedMap>): void\n  export function onBeforeRouteUpdate(guard: NavigationGuard<RouteNamedMap>): void\n\n  export const RouterLink: RouterLinkTyped<RouteNamedMap>\n  export const RouterLinkProps: RouterLinkPropsTyped<RouteNamedMap>\n\n  // Experimental Data Fetching\n\n  export function defineLoader<\n    P extends Promise<any>,\n    Name extends keyof RouteNamedMap = keyof RouteNamedMap,\n    isLazy extends boolean = false,\n  >(\n    name: Name,\n    loader: (route: RouteLocationNormalizedLoaded<Name>) => P,\n    options?: _DefineLoaderOptions<isLazy>,\n  ): _DataLoader<Awaited<P>, isLazy>\n  export function defineLoader<\n    P extends Promise<any>,\n    isLazy extends boolean = false,\n  >(\n    loader: (route: RouteLocationNormalizedLoaded) => P,\n    options?: _DefineLoaderOptions<isLazy>,\n  ): _DataLoader<Awaited<P>, isLazy>\n\n  export {\n    _definePage as definePage,\n    _HasDataLoaderMeta as HasDataLoaderMeta,\n    _setupDataFetchingGuard as setupDataFetchingGuard,\n    _stopDataFetchingScope as stopDataFetchingScope,\n  } from 'unplugin-vue-router/runtime'\n}\n\ndeclare module 'vue-router' {\n  import type { RouteNamedMap } from 'vue-router/auto/routes'\n\n  export interface TypesConfig {\n    beforeRouteUpdate: NavigationGuard<RouteNamedMap>\n    beforeRouteLeave: NavigationGuard<RouteNamedMap>\n\n    $route: RouteLocationNormalizedLoadedTypedList<RouteNamedMap>[keyof RouteNamedMap]\n    $router: _RouterTyped<RouteNamedMap>\n\n    RouterLink: RouterLinkTyped<RouteNamedMap>\n  }\n}\n"
  },
  {
    "path": "webui/types/dts/vite-env.d.ts",
    "content": "/// <reference types=\"vite/client\" />\n\ndeclare module '*.vue' {\n  import type { DefineComponent } from 'vue';\n  const component: DefineComponent<{}, {}, any>;\n  export default component;\n}\n"
  },
  {
    "path": "webui/types/passkey.ts",
    "content": "/**\n * Passkey 类型定义\n */\n\n// Passkey 列表项\nexport interface PasskeyItem {\n  id: number;\n  name: string;\n  created_at: string;\n  last_used_at: string | null;\n  backup_eligible: boolean;\n  aaguid: string | null;\n}\n\n// 注册选项（从后端返回）\nexport interface RegistrationOptions {\n  challenge: string;\n  rp: { name: string; id: string };\n  user: {\n    id: string;\n    name: string;\n    displayName: string;\n  };\n  pubKeyCredParams: Array<{ type: string; alg: number }>;\n  timeout?: number;\n  excludeCredentials?: Array<{\n    type: string;\n    id: string;\n    transports?: string[];\n  }>;\n  authenticatorSelection?: {\n    residentKey?: string;\n    userVerification?: string;\n  };\n}\n\n// 认证选项\nexport interface AuthenticationOptions {\n  challenge: string;\n  timeout?: number;\n  rpId?: string;\n  allowCredentials?: Array<{\n    type: string;\n    id: string;\n    transports?: string[];\n  }>;\n  userVerification?: string;\n}\n\n// 注册请求\nexport interface PasskeyCreateRequest {\n  name: string;\n  attestation_response: unknown;\n}\n\n// 删除请求\nexport interface PasskeyDeleteRequest {\n  passkey_id: number;\n}\n\n// 认证开始请求\nexport interface PasskeyAuthStartRequest {\n  username?: string; // Optional for discoverable credentials\n}\n\n// 认证完成请求\nexport interface PasskeyAuthFinishRequest {\n  username?: string; // Optional for discoverable credentials\n  credential: unknown;\n}\n"
  },
  {
    "path": "webui/types/rss.ts",
    "content": "export interface RSS {\n  id: number;\n  name: string;\n  url: string;\n  aggregate: boolean;\n  parser: string;\n  enabled: boolean;\n  connection_status: string | null;\n  last_checked_at: string | null;\n  last_error: string | null;\n}\n\nexport const rssTemplate: RSS = {\n  id: 0,\n  name: '',\n  url: '',\n  aggregate: false,\n  parser: 'tmdb',\n  enabled: false,\n  connection_status: null,\n  last_checked_at: null,\n  last_error: null,\n};\n"
  },
  {
    "path": "webui/types/setup.ts",
    "content": "export interface SetupStatus {\n  need_setup: boolean;\n  version: string;\n}\n\nexport interface TestDownloaderRequest {\n  type: string;\n  host: string;\n  username: string;\n  password: string;\n  ssl: boolean;\n}\n\nexport interface TestRSSRequest {\n  url: string;\n}\n\nexport interface TestNotificationRequest {\n  type: string;\n  token: string;\n  chat_id: string;\n}\n\nexport interface TestResult {\n  success: boolean;\n  message_en: string;\n  message_zh: string;\n  title?: string;\n  item_count?: number;\n}\n\nexport interface SetupCompleteRequest {\n  username: string;\n  password: string;\n  downloader_type: string;\n  downloader_host: string;\n  downloader_username: string;\n  downloader_password: string;\n  downloader_path: string;\n  downloader_ssl: boolean;\n  rss_url: string;\n  rss_name: string;\n  notification_enable: boolean;\n  notification_type: string;\n  notification_token: string;\n  notification_chat_id: string;\n}\n\nexport type WizardStep =\n  | 'welcome'\n  | 'account'\n  | 'downloader'\n  | 'rss'\n  | 'notification'\n  | 'review';\n"
  },
  {
    "path": "webui/types/torrent.ts",
    "content": "export interface Torrent {\n  id: number;\n  name: string;\n  url: string;\n  homepage: string;\n  downloaded: boolean;\n}\n"
  },
  {
    "path": "webui/types/utils.ts",
    "content": "export type TupleToUnion<T extends any[]> = T[number];\n"
  },
  {
    "path": "webui/unocss.config.ts",
    "content": "import {\n  defineConfig,\n  presetAttributify,\n  presetIcons,\n  presetUno,\n} from 'unocss';\nimport presetRemToPx from '@unocss/preset-rem-to-px';\n\nexport default defineConfig({\n  presets: [\n    presetUno({\n      dark: 'class',\n    }),\n    presetRemToPx({\n      baseFontSize: 4,\n    }),\n    presetAttributify(),\n    presetIcons({ cdn: 'https://esm.sh/' }),\n  ],\n  preflights: [\n    {\n      getCSS: () => `\n        :root {\n          font-size: 4px;\n        }\n        body {\n          font-size: 4rem;\n        }\n      `,\n    },\n  ],\n  theme: {\n    breakpoints: {\n      sm: '640px',\n      pc: '1024px',\n    },\n    colors: {\n      // Semantic colors via CSS variables (support light/dark)\n      primary: 'var(--color-primary)',\n      'primary-hover': 'var(--color-primary-hover)',\n      'primary-light': 'var(--color-primary-light)',\n      accent: 'var(--color-accent)',\n      success: 'var(--color-success)',\n      danger: 'var(--color-danger)',\n      warning: 'var(--color-warning)',\n      surface: 'var(--color-surface)',\n      'surface-hover': 'var(--color-surface-hover)',\n      'text-primary': 'var(--color-text)',\n      'text-secondary': 'var(--color-text-secondary)',\n      'text-muted': 'var(--color-text-muted)',\n      border: 'var(--color-border)',\n      'border-hover': 'var(--color-border-hover)',\n      page: 'var(--color-bg)',\n\n      // Legacy aliases (for gradual migration)\n      running: 'var(--color-success)',\n      stopped: 'var(--color-danger)',\n    },\n  },\n  rules: [\n    [\n      'bg-theme-row',\n      {\n        background: 'linear-gradient(90.5deg, var(--color-primary) 1.53%, var(--color-primary-hover) 96.48%)',\n      },\n    ],\n    [\n      'bg-theme-col',\n      {\n        background: 'linear-gradient(180deg, var(--color-primary) 0%, var(--color-primary-hover) 100%)',\n      },\n    ],\n    [\n      'poster-shandow',\n      {\n        filter: 'drop-shadow(2px 2px 2px var(--shadow-color, rgba(0, 0, 0, 0.1)))',\n      },\n    ],\n    [\n      'poster-pen-active',\n      {\n        background: 'var(--color-primary-light)',\n        'box-shadow': '2px 2px 4px var(--shadow-color, rgba(0, 0, 0, 0.25))',\n      },\n    ],\n    // Shadows\n    ['shadow-sm', { 'box-shadow': 'var(--shadow-sm)' }],\n    ['shadow-md', { 'box-shadow': 'var(--shadow-md)' }],\n    ['shadow-lg', { 'box-shadow': 'var(--shadow-lg)' }],\n  ],\n  shortcuts: [\n    [/^wh-(.*)$/, ([, t]) => `w-${t} h-${t}`],\n    [/^text-limit-(\\d{0,})$/, ([, n]) => `line-clamp-${n}`],\n\n    // position\n    {\n      rel: 'relative',\n      abs: 'absolute',\n    },\n\n    // flex\n    {\n      'fx-cer': 'flex items-center',\n      'f-cer': 'flex items-center justify-center',\n    },\n\n    // font size\n    {\n      'text-h1': 'text-24',\n      'text-h2': 'text-20',\n      'text-h3': 'text-16',\n      'text-main': 'text-12',\n      'text-body': 'text-14',\n      'text-sm': 'text-12',\n      'text-xs': 'text-10',\n    },\n\n    // input\n    {\n      'ab-input': `outline-none min-w-0 w-full sm:w-200 h-36 sm:h-28\n                     px-12 text-main text-right\n                     rounded-6\n                     border-1 border-border\n                     bg-surface text-text-primary\n                     hover:border-primary\n                     focus:border-primary focus:ring-2 focus:ring-primary/20\n                     transition-colors duration-150\n                    `,\n\n      'input-error': 'border-danger',\n      'input-reset': 'bg-transparent min-w-0 flex-1 outline-none',\n    },\n\n    // status\n    {\n      'is-btn': 'cursor-pointer select-none',\n      'btn-click': 'hover:scale-110 active:scale-100',\n      'is-disabled': 'cursor-not-allowed select-none opacity-50',\n    },\n\n    // other\n    {\n      line: 'w-full h-1 bg-border',\n    },\n  ],\n});\n"
  },
  {
    "path": "webui/vite.config.ts",
    "content": "import { resolve } from 'node:path';\nimport UnoCSS from 'unocss/vite';\nimport { defineConfig } from 'vite';\nimport vue from '@vitejs/plugin-vue';\nimport AutoImport from 'unplugin-auto-import/vite';\nimport Components from 'unplugin-vue-components/vite';\nimport VueRouter from 'unplugin-vue-router/vite';\nimport { VueRouterAutoImports } from 'unplugin-vue-router';\nimport VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite';\nimport { VitePWA } from 'vite-plugin-pwa';\nimport VueJsx from '@vitejs/plugin-vue-jsx';\n\n// https://vitejs.dev/config/\nexport default defineConfig(({ mode }) => ({\n  base: './',\n  plugins: [\n    VueJsx(),\n    VueRouter({\n      dts: 'types/dts/router-type.d.ts',\n    }),\n    vue({\n      script: {\n        defineModel: true,\n      },\n    }),\n    UnoCSS(),\n    AutoImport({\n      imports: [\n        'vue',\n        'vitest',\n        'pinia',\n        {\n          '@vueuse/core': [\n            'createSharedComposable',\n            'useBreakpoints',\n            'usePreferredDark',\n            'useClipboard',\n            'useLocalStorage',\n            'useIntervalFn',\n          ],\n        },\n        VueRouterAutoImports,\n        'vue-i18n',\n      ],\n      dts: 'types/dts/auto-imports.d.ts',\n      dirs: ['src/api', 'src/store', 'src/hooks', 'src/utils'],\n    }),\n    Components({\n      dts: 'types/dts/components.d.ts',\n      dirs: [\n        'src/components',\n        'src/components/basic',\n        'src/components/layout',\n        'src/components/setting',\n        'src/components/setup',\n      ],\n    }),\n    VueI18nPlugin({\n      include: resolve(__dirname, './src/i18n/**'),\n    }),\n    VitePWA({\n      injectRegister: false,\n      registerType: 'autoUpdate',\n      devOptions: {\n        enabled: true,\n      },\n      workbox: {\n        globPatterns: ['**/*.{js,css,html,ico,png,svg}'],\n      },\n      manifest: {\n        name: 'AutoBangumi',\n        display: 'standalone',\n        short_name: 'AutoBangumi',\n        description: 'Automated Bangumi Download Tool',\n        theme_color: '#ffffff',\n        icons: [\n          {\n            src: '/images/logo.svg',\n            sizes: 'any',\n            type: 'image/svg+xml',\n            purpose: 'any',\n          },\n          {\n            src: '/images/pwa-192.png',\n            sizes: '192x192',\n            type: 'image/png',\n          },\n          {\n            src: '/images/pwa-512.png',\n            sizes: '512x512',\n            type: 'image/png',\n            purpose: 'any',\n          },\n        ],\n      },\n    }),\n  ],\n  css: {\n    preprocessorOptions: {\n      scss: {\n        additionalData: '@import \"./src/style/mixin.scss\";',\n      },\n    },\n  },\n  esbuild: {\n    drop: mode === 'production' ? ['console', 'debugger'] : [],\n  },\n  build: {\n    cssCodeSplit: false,\n  },\n  resolve: {\n    alias: {\n      '~': resolve(__dirname, './'),\n      '@': resolve(__dirname, 'src'),\n      '#': resolve(__dirname, 'types'),\n    },\n  },\n  server: {\n    proxy: {\n      '^/api/.*': {\n        target: 'http://localhost:7892',\n        changeOrigin: false,\n      },\n      '^/posters/.*': 'http://localhost:7892',\n    },\n  },\n}));\n"
  },
  {
    "path": "webui/vitest.config.ts",
    "content": "import { resolve } from 'node:path';\nimport { defineConfig } from 'vitest/config';\nimport vue from '@vitejs/plugin-vue';\nimport AutoImport from 'unplugin-auto-import/vite';\n\nexport default defineConfig({\n  plugins: [\n    vue(),\n    AutoImport({\n      imports: ['vue', 'vitest', 'pinia'],\n      dts: false,\n    }),\n  ],\n  test: {\n    environment: 'happy-dom',\n    globals: true,\n    setupFiles: ['./src/test/setup.ts'],\n    include: ['src/**/*.{test,spec}.{js,ts}'],\n    coverage: {\n      provider: 'v8',\n      reporter: ['text', 'json', 'html'],\n      include: ['src/**/*.{ts,vue}'],\n      exclude: [\n        'src/test/**',\n        'src/**/*.d.ts',\n        'src/main.ts',\n        'src/router/**',\n      ],\n    },\n  },\n  resolve: {\n    alias: {\n      '~': resolve(__dirname, './'),\n      '@': resolve(__dirname, 'src'),\n      '#': resolve(__dirname, 'types'),\n    },\n  },\n});\n"
  }
]