[
  {
    "path": ".dockerignore",
    "content": ".github/\nnode_modules/\nnetlify*\n.gitignore\n.dockerignore\n.env*\nnodemon.json\nvercel.json\nDockerfile*\ndocker-compose*.yml\n.docker/"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: BUG Report / BUG 报告\ndescription: \"Create a report to help us improve\"\ntitle: \"[BUG]\"\nlabels: [\"bug\"]\nbody:\n  - type: checkboxes\n    id: self_check\n    attributes:\n      label: Self-check / 自查\n      description: Self-Check before submitting the Issue / 在提交Issue之前的自查\n      options:\n        - label: I have already cleard my browser cache / 我已经清除了浏览器缓存\n          required: false\n  - type: checkboxes\n    id: confirm\n    attributes:\n      label: Confirm / 确认\n      description: Please confirm / 请你确认\n      options:\n        - label: I have searched the Issue and found no related issues / 我已经搜索过Issue，没有找到相关问题\n          required: true\n        - label: I am using the latest source from this repository / 我使用的是来自此仓库的最新版代码\n          required: true\n        - label: I provided information which does not include sensitive information / 我提供的信息里不包含敏感信息\n          required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: BUG Description / BUG 描述\n      description: Describe your BUG here / 在此描述你的BUG\n    validations:\n      required: true\n  - type: textarea\n    id: expected_behavior\n    attributes:\n      label: Expected Behavior / 预期行为\n      description: What you expect to happen / 你认为的预期行为\n    validations:\n      required: true\n  - type: textarea\n    id: reproduce\n    attributes:\n      label: Reproduction Steps / 复现步骤\n      description: How to reproduce / 如何复现\n    validations:\n      required: true\n  - type: textarea\n    id: debug\n    attributes:\n      label: Console Log / 控制台日志\n      description: F12 -> Console / 从 F12 -> 控制台 复制你觉得可能有帮助的日志\n    validations:\n      required: false\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: Additional Information / 附加信息\n      description: Any other information you think might be helpful to solve this BUG / 你觉得对解决此BUG有帮助的其它信息\n    validations:\n      required: false"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: How To Ask Questions The Smart Way / 提问的智慧\n    about: Read it before start a new issue\n    url: http://www.catb.org/~esr/faqs/smart-questions.html\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature request / 功能请求\ndescription: \"Suggest an idea for this project\"\ntitle: \"[Feature Request]\"\nlabels: [\"enhancement\"]\nbody:\n  - type: checkboxes\n    id: confirm\n    attributes:\n      label: Confirm / 确认\n      description: Please confirm / 请你确认\n      options:\n        - label: I have searched the Issue and found no related feature requests / 我已经搜索过Issue，没有找到相关的功能请求\n          required: true\n  - type: textarea\n    id: description\n    attributes:\n      label: Feature Description / 功能描述\n      description: Describe the feature you want / 描述你想要的功能\n    validations:\n      required: true\n  - type: textarea\n    id: how_to\n    attributes:\n      label: How to Implement / 如何实现\n      description: How to implement this feature / 应该如何实现这个功能\n    validations:\n      required: false\n  - type: textarea\n    id: additional_context\n    attributes:\n      label: Additional Information / 附加信息\n      description: Any other information you think might be helpful to implement this feature / 你觉得对实现此功能有帮助的其它信息\n    validations:\n      required: false"
  },
  {
    "path": ".github/workflows/docker-build.yml",
    "content": "name: Build LibreTV image\n\non:\n  workflow_run:\n    workflows: [\"Bump version\"]\n    types:\n      - completed\n  workflow_dispatch:\n\njobs:\n  build:\n    name: Build LibreTV image\n    runs-on: ubuntu-latest\n    if: (github.event.workflow_run.conclusion == 'success' && github.repository == 'LibreSpark/LibreTV') || (github.repository == 'bestZwei/libretv')\n\n    steps:\n      - name: Check out repository\n        uses: actions/checkout@v4\n      \n      - name: Read version from VERSION.txt\n        id: version\n        run: |\n          VERSION=$(cat VERSION.txt)\n          echo \"VERSION=$VERSION\" >> $GITHUB_OUTPUT\n          \n      - name: Set Docker image tag based on repository\n        id: set-tag\n        run: |\n          if [ \"${{ github.repository }}\" = \"LibreSpark/LibreTV\" ]; then\n            echo \"IMAGE_NAME=libretv\" >> $GITHUB_OUTPUT\n            echo \"TAGS=${{ secrets.DOCKER_USERNAME }}/libretv:latest,${{ secrets.DOCKER_USERNAME }}/libretv:${{ steps.version.outputs.VERSION }}\" >> $GITHUB_OUTPUT\n          else\n            echo \"IMAGE_NAME=libretv-beta\" >> $GITHUB_OUTPUT\n            echo \"TAGS=${{ secrets.DOCKER_USERNAME }}/libretv-beta:latest\" >> $GITHUB_OUTPUT\n          fi\n\n      - name: Set up Docker QEMU\n        uses: docker/setup-qemu-action@v3\n\n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v3\n\n      - name: Login to DockerHub\n        uses: docker/login-action@v3\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n\n      - name: Build and push LibreTV image\n        uses: docker/build-push-action@v6.14.0\n        with:\n          context: .\n          file: Dockerfile\n          push: true\n          tags: ${{ steps.set-tag.outputs.TAGS }}\n          platforms: linux/amd64,linux/arm64/v8,linux/arm/v7"
  },
  {
    "path": ".github/workflows/nomore-spam.yml",
    "content": "name: NoMore Spam\n\non:\n  issues:\n    types: [opened]\n  pull_request_target:\n    types: [opened]\n    \npermissions:\n  contents: read\n  issues: write\n  pull-requests: write\n  models: read\n  actions: write\n\njobs:\n  spam-detection:\n    runs-on: ubuntu-latest\n    \n    steps:\n      - name: Detect and close spam\n        uses: JohnsonRan/nomore-spam@main\n        with:\n          github-token: ${{ secrets.GITHUB_TOKEN }}\n          ai-base-url: ${{ secrets.AI_BASE_URL }}\n          ai-api-key: ${{ secrets.AI_API_KEY }}\n          ai-model: 'qwen3-235b-a22b'\n          labels: 'bug,enhancement,question'\n          analyze-file-changes: 'true'\n          max-analysis-depth: 'normal'\n          blacklist: ${{ secrets.BLACKLIST }} # 可选：黑名单用户列表\n"
  },
  {
    "path": ".github/workflows/sync.yml",
    "content": "name: Upstream Sync\n\npermissions:\n  contents: write\n\non:\n  schedule:\n    - cron: \"0 4 * * *\" # At 12PM UTC+8\n  workflow_dispatch:\n\njobs:\n  sync_latest_from_upstream:\n    name: Sync latest commits from upstream repo\n    runs-on: ubuntu-latest\n    if: ${{ github.event.repository.fork }}\n\n    steps:\n      # Step 1: run a standard checkout action\n      - name: Checkout target repo\n        uses: actions/checkout@v4\n\n      # Step 2: run the sync action\n      - name: Sync upstream changes\n        id: sync\n        uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1\n        with:\n          upstream_sync_repo: LibreSpark/LibreTV\n          upstream_sync_branch: main\n          target_sync_branch: main\n          target_repo_token: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Sync check\n        if: failure()\n        run: |\n          echo \"[Error] Due to a change in the workflow file of the upstream repository, GitHub has automatically suspended the scheduled automatic update. You need to manually sync your fork.\"\n          exit 1\n"
  },
  {
    "path": ".github/workflows/version.yml",
    "content": "name: Bump version\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\njobs:\n  bump-version:\n    if: github.repository == 'LibreSpark/LibreTV'\n    runs-on: ubuntu-latest\n    env:\n      TZ: 'Asia/Shanghai'\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n    - name: Bump version and commit changes\n      run: |\n        CURRENT_TIME=$(date +\"%Y-%m-%d %H:%M\")\n        git config user.name 'github-actions[bot]'\n        git config user.email 'github-actions[bot]@users.noreply.github.com'\n        echo $(date +\"%Y%m%d%H%M\") > VERSION.txt\n        git add VERSION.txt\n        git commit -m \"Auto Update $CURRENT_TIME\"\n        git push origin main\n\n    - name: Delete workflow runs\n      uses: Mattraks/delete-workflow-runs@main\n      with:\n        token: ${{ secrets.GITHUB_TOKEN }}\n        repository: ${{ github.repository }}\n        retain_days: 0\n        keep_minimum_runs: 2"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndist\ndist-ssr\ncoverage\n*.local\n\n# Editor directories and files\n.vscode/*\n!.vscode/extensions.json\n.idea\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n.env\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# 贡献指南\n\n感谢您对 LibreTV 项目的关注！我们欢迎所有形式的贡献，包括但不限于代码提交、问题报告、功能建议、文档改进等。\n\n## 🚀 快速开始\n\n### 开发环境要求\n\n- Node.js 16.0 或更高版本\n- Git\n- 支持 ES6 的现代浏览器\n\n### 本地开发设置\n\n1. **Fork 项目**\n   ```bash\n   # 通过 GitHub 网页 Fork 本项目到您的账户\n   ```\n\n2. **克隆仓库**\n   ```bash\n   git clone https://github.com/YOUR_USERNAME/LibreTV.git\n   cd LibreTV\n   ```\n\n3. **安装依赖**\n   ```bash\n   npm install\n   ```\n\n4. **配置环境变量**\n   ```bash\n   cp .env.example .env\n   # 根据需要修改 .env 文件中的配置\n   ```\n\n5. **启动开发服务器**\n   ```bash\n   npm run dev\n   ```\n\n6. **访问应用**\n   ```\n   打开浏览器访问 http://localhost:8080\n   ```\n\n## 🤝 如何贡献\n\n### 报告问题\n\n如果您发现了 bug 或希望建议新功能：\n\n1. 首先查看 [Issues](https://github.com/LibreSpark/LibreTV/issues) 确保问题尚未被报告\n2. 创建新的 Issue，请包含：\n   - 清晰的标题和描述\n   - 重现步骤（如果是 bug）\n   - 预期行为和实际行为\n   - 环境信息（浏览器、操作系统等）\n   - 截图或错误日志（如果适用）\n\n### 提交代码\n\n1. **创建分支**\n   ```bash\n   git checkout -b feature/your-feature-name\n   # 或\n   git checkout -b fix/your-bug-fix\n   ```\n\n2. **进行开发**\n   - 保持代码风格一致\n   - 添加必要的注释\n   - 确保功能正常工作\n\n3. **测试更改**\n   ```bash\n   # 确保应用正常启动\n   npm run dev\n   \n   # 测试各项功能\n   # - 视频搜索\n   # - 视频播放\n   # - 响应式设计\n   # - 各种部署方式\n   ```\n\n4. **提交更改**\n   ```bash\n   git add .\n   git commit -m \"类型: 简洁的提交信息\"\n   ```\n\n5. **推送分支**\n   ```bash\n   git push origin feature/your-feature-name\n   ```\n\n6. **创建 Pull Request**\n   - 在 GitHub 上创建 Pull Request\n   - 填写详细的 PR 描述\n   - 等待代码审查\n\n### 提交信息格式\n\n请使用以下格式提交代码：\n\n```\n类型: 简洁的描述\n\n详细描述（可选）\n\n相关 Issue: #123\n```\n\n**提交类型：**\n- `feat`: 新功能\n- `fix`: 修复 bug\n- `docs`: 文档更新\n- `style`: 代码格式调整\n- `refactor`: 代码重构\n- `test`: 测试相关\n- `chore`: 构建过程或辅助工具的变动\n\n**示例：**\n```\nfeat: 添加自定义播放器控制栏\n\n- 增加播放速度调节功能\n- 优化进度条拖拽体验\n- 添加音量记忆功能\n\n相关 Issue: #45\n```\n\n## 📋 代码规范\n\n### JavaScript 规范\n\n- 使用 ES6+ 语法\n- 优先使用 `const`，需要重新赋值时使用 `let`\n- 使用有意义的变量和函数名\n- 函数名使用驼峰命名\n- 常量使用大写字母和下划线\n\n```javascript\n// ✅ 推荐\nconst API_BASE_URL = 'https://api.example.com';\nconst searchVideos = async (keyword) => {\n    // 函数实现\n};\n\n// ❌ 不推荐\nvar url = 'https://api.example.com';\nfunction search(k) {\n    // 函数实现\n}\n```\n\n### CSS 规范\n\n- 使用 BEM 命名方式或语义化类名\n- 优先使用 CSS 变量\n- 移动端优先的响应式设计\n- 避免使用 `!important`\n\n```css\n/* ✅ 推荐 */\n.video-player {\n    --primary-color: #00ccff;\n    background-color: var(--primary-color);\n}\n\n.video-player__controls {\n    display: flex;\n    gap: 1rem;\n}\n\n/* ❌ 不推荐 */\n.player {\n    background-color: #00ccff !important;\n}\n```\n\n### HTML 规范\n\n- 使用语义化标签\n- 确保可访问性（添加适当的 aria 属性）\n- 保持良好的缩进格式\n\n```html\n<!-- ✅ 推荐 -->\n<main class=\"video-search\">\n    <section class=\"search-form\" role=\"search\">\n        <input type=\"search\" aria-label=\"搜索视频\" placeholder=\"输入关键词\">\n        <button type=\"submit\" aria-label=\"搜索\">搜索</button>\n    </section>\n</main>\n\n<!-- ❌ 不推荐 -->\n<div class=\"search\">\n    <input type=\"text\" placeholder=\"搜索\">\n    <div onclick=\"search()\">搜索</div>\n</div>\n```\n\n## 🎯 贡献重点领域\n\n我们特别欢迎以下方面的贡献：\n\n### 核心功能\n- **搜索优化**: 改进搜索算法和用户体验\n- **播放器增强**: 新的播放器功能和控制选项\n- **API 集成**: 添加新的视频源 API 支持\n- **性能优化**: 加载速度和播放性能改进\n\n### 用户体验\n- **界面设计**: UI/UX 改进和现代化\n- **响应式设计**: 移动端体验优化\n- **无障碍功能**: 提高可访问性\n- **国际化**: 多语言支持\n\n### 技术架构\n- **代码重构**: 提高代码质量和可维护性\n- **安全性**: 安全漏洞修复和防护\n- **部署优化**: 改进各平台部署流程\n- **监控日志**: 添加错误监控和日志系统\n\n### 文档和社区\n- **文档完善**: API 文档、部署指南等\n- **示例项目**: 集成示例和最佳实践\n- **社区建设**: 问题回答和新手指导\n\n## 🔍 代码审查流程\n\n1. **自动检查**: PR 会触发自动化测试\n2. **代码审查**: 维护者会审查代码质量和功能\n3. **反馈修改**: 根据审查意见修改代码\n4. **合并**: 审查通过后合并到主分支\n\n### 审查标准\n\n- **功能完整**: 功能按预期工作\n- **代码质量**: 遵循项目编码规范\n- **性能影响**: 不显著影响应用性能\n- **兼容性**: 与现有功能兼容\n- **文档更新**: 必要时更新相关文档\n\n## 🚫 注意事项\n\n### 不接受的贡献\n\n- **侵权内容**: 包含版权争议的代码或资源\n- **恶意代码**: 包含病毒、后门或其他恶意功能\n- **商业推广**: 纯粹的商业宣传或广告\n- **不相关功能**: 与项目核心功能无关的特性\n\n### 法律要求\n\n- 确保您的贡献不侵犯他人版权\n- 提交的代码必须是您原创或有合法使用权\n- 同意以项目相同的 MIT 许可证分发您的贡献\n\n## 📞 联系方式\n\n如果您有任何问题或需要帮助：\n\n- **GitHub Issues**: [报告问题或建议](https://github.com/LibreSpark/LibreTV/issues)\n- **GitHub Discussions**: [参与社区讨论](https://github.com/LibreSpark/LibreTV/discussions)\n- **Email**: 通过 GitHub 联系项目维护者\n\n## 🙏 致谢\n\n感谢所有为 LibreTV 项目做出贡献的开发者！您的每一份贡献都让这个项目变得更好。\n\n### 贡献者列表\n\n我们会在项目 README 中展示所有贡献者。您的贡献被合并后，您的 GitHub 头像将出现在贡献者列表中。\n\n---\n\n**再次感谢您的贡献！** 🎉\n\n让我们一起构建一个更好的 LibreTV！\n"
  },
  {
    "path": "Dockerfile",
    "content": "FROM node:lts-alpine\n\nLABEL maintainer=\"LibreTV Team\"\nLABEL description=\"LibreTV - 免费在线视频搜索与观看平台\"\n\n# 设置环境变量\nENV PORT=8080\nENV CORS_ORIGIN=*\nENV DEBUG=false\nENV REQUEST_TIMEOUT=5000\nENV MAX_RETRIES=2\nENV CACHE_MAX_AGE=1d\n\n# 设置工作目录\nWORKDIR /app\n\n# 复制 package.json 和 package-lock.json（如果存在）\nCOPY package*.json ./\n\n# 安装依赖\nRUN npm ci --only=production && npm cache clean --force\n\n# 复制应用文件\nCOPY . .\n\n# 暴露端口\nEXPOSE 8080\n\n# 健康检查\nHEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \\\n  CMD node -e \"require('http').get('http://localhost:8080', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))\"\n\n# 启动应用\nCMD [\"npm\", \"start\"]\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright 2025 LibreTV Team\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n"
  },
  {
    "path": "README.md",
    "content": "# LibreTV - 免费在线视频搜索与观看平台\n\n<div align=\"center\">\n  <img src=\"image/logo.png\" alt=\"LibreTV Logo\" width=\"120\">\n  <br>\n  <p><strong>自由观影，畅享精彩</strong></p>\n</div>\n\n## 📺 项目简介\n\nLibreTV 是一个轻量级、免费的在线视频搜索与观看平台，提供来自多个视频源的内容搜索与播放服务。无需注册，即开即用，支持多种设备访问。项目结合了前端技术和后端代理功能，可部署在支持服务端功能的各类网站托管服务上。**项目门户**： [libretv.is-an.org](https://libretv.is-an.org)\n\n本项目基于 [bestK/tv](https://github.com/bestK/tv) 进行重构与增强。\n\n<details>\n  <summary>点击查看项目截图</summary>\n  <img src=\"https://github.com/user-attachments/assets/df485345-e83b-4564-adf7-0680be92d3c7\" alt=\"项目截图\" style=\"max-width:600px\">\n</details>\n\n## 🚀 快速部署\n\n选择以下任一平台，点击一键部署按钮，即可快速创建自己的 LibreTV 实例：\n\n[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FLibreSpark%2FLibreTV)  \n[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/LibreSpark/LibreTV)  \n[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/LibreSpark/LibreTV)\n\n## 🚨 重要声明\n\n- 本项目仅供学习和个人使用，为避免版权纠纷，必须设置PASSWORD环境变量\n- 请勿将部署的实例用于商业用途或公开服务\n- 如因公开分享导致的任何法律问题，用户需自行承担责任\n- 项目开发者不对用户的使用行为承担任何法律责任\n\n## ⚠️ 同步与升级\n\nPull Bot 会反复触发无效的 PR 和垃圾邮件，严重干扰项目维护。作者可能会直接拉黑所有 Pull Bot 自动发起的同步请求的仓库所有者。\n\n**推荐做法：**\n\n建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能（见 `.github/workflows/sync.yml`）。 \n\n如需手动同步主仓库更新，也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。\n\n对于更新后可能会出现的错误和异常，在设置中备份配置后，首先清除页面Cookie，然后 Ctrl + F5 刷新页面。再次访问网页检查是否解决问题。\n\n\n## 📋 详细部署指南\n\n### Cloudflare Pages\n\n1. Fork 或克隆本仓库到您的 GitHub 账户\n2. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/)，进入 Pages 服务\n3. 点击\"创建项目\"，连接您的 GitHub 仓库\n4. 使用以下设置：\n   - 构建命令：留空（无需构建）\n   - 输出目录：留空（默认为根目录）\n5. **⚠️ 重要：在\"设置\" > \"环境变量\"中添加 `PASSWORD` 变量（必须设置）**\n6. 点击\"保存并部署\"\n\n### Vercel\n\n1. Fork 或克隆本仓库到您的 GitHub/GitLab 账户\n2. 登录 [Vercel](https://vercel.com/)，点击\"New Project\"\n3. 导入您的仓库，使用默认设置\n4. **⚠️ 重要：在\"Settings\" > \"Environment Variables\"中添加 `PASSWORD` 变量（必须设置）**\n5. 点击\"Deploy\"\n\n\n### Docker\n```\ndocker run -d \\\n  --name libretv \\\n  --restart unless-stopped \\\n  -p 8899:8080 \\\n  -e PASSWORD=your_password \\\n  bestzwei/libretv:latest\n```\n\n### Docker Compose\n\n`docker-compose.yml` 文件：\n\n```yaml\nservices:\n  libretv:\n    image: bestzwei/libretv:latest\n    container_name: libretv\n    ports:\n      - \"8899:8080\" # 将内部 8080 端口映射到主机的 8899 端口\n    environment:\n      - PASSWORD=${PASSWORD:-111111} # 可将 111111 修改为你想要的密码，默认为 your_password\n    restart: unless-stopped\n```\n启动 LibreTV：\n\n```bash\ndocker compose up -d\n```\n访问 `http://localhost:8899` 即可使用。\n\n### 本地开发环境\n\n项目包含后端代理功能，需要支持服务器端功能的环境：\n\n```bash\n# 首先，通过复制示例来设置 .env 文件（可选）\ncp .env.example .env\n\n# 安装依赖\nnpm install\n\n# 启动开发服务器\nnpm run dev\n```\n\n访问 `http://localhost:8080` 即可使用（端口可在.env文件中通过PORT变量修改）。\n\n> ⚠️ 注意：使用简单静态服务器（如 `python -m http.server` 或 `npx http-server`）时，视频代理功能将不可用，视频无法正常播放。完整功能测试请使用 Node.js 开发服务器。\n\n## 🔧 自定义配置\n\n### 密码保护\n\n**重要提示**: 为确保安全，所有部署都必须设置 PASSWORD 环境变量，否则用户将看到设置密码的提示。\n\n\n### API兼容性\n\nLibreTV 支持标准的苹果 CMS V10 API 格式。添加自定义 API 时需遵循以下格式：\n- 搜索接口: `https://example.com/api.php/provide/vod/?ac=videolist&wd=关键词`\n- 详情接口: `https://example.com/api.php/provide/vod/?ac=detail&ids=视频ID`\n\n**添加 CMS 源**:\n1. 在设置面板中选择\"自定义接口\"\n2. 接口地址: `https://example.com/api.php/provide/vod`\n\n## ⌨️ 键盘快捷键\n\n播放器支持以下键盘快捷键：\n\n- **空格键**: 播放/暂停\n- **左右箭头**: 快退/快进\n- **上下箭头**: 音量增加/减小\n- **M 键**: 静音/取消静音\n- **F 键**: 全屏/退出全屏\n- **Esc 键**: 退出全屏\n\n## 🛠️ 技术栈\n\n- HTML5 + CSS3 + JavaScript (ES6+)\n- Tailwind CSS\n- HLS.js 用于 HLS 流处理\n- DPlayer 视频播放器核心\n- Cloudflare/Vercel/Netlify Serverless Functions\n- 服务端 HLS 代理和处理技术\n- localStorage 本地存储\n\n## ⚠️ 免责声明\n\nLibreTV 仅作为视频搜索工具，不存储、上传或分发任何视频内容。所有视频均来自第三方 API 接口提供的搜索结果。如有侵权内容，请联系相应的内容提供方。\n\n本项目开发者不对使用本项目产生的任何后果负责。使用本项目时，您必须遵守当地的法律法规。\n\n## 🤝 衍生项目\n\n它们提供了更多丰富的自定义功能，欢迎体验~\n\n- **[MoonTV](https://github.com/senshinya/MoonTV)**  \n- **[OrionTV](https://github.com/zimplexing/OrionTV)**  \n\n## 🥇 感谢支持\n\n- **[Sharon](https://sharon.io)**\n- **[ZMTO](https://zmto.com)**\n- **[YXVM](https://yxvm.com)**  "
  },
  {
    "path": "VERSION.txt",
    "content": "202508060117\n"
  },
  {
    "path": "about.html",
    "content": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n\t<title>关于我们 - LibreTV</title>\n\t<script src=\"libs/tailwindcss.min.js\"></script>\n\t<link rel=\"stylesheet\" href=\"css/styles.css\">\n\t<link rel=\"manifest\" href=\"manifest.json\">\n\t\n\t<!-- Favicon -->\n    <link rel=\"icon\" href=\"image/logo.png\">\n    <link rel=\"apple-touch-icon\" href=\"image/logo-black.png\">\n</head>\n<body class=\"page-bg text-white flex flex-col min-h-screen\">\n\t<header class=\"border-b border-[#333] bg-[#0a0a0a] p-4\">\n        <div class=\"container mx-auto flex items-center\">\n            <div class=\"flex items-center\">\n                <a href=\"/\" class=\"flex items-center\">\n                    <svg class=\"w-8 h-8 mr-2 text-[#00ccff]\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                    </svg>\n                    <h1 class=\"text-xl font-bold gradient-text\">LibreTV</h1>\n                </a>\n            </div>\n            <div class=\"flex-1 text-center\">\n                <h2 class=\"text-xl font-semibold\">关于LibreTV</h2>\n            </div>\n            <div class=\"flex items-center\">\n                <a href=\"/\" class=\"px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors flex items-center\">\n                    <svg class=\"w-5 h-5 mr-1\" viewBox=\"0 0 24 24\" fill=\"#ffffff\" xmlns=\"http://www.w3.org/2000/svg\">\n                        <path d=\"M10 19l-7-7m0 0l7-7m-7 7h18\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n                    </svg>\n                    回到首页\n                </a>\n            </div>\n        </div>\n    </header>\t<main class=\"flex-grow container mx-auto px-4 sm:px-6 py-8 sm:py-16\">\n\t\t<div class=\"max-w-5xl mx-auto\">\n\t\t\t<!-- 主要内容区域 -->\n\t\t\t<div class=\"bg-gradient-to-br from-[#111] to-[#0a0a0a] border border-[#333] rounded-xl sm:rounded-2xl p-4 sm:p-8 lg:p-12 shadow-2xl\">\n\t\t\t\t<!-- 项目介绍 -->\t\t\t\t\n\t\t\t\t <div class=\"text-center mb-8 sm:mb-16\">\n\t\t\t\t\t<div class=\"inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl mb-6 sm:mb-8\">\n\t\t\t\t\t\t<img src=\"image/logo-black.png\" alt=\"LibreTV Logo\" class=\"w-12 h-12 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl\">\n\t\t\t\t\t</div>\n\t\t\t\t\t\n\t\t\t\t\t<p class=\"text-gray-300 text-lg sm:text-xl mb-6 sm:mb-8 leading-relaxed max-w-3xl mx-auto px-2\">\n\t\t\t\t\t\tLibreTV 是一个免费的在线视频搜索平台，提供视频搜索和播放服务，致力于为用户带来最佳体验。\n\t\t\t\t\t</p>\n\t\t\t\t\t<div class=\"bg-[#1a1a1a] border border-[#333] rounded-lg sm:rounded-xl p-4 sm:p-6 max-w-2xl mx-auto\">\n\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg mb-4 px-1\">\n\t\t\t\t\t\t\t本项目代码托管在 GitHub 上，欢迎访问我们的仓库：\n\t\t\t\t\t\t</p>\n\t\t\t\t\t\t<a href=\"https://github.com/LibreSpark/LibreTV\" class=\"inline-flex items-center px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white text-sm sm:text-base font-medium rounded-lg transition-all duration-300 transform hover:scale-105\" target=\"_blank\" rel=\"noopener\">\n\t\t\t\t\t\t\t<svg class=\"w-4 h-4 sm:w-5 sm:h-5 mr-2\" fill=\"currentColor\" viewBox=\"0 0 24 24\">\n\t\t\t\t\t\t\t\t<path d=\"M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z\"/>\n\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t<span class=\"break-words\">访问 GitHub 仓库</span>\n\t\t\t\t\t\t</a>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\t\t\t\t<!-- 分割线 -->\n\t\t\t\t<div class=\"w-full h-px bg-gradient-to-r from-transparent via-[#333] to-transparent mb-8 sm:mb-16\"></div>\n\n\t\t\t\t<!-- 隐私政策 -->\n\t\t\t\t<div class=\"mb-8 sm:mb-16\">\n\t\t\t\t\t<h2 class=\"text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-center bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent\">隐私政策</h2>\n\t\t\t\t\t<div class=\"grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-8\">\n\t\t\t\t\t\t<div class=\"bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8\">\n\t\t\t\t\t\t\t<div class=\"flex items-center mb-3 sm:mb-4\">\n\t\t\t\t\t\t\t\t<div class=\"w-3 h-3 bg-green-400 rounded-full mr-3\"></div>\n\t\t\t\t\t\t\t\t<h3 class=\"text-lg sm:text-xl font-semibold text-gray-200\">数据保护</h3>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg leading-relaxed\">\n\t\t\t\t\t\t\t\t我们尊重并保护您的隐私。LibreTV 不收集任何个人数据，且不会限制访问或使用本网站。\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div class=\"bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8\">\n\t\t\t\t\t\t\t<div class=\"flex items-center mb-3 sm:mb-4\">\n\t\t\t\t\t\t\t\t<div class=\"w-3 h-3 bg-blue-400 rounded-full mr-3\"></div>\n\t\t\t\t\t\t\t\t<h3 class=\"text-lg sm:text-xl font-semibold text-gray-200\">服务说明</h3>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg leading-relaxed\">\n\t\t\t\t\t\t\t\t本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供，我们不会存储或追踪用户信息。\n\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\n\t\t\t\t<!-- 分割线 -->\n\t\t\t\t<div class=\"w-full h-px bg-gradient-to-r from-transparent via-[#333] to-transparent mb-8 sm:mb-16\"></div>\t\t\t\t<!-- 版权声明与投诉机制 -->\n\t\t\t\t<div>\n\t\t\t\t\t<h2 class=\"text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-center bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent\">版权声明与投诉机制</h2>\n\t\t\t\t\t<div class=\"space-y-6 sm:space-y-8\">\n\t\t\t\t\t\t<div class=\"bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8\">\n\t\t\t\t\t\t\t<div class=\"flex items-start\">\n\t\t\t\t\t\t\t\t<div class=\"flex-shrink-0 w-8 h-8 bg-yellow-500 rounded-lg flex items-center justify-center mr-3 sm:mr-4 mt-1\">\n\t\t\t\t\t\t\t\t\t<svg class=\"w-4 h-4 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n\t\t\t\t\t\t\t\t\t\t<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z\"></path>\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div>\n\t\t\t\t\t\t\t\t\t<h3 class=\"text-lg sm:text-xl font-semibold text-gray-200 mb-3 sm:mb-4\">免责声明</h3>\n\t\t\t\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg leading-relaxed\">\n\t\t\t\t\t\t\t\t\t\tLibreTV 仅提供视频搜索服务，不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。用户在使用本站服务时，须遵守相关法律法规，不得利用搜索结果从事侵权行为，如下载、传播未经授权的作品等。\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\n\t\t\t\t\t\t<div class=\"bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8\">\n\t\t\t\t\t\t\t<div class=\"flex items-start\">\n\t\t\t\t\t\t\t\t<div class=\"flex-shrink-0 w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center mr-3 sm:mr-4 mt-1\">\n\t\t\t\t\t\t\t\t\t<svg class=\"w-4 h-4 text-white\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n\t\t\t\t\t\t\t\t\t\t<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"></path>\n\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t<div class=\"flex-1\">\n\t\t\t\t\t\t\t\t\t<h3 class=\"text-lg sm:text-xl font-semibold text-gray-200 mb-3 sm:mb-4\">投诉反馈</h3>\n\t\t\t\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg mb-4 sm:mb-6 leading-relaxed\">\n\t\t\t\t\t\t\t\t\t\t若您是版权方或相关权利人，发现本站搜索结果中存在侵犯您合法权益的内容，请通过以下渠道向我们反馈：\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t\t<div class=\"bg-gradient-to-r from-[#222] to-[#333] border border-[#444] rounded-lg p-4 sm:p-6 mb-4 sm:mb-6\">\n\t\t\t\t\t\t\t\t\t\t<div class=\"flex items-center flex-wrap\">\n\t\t\t\t\t\t\t\t\t\t\t<svg class=\"w-5 h-5 sm:w-6 sm:h-6 text-blue-400 mr-2 sm:mr-3 flex-shrink-0\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n\t\t\t\t\t\t\t\t\t\t\t\t<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z\"></path>\n\t\t\t\t\t\t\t\t\t\t\t</svg>\n\t\t\t\t\t\t\t\t\t\t\t<span class=\"text-gray-300 text-base sm:text-lg font-medium mr-2 sm:mr-3\">投诉邮箱：</span>\n\t\t\t\t\t\t\t\t\t\t\t<a href=\"mailto:troll@pissmail.com\" class=\"text-blue-400 hover:text-blue-300 transition-colors text-base sm:text-lg font-medium break-all\">troll@pissmail.com</a>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t<p class=\"text-gray-300 text-base sm:text-lg leading-relaxed\">\n\t\t\t\t\t\t\t\t\t\t请在投诉邮件中提供：您的身份证明、权利证明、侵权内容的具体链接及相关说明。我们将在收到投诉后尽快处理，对于确认侵权的内容，将立即断开相关链接，停止展示侵权内容，并将处理结果反馈给您。\n\t\t\t\t\t\t\t\t\t</p>\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t</div>\n\t</main>\n\t<footer class=\"footer py-6 border-t border-[#333] bg-[#0a0a0a]\">\n        <div class=\"container mx-auto px-4\">\n            <div class=\"flex flex-col md:flex-row justify-between items-center\">\n                <div class=\"mb-4 md:mb-0\">\n                    <div class=\"flex items-center justify-center md:justify-start\">\n                        <svg class=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                        </svg>\n                        <span class=\"gradient-text font-bold\">LibreTV</span>\n                    </div>\n                    <p class=\"text-gray-500 text-sm mt-2 text-center md:text-left\">© 2025 LibreTV - 自由观影，畅享精彩</p>\n                </div>\n                \n                <div class=\"text-center md:text-right\">\n                    <p class=\"text-gray-500 text-sm max-w-md\">\n                        免责声明：本站仅为视频搜索工具，不存储、上传或分发任何视频内容。\n                        所有视频均来自第三方API接口。如有侵权，请联系相关内容提供方。\n                    </p>\n                    <div class=\"mt-2 flex justify-center md:justify-end space-x-4\">\n                        <a href=\"/\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">首页</a>\n                        <a href=\"about.html\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">关于我们</a>\n                        <a href=\"https://www.msf.hk/zh-hant/donate/general?type=one-off\" target=\"_blank\" rel=\"noopener\" class=\"text-blue-400 hover:text-blue-300 text-sm transition-colors\">捐赠</a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </footer>\n</body>\n</html>\n"
  },
  {
    "path": "api/proxy/[...path].mjs",
    "content": "// /api/proxy/[...path].mjs - Vercel Serverless Function (ES Module)\n\nimport fetch from 'node-fetch';\nimport { URL } from 'url'; // 使用 Node.js 内置 URL 处理\nimport crypto from 'crypto'; // 导入 crypto 模块用于密码哈希\n\n// --- 配置 (从环境变量读取) ---\nconst DEBUG_ENABLED = process.env.DEBUG === 'true';\nconst CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // 默认 24 小时\nconst MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // 默认 5 层\n\n// --- User Agent 处理 ---\n// 默认 User Agent 列表\nlet USER_AGENTS = [\n    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'\n];\n// 尝试从环境变量读取并解析 USER_AGENTS_JSON\ntry {\n    const agentsJsonString = process.env.USER_AGENTS_JSON;\n    if (agentsJsonString) {\n        const parsedAgents = JSON.parse(agentsJsonString);\n        // 检查解析结果是否为非空数组\n        if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {\n            USER_AGENTS = parsedAgents; // 使用环境变量中的数组\n            console.log(`[代理日志] 已从环境变量加载 ${USER_AGENTS.length} 个 User Agent。`);\n        } else {\n            console.warn(\"[代理日志] 环境变量 USER_AGENTS_JSON 不是有效的非空数组，使用默认值。\");\n        }\n    } else {\n        console.log(\"[代理日志] 未设置环境变量 USER_AGENTS_JSON，使用默认 User Agent。\");\n    }\n} catch (e) {\n    // 如果 JSON 解析失败，记录错误并使用默认值\n    console.error(`[代理日志] 解析环境变量 USER_AGENTS_JSON 出错: ${e.message}。使用默认 User Agent。`);\n}\n\n// 广告过滤在代理中禁用，由播放器处理\nconst FILTER_DISCONTINUITY = false;\n\n\n// --- 辅助函数 ---\n\nfunction logDebug(message) {\n    if (DEBUG_ENABLED) {\n        console.log(`[代理日志] ${message}`);\n    }\n}\n\n/**\n * 从代理请求路径中提取编码后的目标 URL。\n * @param {string} encodedPath - URL 编码后的路径部分 (例如 \"https%3A%2F%2F...\")\n * @returns {string|null} 解码后的目标 URL，如果无效则返回 null。\n */\nfunction getTargetUrlFromPath(encodedPath) {\n    if (!encodedPath) {\n        logDebug(\"getTargetUrlFromPath 收到空路径。\");\n        return null;\n    }\n    try {\n        const decodedUrl = decodeURIComponent(encodedPath);\n        // 基础检查，看是否像一个 HTTP/HTTPS URL\n        if (decodedUrl.match(/^https?:\\/\\/.+/i)) {\n            return decodedUrl;\n        } else {\n            logDebug(`无效的解码 URL 格式: ${decodedUrl}`);\n            // 备选检查：原始路径是否未编码但看起来像 URL？\n            if (encodedPath.match(/^https?:\\/\\/.+/i)) {\n                logDebug(`警告: 路径未编码但看起来像 URL: ${encodedPath}`);\n                return encodedPath;\n            }\n            return null;\n        }\n    } catch (e) {\n        // 捕获解码错误 (例如格式错误的 URI)\n        logDebug(`解码目标 URL 出错: ${encodedPath} - ${e.message}`);\n        return null;\n    }\n}\n\nfunction getBaseUrl(urlStr) {\n    if (!urlStr) return '';\n    try {\n        const parsedUrl = new URL(urlStr);\n        // 处理根目录或只有文件名的情况\n        const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); // 移除空字符串\n        if (pathSegments.length <= 1) {\n            return `${parsedUrl.origin}/`;\n        }\n        pathSegments.pop(); // 移除最后一段\n        return `${parsedUrl.origin}/${pathSegments.join('/')}/`;\n    } catch (e) {\n        logDebug(`获取 BaseUrl 失败: \"${urlStr}\": ${e.message}`);\n        // 备用方法：查找最后一个斜杠\n        const lastSlashIndex = urlStr.lastIndexOf('/');\n        if (lastSlashIndex > urlStr.indexOf('://') + 2) { // 确保不是协议部分的斜杠\n            return urlStr.substring(0, lastSlashIndex + 1);\n        }\n        return urlStr + '/'; // 如果没有路径，添加斜杠\n    }\n}\n\nfunction resolveUrl(baseUrl, relativeUrl) {\n    if (!relativeUrl) return ''; // 处理空的 relativeUrl\n    if (relativeUrl.match(/^https?:\\/\\/.+/i)) {\n        return relativeUrl; // 已经是绝对 URL\n    }\n    if (!baseUrl) return relativeUrl; // 没有基础 URL 无法解析\n\n    try {\n        // 使用 Node.js 的 URL 构造函数处理相对路径\n        return new URL(relativeUrl, baseUrl).toString();\n    } catch (e) {\n        logDebug(`URL 解析失败: base=\"${baseUrl}\", relative=\"${relativeUrl}\". 错误: ${e.message}`);\n        // 简单的备用逻辑\n        if (relativeUrl.startsWith('/')) {\n             try {\n                const baseOrigin = new URL(baseUrl).origin;\n                return `${baseOrigin}${relativeUrl}`;\n             } catch { return relativeUrl; } // 如果 baseUrl 也无效，返回原始相对路径\n        } else {\n            // 假设相对于包含基础 URL 资源的目录\n            return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`;\n        }\n    }\n}\n\n// ** 已修正：确保生成 /proxy/ 前缀的链接 **\nfunction rewriteUrlToProxy(targetUrl) {\n    if (!targetUrl || typeof targetUrl !== 'string') return '';\n    // 返回与 vercel.json 的 \"source\" 和前端 PROXY_URL 一致的路径\n    return `/proxy/${encodeURIComponent(targetUrl)}`;\n}\n\nfunction getRandomUserAgent() {\n    return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];\n}\n\nasync function fetchContentWithType(targetUrl, requestHeaders) {\n    // 准备请求头\n    const headers = {\n        'User-Agent': getRandomUserAgent(),\n        'Accept': requestHeaders['accept'] || '*/*', // 传递原始 Accept 头（如果有）\n        'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',\n        // 尝试设置一个合理的 Referer\n        'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,\n    };\n    // 清理空值的头\n    Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});\n\n    logDebug(`准备请求目标: ${targetUrl}，请求头: ${JSON.stringify(headers)}`);\n\n    try {\n        // 发起 fetch 请求\n        const response = await fetch(targetUrl, { headers, redirect: 'follow' });\n\n        // 检查响应是否成功\n        if (!response.ok) {\n            const errorBody = await response.text().catch(() => ''); // 尝试获取错误响应体\n            logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);\n            // 创建一个包含状态码的错误对象\n            const err = new Error(`HTTP 错误 ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);\n            err.status = response.status; // 将状态码附加到错误对象\n            throw err; // 抛出错误\n        }\n\n        // 读取响应内容\n        const content = await response.text();\n        const contentType = response.headers.get('content-type') || '';\n        logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);\n        // 返回结果\n        return { content, contentType, responseHeaders: response.headers };\n\n    } catch (error) {\n        // 捕获 fetch 本身的错误（网络、超时等）或上面抛出的 HTTP 错误\n        logDebug(`请求异常 ${targetUrl}: ${error.message}`);\n        // 重新抛出，确保包含原始错误信息\n        throw new Error(`请求目标 URL 失败 ${targetUrl}: ${error.message}`);\n    }\n}\n\nfunction isM3u8Content(content, contentType) {\n    if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {\n        return true;\n    }\n    return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');\n}\n\nfunction processKeyLine(line, baseUrl) {\n    return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => {\n        const absoluteUri = resolveUrl(baseUrl, uri);\n        logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);\n        return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`;\n    });\n}\n\nfunction processMapLine(line, baseUrl) {\n     return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => {\n        const absoluteUri = resolveUrl(baseUrl, uri);\n        logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);\n        return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`;\n     });\n }\n\nfunction processMediaPlaylist(url, content) {\n    const baseUrl = getBaseUrl(url);\n    if (!baseUrl) {\n        logDebug(`无法确定媒体列表的 Base URL: ${url}，相对路径可能无法处理。`);\n    }\n    const lines = content.split('\\n');\n    const output = [];\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i].trim();\n        // 保留最后一个空行\n        if (!line && i === lines.length - 1) { output.push(line); continue; }\n        if (!line) continue; // 跳过中间空行\n        // 广告过滤已禁用\n        if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }\n        if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }\n        if (line.startsWith('#EXTINF')) { output.push(line); continue; }\n        // 处理 URL 行\n        if (!line.startsWith('#')) {\n            const absoluteUrl = resolveUrl(baseUrl, line);\n            logDebug(`重写媒体片段: 原始='${line}', 解析后='${absoluteUrl}'`);\n            output.push(rewriteUrlToProxy(absoluteUrl)); continue;\n        }\n        // 保留其他 M3U8 标签\n        output.push(line);\n    }\n    return output.join('\\n');\n}\n\nasync function processM3u8Content(targetUrl, content, recursionDepth = 0) {\n    // 判断是主列表还是媒体列表\n    if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {\n        logDebug(`检测到主播放列表: ${targetUrl} (深度: ${recursionDepth})`);\n        return await processMasterPlaylist(targetUrl, content, recursionDepth);\n    }\n    logDebug(`检测到媒体播放列表: ${targetUrl} (深度: ${recursionDepth})`);\n    return processMediaPlaylist(targetUrl, content);\n}\n\nasync function processMasterPlaylist(url, content, recursionDepth) {\n    // 检查递归深度\n    if (recursionDepth > MAX_RECURSION) {\n        throw new Error(`处理主播放列表时，递归深度超过最大限制 (${MAX_RECURSION}): ${url}`);\n    }\n    const baseUrl = getBaseUrl(url);\n    const lines = content.split('\\n');\n    let highestBandwidth = -1;\n    let bestVariantUrl = '';\n\n    // 查找最高带宽的流\n    for (let i = 0; i < lines.length; i++) {\n        if (lines[i].startsWith('#EXT-X-STREAM-INF')) {\n            const bandwidthMatch = lines[i].match(/BANDWIDTH=(\\d+)/);\n            const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;\n            let variantUriLine = '';\n            // 找到下一行的 URI\n            for (let j = i + 1; j < lines.length; j++) {\n                const line = lines[j].trim();\n                if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; }\n            }\n            if (variantUriLine && currentBandwidth >= highestBandwidth) {\n                highestBandwidth = currentBandwidth;\n                bestVariantUrl = resolveUrl(baseUrl, variantUriLine);\n            }\n        }\n    }\n    // 如果没有找到带宽信息，尝试查找第一个 .m3u8 链接\n    if (!bestVariantUrl) {\n        logDebug(`主播放列表中未找到 BANDWIDTH 信息，尝试查找第一个 URI: ${url}`);\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i].trim();\n             // 更可靠地匹配 .m3u8 链接\n            if (line && !line.startsWith('#') && line.match(/\\.m3u8($|\\?.*)/i)) {\n                bestVariantUrl = resolveUrl(baseUrl, line);\n                logDebug(`备选方案: 找到第一个子播放列表 URI: ${bestVariantUrl}`);\n                break;\n            }\n        }\n    }\n    // 如果仍然没有找到子列表 URL\n    if (!bestVariantUrl) {\n        logDebug(`在主播放列表 ${url} 中未找到有效的子列表 URI，将其作为媒体列表处理。`);\n        return processMediaPlaylist(url, content);\n    }\n\n    logDebug(`选择的子播放列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);\n    // 请求选定的子播放列表内容 (注意：这里传递 {} 作为请求头，不传递客户端的原始请求头)\n    const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});\n\n    // 检查获取的内容是否是 M3U8\n    if (!isM3u8Content(variantContent, variantContentType)) {\n        logDebug(`获取的子播放列表 ${bestVariantUrl} 不是 M3U8 (类型: ${variantContentType})，将其作为媒体列表处理。`);\n        return processMediaPlaylist(bestVariantUrl, variantContent);\n    }\n\n    // 递归处理获取到的子 M3U8 内容\n    return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);\n}\n\n/**\n * 验证代理请求的鉴权\n */\nasync function validateAuth(req) {\n    const authHash = req.query.auth;\n    const timestamp = req.query.t;\n    \n    // 获取服务器端密码哈希\n    const serverPassword = process.env.PASSWORD;\n    if (!serverPassword) {\n        console.error('服务器未设置 PASSWORD 环境变量，代理访问被拒绝');\n        return false;\n    }\n    \n    // 使用 crypto 模块计算 SHA-256 哈希\n    const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');\n    \n    if (!authHash || authHash !== serverPasswordHash) {\n        console.warn('代理请求鉴权失败：密码哈希不匹配');\n        return false;\n    }\n    \n    // 验证时间戳（10分钟有效期）\n    if (timestamp) {\n        const now = Date.now();\n        const maxAge = 10 * 60 * 1000; // 10分钟\n        if (now - parseInt(timestamp) > maxAge) {\n            console.warn('代理请求鉴权失败：时间戳过期');\n            return false;\n        }\n    }\n    \n    return true;\n}\n\n// --- Vercel Handler 函数 ---\nexport default async function handler(req, res) {\n    // --- 记录请求开始 ---\n    console.info('--- Vercel 代理请求开始 ---');\n    console.info('时间:', new Date().toISOString());\n    console.info('方法:', req.method);\n    console.info('URL:', req.url); // 原始请求 URL (例如 /proxy/...)\n    console.info('查询参数:', JSON.stringify(req.query)); // Vercel 解析的查询参数\n\n    // --- 提前设置 CORS 头 ---\n    res.setHeader('Access-Control-Allow-Origin', '*');\n    res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');\n    res.setHeader('Access-Control-Allow-Headers', '*'); // 允许所有请求头\n\n    // --- 处理 OPTIONS 预检请求 ---\n    if (req.method === 'OPTIONS') {\n        console.info(\"处理 OPTIONS 预检请求\");\n        res.status(204).setHeader('Access-Control-Max-Age', '86400').end(); // 缓存预检结果 24 小时\n        return;\n    }\n\n    let targetUrl = null; // 初始化目标 URL\n\n    try { // ---- 开始主处理逻辑的 try 块 ----\n\n        // --- 验证鉴权 ---\n        const isAuthorized = await validateAuth(req);\n        if (!isAuthorized) {\n            console.warn('代理请求鉴权失败');\n            res.status(401).json({\n                success: false,\n                error: '代理访问未授权：请检查密码配置或鉴权参数'\n            });\n            return;\n        }\n\n        // --- 提取目标 URL (主要依赖 req.query[\"...path\"]) ---\n        // Vercel 将 :path* 捕获的内容（可能包含斜杠）放入 req.query[\"...path\"] 数组\n        const pathData = req.query[\"...path\"]; // 使用正确的键名\n        let encodedUrlPath = '';\n\n        if (pathData) {\n            if (Array.isArray(pathData)) {\n                encodedUrlPath = pathData.join('/'); // 重新组合\n                console.info(`从 req.query[\"...path\"] (数组) 组合的编码路径: ${encodedUrlPath}`);\n            } else if (typeof pathData === 'string') {\n                encodedUrlPath = pathData; // 也处理 Vercel 可能只返回字符串的情况\n                console.info(`从 req.query[\"...path\"] (字符串) 获取的编码路径: ${encodedUrlPath}`);\n            } else {\n                console.warn(`[代理警告] req.query[\"...path\"] 类型未知: ${typeof pathData}`);\n            }\n        } else {\n            console.warn(`[代理警告] req.query[\"...path\"] 为空或未定义。`);\n            // 备选：尝试从 req.url 提取（如果需要）\n            if (req.url && req.url.startsWith('/proxy/')) {\n                encodedUrlPath = req.url.substring('/proxy/'.length);\n                console.info(`使用备选方法从 req.url 提取的编码路径: ${encodedUrlPath}`);\n            }\n        }\n\n        // 如果仍然为空，则无法继续\n        if (!encodedUrlPath) {\n             throw new Error(\"无法从请求中确定编码后的目标路径。\");\n        }\n\n        // 解析目标 URL\n        targetUrl = getTargetUrlFromPath(encodedUrlPath);\n        console.info(`解析出的目标 URL: ${targetUrl || 'null'}`); // 记录解析结果\n\n        // 检查目标 URL 是否有效\n        if (!targetUrl) {\n            // 抛出包含更多上下文的错误\n            throw new Error(`无效的代理请求路径。无法从组合路径 \"${encodedUrlPath}\" 中提取有效的目标 URL。`);\n        }\n\n        console.info(`开始处理目标 URL 的代理请求: ${targetUrl}`);\n\n        // --- 获取并处理目标内容 ---\n        const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, req.headers);\n\n        // --- 如果是 M3U8，处理并返回 ---\n        if (isM3u8Content(content, contentType)) {\n            console.info(`正在处理 M3U8 内容: ${targetUrl}`);\n            const processedM3u8 = await processM3u8Content(targetUrl, content);\n\n            console.info(`成功处理 M3U8: ${targetUrl}`);\n            // 发送处理后的 M3U8 响应\n            res.status(200)\n                .setHeader('Content-Type', 'application/vnd.apple.mpegurl;charset=utf-8')\n                .setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`)\n                // 移除可能导致问题的原始响应头\n                .removeHeader('content-encoding') // 很重要！node-fetch 已解压\n                .removeHeader('content-length')   // 长度已改变\n                .send(processedM3u8); // 发送 M3U8 文本\n\n        } else {\n            // --- 如果不是 M3U8，直接返回原始内容 ---\n            console.info(`直接返回非 M3U8 内容: ${targetUrl}, 类型: ${contentType}`);\n\n            // 设置原始响应头，但排除有问题的头和 CORS 头（已设置）\n            responseHeaders.forEach((value, key) => {\n                 const lowerKey = key.toLowerCase();\n                 if (!lowerKey.startsWith('access-control-') &&\n                     lowerKey !== 'content-encoding' && // 很重要！\n                     lowerKey !== 'content-length') {   // 很重要！\n                     res.setHeader(key, value); // 设置其他原始头\n                 }\n             });\n            // 设置我们自己的缓存策略\n            res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`);\n\n            // 发送原始（已解压）内容\n            res.status(200).send(content);\n        }\n\n    // ---- 结束主处理逻辑的 try 块 ----\n    } catch (error) { // ---- 捕获处理过程中的任何错误 ----\n        // **检查这个错误是否是 \"Assignment to constant variable\"**\n        console.error(`[代理错误处理 V3] 捕获错误！目标: ${targetUrl || '解析失败'} | 错误类型: ${error.constructor.name} | 错误消息: ${error.message}`);\n        console.error(`[代理错误堆栈 V3] ${error.stack}`); // 记录完整的错误堆栈信息\n\n        // 特别标记 \"Assignment to constant variable\" 错误\n        if (error instanceof TypeError && error.message.includes(\"Assignment to constant variable\")) {\n             console.error(\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\");\n             console.error(\"捕获到 'Assignment to constant variable' 错误!\");\n             console.error(\"请再次检查函数代码及所有辅助函数中，是否有 const 声明的变量被重新赋值。\");\n             console.error(\"错误堆栈指向:\", error.stack);\n             console.error(\"!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!\");\n        }\n\n        // 尝试从错误对象获取状态码，否则默认为 500\n        const statusCode = error.status || 500;\n\n        // 确保在发送错误响应前没有发送过响应头\n        if (!res.headersSent) {\n             res.setHeader('Content-Type', 'application/json');\n             // CORS 头应该已经在前面设置好了\n             res.status(statusCode).json({\n                success: false,\n                error: `代理处理错误: ${error.message}`, // 返回错误消息给前端\n                targetUrl: targetUrl // 包含目标 URL 以便调试\n            });\n        } else {\n            // 如果响应头已发送，无法再发送 JSON 错误\n            console.error(\"[代理错误处理 V3] 响应头已发送，无法发送 JSON 错误响应。\");\n            // 尝试结束响应\n             if (!res.writableEnded) {\n                 res.end();\n             }\n        }\n    } finally {\n         // 记录请求处理结束\n         console.info('--- Vercel 代理请求结束 ---');\n    }\n}\n\n// --- [确保所有辅助函数定义都在这里] ---\n// getTargetUrlFromPath, getBaseUrl, resolveUrl, rewriteUrlToProxy, getRandomUserAgent,\n// fetchContentWithType, isM3u8Content, processKeyLine, processMapLine,\n// processMediaPlaylist, processM3u8Content, processMasterPlaylist\n"
  },
  {
    "path": "css/index.css",
    "content": "/* 主页特定样式 */\n\n/* 历史记录和设置按钮定位样式 */\n.top-corner-button {\n    position: fixed;\n    z-index: 10;\n    background: #222;\n    border: 1px solid #333;\n    border-radius: 0.5rem;\n    padding: 0.375rem 0.75rem;\n    transition: all 0.2s ease;\n}\n\n.top-corner-button:hover {\n    background: #333;\n    border-color: white;\n}\n\n/* 搜索区域样式 */\n.search-box {\n    height: 3.5rem;\n    box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);\n    border-radius: 0.5rem;\n    overflow: hidden;\n    display: flex;\n    align-items: stretch;\n}\n\n.search-button {\n    width: 5rem;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background: white;\n    color: black;\n    font-weight: 500;\n    transition: background-color 0.2s;\n}\n\n.search-button:hover {\n    background: #f0f0f0;\n}\n\n.search-input {\n    flex: 1;\n    background: #111;\n    border-top: 1px solid #333;\n    border-bottom: 1px solid #333;\n    color: white;\n    padding: 0 1.5rem;\n    font-size: 1rem;\n    outline: none;\n    transition: background-color 0.2s;\n}\n\n.search-input:focus {\n    background: #191919;\n}\n\n/* 最近搜索记录样式 */\n.recent-search-tag {\n    display: inline-block;\n    padding: 0.25rem 0.75rem;\n    margin: 0.25rem;\n    background: rgba(59, 130, 246, 0.1);\n    border: 1px solid rgba(59, 130, 246, 0.2);\n    border-radius: 0.5rem;\n    color: #e5e7eb;\n    font-size: 0.875rem;\n    transition: all 0.2s;\n}\n\n.recent-search-tag:hover {\n    background: rgba(59, 130, 246, 0.2);\n    border-color: rgba(59, 130, 246, 0.4);\n}\n\n/* 豆瓣区域样式 */\n.douban-container {\n    margin: 2rem auto;\n    max-width: 1280px;\n    padding: 0 0.5rem;\n}\n\n.douban-header {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    margin-bottom: 1rem;\n}\n\n.douban-toggle {\n    display: flex;\n    align-items: center;\n    background: #222;\n    border-radius: 9999px;\n    padding: 0.25rem;\n}\n\n.douban-toggle-button {\n    padding: 0.25rem 0.75rem;\n    font-size: 0.875rem;\n    border-radius: 9999px;\n}\n\n.douban-toggle-button.active {\n    background: #db2777;\n    color: white;\n}\n\n.douban-toggle-button:not(.active) {\n    color: #9ca3af;\n}\n\n.douban-toggle-button:not(.active):hover {\n    color: white;\n}\n\n.douban-refresh-button {\n    font-size: 0.875rem;\n    padding: 0.25rem 0.75rem;\n    background: #db2777;\n    color: white;\n    border-radius: 0.5rem;\n    display: flex;\n    align-items: center;\n    gap: 0.25rem;\n}\n\n.douban-refresh-button:hover {\n    background: #be185d;\n}\n\n.douban-tags-container {\n    overflow-x: auto;\n    padding-bottom: 0.5rem;\n}\n\n.douban-tags {\n    display: flex;\n    gap: 0.5rem;\n    min-width: max-content;\n}\n\n.douban-tag {\n    padding: 0.25rem 0.75rem;\n    background: rgba(219, 39, 119, 0.1);\n    border: 1px solid rgba(219, 39, 119, 0.2);\n    border-radius: 0.5rem;\n    color: #f9a8d4;\n    font-size: 0.875rem;\n    transition: all 0.2s;\n}\n\n.douban-tag:hover {\n    background: rgba(219, 39, 119, 0.2);\n    border-color: rgba(219, 39, 119, 0.4);\n}\n\n.douban-tag.active {\n    background: #db2777;\n    border-color: #db2777;\n    color: white;\n}\n\n/* 搜索结果样式 */\n.search-results-container {\n    width: 100%;\n    max-width: 1280px;\n    margin: 0 auto;\n    padding: 0 0.5rem;\n}\n\n.search-result-stats {\n    text-align: right;\n    font-size: 0.875rem;\n    color: #9ca3af;\n    margin-bottom: 1rem;\n}\n\n/* 响应式网格布局 */\n.search-results-grid {\n    display: grid;\n    gap: 1rem;\n}\n\n@media (max-width: 640px) {\n    .search-results-grid {\n        grid-template-columns: 1fr;\n    }\n}\n\n@media (min-width: 641px) and (max-width: 768px) {\n    .search-results-grid {\n        grid-template-columns: repeat(2, 1fr);\n    }\n}\n\n@media (min-width: 769px) and (max-width: 1024px) {\n    .search-results-grid {\n        grid-template-columns: repeat(3, 1fr);\n    }\n}\n\n@media (min-width: 1025px) {\n    .search-results-grid {\n        grid-template-columns: repeat(4, 1fr);\n    }\n}\n"
  },
  {
    "path": "css/modals.css",
    "content": "/* 模态框通用样式 */\n.modal-overlay {\n    position: fixed;\n    inset: 0;\n    background-color: rgba(0, 0, 0, 0.95);\n    display: none;\n    align-items: center;\n    justify-content: center;\n    z-index: 40;\n    transition: opacity 0.3s ease;\n}\n\n.modal-content {\n    background-color: #111;\n    padding: 2rem;\n    border-radius: 0.5rem;\n    border: 1px solid #333;\n    width: 91.666667%;\n    max-width: 56rem;\n    max-height: 90vh;\n    display: flex;\n    flex-direction: column;\n}\n\n.modal-header {\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    margin-bottom: 1.5rem;\n    flex: none;\n}\n\n.modal-title {\n    font-size: 1.5rem;\n    font-weight: 700;\n    background: linear-gradient(to right, #00ccff, #ff3c78);\n    -webkit-background-clip: text;\n    -webkit-text-fill-color: transparent;\n    word-break: break-word;\n    padding-right: 1rem;\n    max-width: 80%;\n}\n\n.modal-close {\n    color: #9ca3af;\n    font-size: 1.5rem;\n    transition: color 0.2s;\n    flex-shrink: 0;\n}\n\n.modal-close:hover {\n    color: white;\n}\n\n.modal-body {\n    overflow: auto;\n    flex: 1;\n    min-height: 0;\n}\n\n/* 密码验证模态框 */\n.password-modal {\n    z-index: 65;\n}\n\n.password-form {\n    margin-bottom: 1.5rem;\n}\n\n.password-input {\n    width: 100%;\n    background-color: #111;\n    border: 1px solid #333;\n    color: white;\n    padding: 1rem;\n    border-radius: 0.5rem;\n    margin-bottom: 1rem;\n}\n\n.password-input:focus {\n    outline: none;\n    border-color: white;\n}\n\n.password-submit {\n    width: 100%;\n    background-color: #3b82f6;\n    color: white;\n    padding: 0.5rem 1rem;\n    border-radius: 0.25rem;\n    font-weight: 500;\n}\n\n.password-submit:hover {\n    background-color: #2563eb;\n}\n\n.password-error {\n    color: #ef4444;\n    margin-top: 0.5rem;\n    display: none;\n}\n\n/* 声明模态框 */\n.disclaimer-modal {\n    z-index: 60;\n}\n\n.disclaimer-content {\n    color: #d1d5db;\n    line-height: 1.5;\n}\n\n.disclaimer-content p {\n    margin-bottom: 1rem;\n}\n\n.disclaimer-content strong {\n    color: #60a5fa;\n}\n\n.disclaimer-button {\n    margin-top: 1.5rem;\n    padding: 0.75rem 1.5rem;\n    background: linear-gradient(to right, #4f46e5, #8b5cf6, #ec4899);\n    color: white;\n    font-weight: 600;\n    border-radius: 0.5rem;\n    transition: all 0.3s;\n}\n\n.disclaimer-button:hover {\n    box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);\n    transform: translateY(-1px);\n}\n\n/* Toast 和 Loading 提示 */\n.toast {\n    position: fixed;\n    top: 1rem;\n    left: 50%;\n    transform: translateX(-50%) translateY(-100%);\n    background-color: #ef4444;\n    color: white;\n    padding: 0.75rem 1.5rem;\n    border-radius: 0.5rem;\n    box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.3);\n    transition: all 0.3s;\n    opacity: 0;\n    z-index: 50;\n}\n\n.toast.show {\n    transform: translateX(-50%) translateY(0);\n    opacity: 1;\n}\n\n.loading-overlay {\n    position: fixed;\n    inset: 0;\n    background-color: rgba(0, 0, 0, 0.8);\n    display: none;\n    align-items: center;\n    justify-content: center;\n    z-index: 50;\n}\n\n.loading-content {\n    background-color: #111;\n    padding: 2rem;\n    border-radius: 0.5rem;\n    border: 1px solid #333;\n    display: flex;\n    align-items: center;\n    gap: 1rem;\n}\n\n.loading-spinner {\n    width: 2rem;\n    height: 2rem;\n    border: 4px solid white;\n    border-top-color: transparent;\n    border-radius: 50%;\n    animation: spin 1s linear infinite;\n}\n\n@keyframes spin {\n    to { transform: rotate(360deg); }\n}\n\n.loading-text {\n    color: white;\n    font-size: 1.125rem;\n}\n\n/* 动画效果 */\n@keyframes fadeIn {\n    from { opacity: 0; }\n    to { opacity: 1; }\n}\n\n@keyframes fadeOut {\n    from { opacity: 1; }\n    to { opacity: 0; }\n}\n\n.modal-overlay.show {\n    animation: fadeIn 0.3s forwards;\n    display: flex;\n}\n\n.modal-overlay.hide {\n    animation: fadeOut 0.3s forwards;\n}\n\n/* 资源速率测试相关样式 */\n.speed-indicator {\n    display: inline-flex;\n    align-items: center;\n    gap: 2px;\n    font-size: 10px;\n    font-weight: 500;\n    line-height: 1;\n}\n\n.speed-indicator.good {\n    color: #10b981;\n}\n\n.speed-indicator.medium {\n    color: #f59e0b;\n}\n\n.speed-indicator.poor {\n    color: #ef4444;\n}\n\n.speed-indicator.error {\n    color: #ef4444;\n}\n\n/* 资源卡片悬停效果增强 */\n.resource-card {\n    transition: all 0.2s ease;\n}\n\n.resource-card:hover {\n    transform: translateY(-2px);\n    box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);\n}\n\n.resource-card.current {\n    border: 1px solid #3b82f6;\n    box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3);\n}\n\n/* 速率徽章样式 */\n.speed-badge {\n    backdrop-filter: blur(4px);\n    font-size: 9px;\n    font-weight: 600;\n    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);\n    border-radius: 4px;\n    padding: 2px 4px;\n}\n"
  },
  {
    "path": "css/player.css",
    "content": "body, html {\n    margin: 0;\n    padding: 0;\n    width: 100%;\n    height: 100%;\n    background-color: #0f1622;\n    color: white;\n    padding-top: 37px;\n}\n\n/* Critical header and navigation styles */\n.player-header {\n    position: relative;\n    z-index: 2147483647 !important;\n    pointer-events: auto !important;\n}\n\n.player-header-fixed {\n    position: fixed !important;\n    top: 0;\n    left: 0;\n    width: 100vw;\n    z-index: 9000 !important;\n    pointer-events: auto !important;\n    background: #111;\n}\n\n#homeButton { \n    pointer-events: auto !important; \n}\n\n.home-button {\n    background: none !important;\n    border: none !important;\n    padding: 0 !important;\n}\n\n/* Critical loading styles to prevent FOUC */\n.player-placeholder {\n    width: 100% !important;\n    height: auto !important;\n    aspect-ratio: 16/9 !important;\n    background-color: #1f2937 !important;\n    position: relative !important;\n    display: block !important;\n    border-radius: 8px !important;\n    overflow: hidden !important;\n}\n\n.player-loading-container {\n    width: 100% !important;\n    height: 0 !important;\n    padding-bottom: 56.25% !important;\n    position: relative !important;\n    background-color: #1f2937 !important;\n    border-radius: 8px !important;\n    overflow: hidden !important;\n    display: block !important;\n}\n\n.player-loading-overlay {\n    position: absolute !important;\n    top: 0 !important;\n    left: 0 !important;\n    width: 100% !important;\n    height: 100% !important;\n    display: flex !important;\n    flex-direction: column !important;\n    align-items: center !important;\n    justify-content: center !important;\n    background-color: rgba(17, 24, 39, 0.7) !important;\n}\n\n.player-loading-spinner {\n    width: 48px !important;\n    height: 48px !important;\n    border: 4px solid rgba(255, 255, 255, 0.1) !important;\n    border-radius: 50% !important;\n    border-top-color: #f97316 !important;\n    position: relative !important;\n    margin-bottom: 16px !important;\n    animation: spin 1s linear infinite !important;\n}\n\n/* Critical styles for loading text */\n.player-loading-text,\n.player-loading-overlay div:nth-child(2),\ndiv.player-loading-text {\n    display: block !important;\n    visibility: visible !important;\n    opacity: 1 !important;\n    color: #f9fafb !important;\n    font-size: 16px !important;\n    font-weight: 500 !important;\n    margin-bottom: 8px !important;\n    text-align: center !important;\n    font-family: system-ui, -apple-system, sans-serif !important;\n    line-height: 1.4 !important;\n    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;\n    pointer-events: none !important;\n    z-index: 100 !important;\n    background: transparent !important;\n    max-width: 90% !important;\n}\n\n@keyframes player-spinner-rotate {\n    to { transform: rotate(360deg); }\n}\n\n@keyframes spin {\n    0% { transform: rotate(0deg); }\n    100% { transform: rotate(360deg); }\n}\n\n/* ArtPlayer specific styles */\n.art-video-player, .art-video-player video {\n    width: 100% !important;\n    height: 100% !important;\n    min-height: 150px !important;\n    max-height: 100vh !important;\n    background: #000 !important;\n    object-fit: contain !important;\n    display: block !important;\n}\n\n/* Fix for Chrome-specific issues */\n@media screen and (-webkit-min-device-pixel-ratio: 0) {\n    .art-video-player video {\n        transform: translateZ(0) !important;\n        will-change: transform !important;\n    }\n\n    /* Force visibility of video element */\n    .art-video-player.art-playing video {\n        visibility: visible !important;\n        opacity: 1 !important;\n    }\n}\n\n.player-container {\n    width: 100%;\n    max-width: 1000px;\n    margin: 0 auto;\n}\n\n#player {\n    width: 100%;\n    height: 60vh; /* 视频播放器高度 */\n}\n\n.loading-container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    background-color: rgba(0, 0, 0, 0.7);\n    color: white;\n    z-index: 100;\n    flex-direction: column;\n}\n\n.loading-spinner {\n    width: 50px;\n    height: 50px;\n    border: 4px solid rgba(255, 255, 255, 0.3);\n    border-radius: 50%;\n    border-top-color: white;\n    animation: spin 1s ease-in-out infinite;\n    margin-bottom: 10px;\n}\n\n.error-container {\n    position: absolute;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    display: none;\n    align-items: center;\n    justify-content: center;\n    background-color: rgba(0, 0, 0, 0.7);\n    color: white;\n    z-index: 100;\n    flex-direction: column;\n    text-align: center;\n    padding: 1rem;\n}\n\n.error-icon {\n    font-size: 48px;\n    margin-bottom: 10px;\n}\n\n.error-message-sub {\n    margin-top: 10px;\n    font-size: 14px;\n    color: #aaa;\n}\n\n.episode-active {\n    background-color: #3b82f6 !important;\n    border-color: #60a5fa !important;\n}\n\n.episode-grid {\n    max-height: 30vh;\n    overflow-y: auto;\n}\n\n/* 恢复播放位置提示样式 */\n.position-restore-hint {\n    position: fixed;\n    bottom: 20px;\n    left: 50%;\n    transform: translateX(-50%) translateY(100%);\n    background-color: rgba(0, 0, 0, 0.8);\n    color: white;\n    padding: 10px 20px;\n    border-radius: 4px;\n    z-index: 1000;\n    transition: transform 0.3s ease;\n    font-size: 14px;\n}\n\n.position-restore-hint.show {\n    transform: translateX(-50%) translateY(0);\n}\n\n.hint-content {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n\n.switch {\n    position: relative;\n    display: inline-block;\n    width: 46px;\n    height: 24px;\n}\n\n.switch input {\n    opacity: 0;\n    width: 0;\n    height: 0;\n}\n\n.slider {\n    position: absolute;\n    cursor: pointer;\n    top: 0;\n    left: 0;\n    right: 0;\n    bottom: 0;\n    background-color: #333;\n    transition: .4s;\n    border-radius: 24px;\n}\n\n.slider:before {\n    position: absolute;\n    content: \"\";\n    height: 18px;\n    width: 18px;\n    left: 3px;\n    bottom: 3px;\n    background-color: white;\n    transition: .4s;\n    border-radius: 50%;\n}\n\ninput:checked + .slider {\n    background-color: #00ccff;\n}\n\ninput:checked + .slider:before {\n    transform: translateX(22px);\n}\n\n/* 添加快捷键提示样式 */\n.shortcut-hint {\n    position: fixed;\n    top: 50%;\n    left: 50%;\n    transform: translate(-50%, -50%);\n    background-color: rgba(0, 0, 0, 0.8);\n    color: white;\n    padding: 1rem 2rem;\n    border-radius: 0.5rem;\n    font-size: 1.5rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n    z-index: 1000;\n    opacity: 0;\n    transition: opacity 0.3s ease;\n}\n\n.shortcut-hint.show {\n    opacity: 1;\n}\n\n/* 原生全屏时，播放器容器铺满 */\n.player-container:-webkit-full-screen,\n.player-container:fullscreen {\n    position: fixed;\n    top: 0; left: 0;\n    width: 100vw; height: 100vh;\n    z-index: 10000;\n    background-color: #000;\n}\n\n.player-container:-webkit-full-screen #player,\n.player-container:fullscreen #player {\n    width: 100%; height: 100%;\n}\n\n/* 资源信息卡片区 */\n#resourceInfoBarContainer {\n    align-items: center;\n    border-radius: 0.5rem;\n    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);\n}\n\n.resource-info-bar-left {\n    align-items: center;\n    font-size: 1.1rem;\n    font-weight: bold;\n    color: #fff;\n    flex: 1;\n}\n\n.resource-info-bar-videos {\n    font-size: 1rem;\n    font-weight: normal;\n    margin-left: 10px;\n    color: #ccc;\n}\n\n.resource-switch-btn {\n    align-items: center;\n    background: none;\n    border: none;\n    color: #a67c2d;\n    font-weight: bold;\n    font-size: 1rem;\n    cursor: pointer;\n    gap: 6px;\n    padding: 6px 12px;\n    border-radius: 0.5rem;\n    transition: background 0.2s;\n}\n\n.resource-switch-btn:hover {\n    background: #f5e9d7;\n}\n\n.resource-switch-btn:active {\n    background: #f5e9d7;\n}\n\n.resource-switch-icon {\n    width: 20px;\n    height: 20px;\n    margin-right: 0;\n    color: #a67c2d;\n    vertical-align: middle;\n    transition: transform 0.3s;\n}\n\n/* 新增：移动端响应式样式 */\n@media (max-width: 640px) {\n    .episode-grid {\n        max-height: 40vh; /* 移动端增加集数列表高度 */\n    }\n\n    /* 改进移动端按钮显示 */\n    button {\n        white-space: nowrap;\n    }\n\n    /* 控制栏在小屏幕上可能需要换行 */\n    .player-container .flex-wrap {\n        margin-bottom: 4px;\n    }\n}\n\n/* 隐藏用户名输入框 */\n#username {\n    display: none !important;\n}\n"
  },
  {
    "path": "css/styles.css",
    "content": "/* \nLibreTV 全局样式\n包含多个页面共享的基础样式\n对于特定页面的样式，请参考:\n- index.css: 首页特定样式\n- player.css: 播放器页面特定样式\n- watch.css: 重定向页面特定样式\n- modals.css: 模态框和提示框样式\n*/\n\n.close-btn {\n    position: absolute;\n    top: 12px;\n    right: 12px;\n    background: #222;\n    border: 1px solid #333;\n    border-radius: 8px;\n    padding: 6px;\n    color: white;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    cursor: pointer;\n    transition: all 0.2s ease;\n    z-index: 10;\n}\n\n.close-btn:hover {\n    background: #333;\n    border-color: #555;\n}\n\n.close-btn svg {\n    width: 16px;\n    height: 16px;\n    stroke: currentColor;\n}\n\n:root {\n    /* 赛博影视主题配色方案 - 柔和版 */\n    --primary-color: #00ccff;       /* 霓虹蓝主色调 */\n    --primary-light: #33d6ff;       /* 浅霓虹蓝变体 */\n    --secondary-color: #0f1622;     /* 深蓝黑背景色 */\n    --accent-color: #ff3c78;        /* 霓虹粉强调色 */\n    --text-color: #e6f2ff;          /* 柔和的蓝白色文本 */\n    --text-muted: #8599b2;          /* 淡蓝灰色次级文本 */\n    --border-color: rgba(0, 204, 255, 0.15);\n    --page-gradient-start: #0f1622; /* 深蓝黑起始色 */\n    --page-gradient-end: #192231;   /* 深靛蓝结束色 */\n    --card-gradient-start: #121b29; /* 卡片起始色 */\n    --card-gradient-end: #1c2939;   /* 卡片结束色 */\n    --card-accent: rgba(0, 204, 255, 0.12); /* 霓虹蓝卡片强调色 */\n    --card-hover-border: rgba(0, 204, 255, 0.5); /* 悬停边框颜色 */\n}\n\n.page-bg {\n    background: linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end));\n    min-height: 100vh;\n    /* 柔和赛博点状背景 */\n    background-image: \n        linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)),\n        radial-gradient(circle at 25px 25px, rgba(0, 204, 255, 0.04) 2px, transparent 3px),\n        radial-gradient(circle at 75px 75px, rgba(255, 60, 120, 0.02) 1px, transparent 2px),\n        radial-gradient(circle at 50px 50px, rgba(150, 255, 250, 0.015) 1px, transparent 2px);\n    background-blend-mode: normal;\n    background-size: cover, 100px 100px, 50px 50px, 75px 75px;\n}\n\nbutton, .card-hover {\n    transition: all 0.3s ease;\n}\n\n/* 改进卡片适应不同内容长度 */\n.card-hover {\n    border: 1px solid var(--border-color);\n    background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);\n    position: relative;\n    overflow: hidden;\n    border-radius: 6px;\n    display: flex;\n    flex-direction: column;\n    height: 100%;\n}\n\n/* 确保卡片内容区域高度一致性 */\n.card-hover .flex-grow {\n    min-height: unset; /* 移除最小高度限制，让内容自然流动 */\n    display: flex;\n    flex-direction: column;\n}\n\n/* 针对不同长度的标题优化显示 */\n.card-hover h3 {\n    min-height: unset;\n    max-height: unset;\n    display: -webkit-box;\n    -webkit-box-orient: vertical;\n    overflow: hidden;\n    text-overflow: ellipsis;\n    -webkit-line-clamp: 2;\n    line-height: 1.2rem;\n    word-break: break-word; /* 允许在任何字符间断行 */\n    hyphens: auto; /* 允许断词 */\n}\n\n.card-hover::before {\n    content: \"\";\n    position: absolute;\n    top: 0;\n    left: -100%;\n    width: 100%;\n    height: 100%;\n    background: linear-gradient(90deg, transparent, var(--card-accent), transparent);\n    transition: left 0.6s ease;\n}\n\n.card-hover:hover {\n    border-color: var(--card-hover-border);\n    transform: translateY(-3px);\n    box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);\n}\n\n.card-hover:hover::before {\n    left: 100%;\n}\n\n.gradient-text {\n    background: linear-gradient(to right, var(--primary-color), var(--accent-color));\n    -webkit-background-clip: text;\n    -webkit-text-fill-color: transparent;\n}\n\n/* 改进设置面板样式 */\n.settings-panel {\n    scrollbar-width: thin;\n    scrollbar-color: #444 #222;\n    transform: translateX(100%);\n    transition: transform 0.3s ease;\n    background: linear-gradient(135deg, var(--page-gradient-end), var(--page-gradient-start));\n    border-left: 1px solid var(--primary-color);\n}\n\n.settings-panel.show {\n    transform: translateX(0);\n}\n\n.settings-panel::-webkit-scrollbar {\n    width: 6px;\n}\n\n.settings-panel::-webkit-scrollbar-track {\n    background: transparent;\n}\n\n.settings-panel::-webkit-scrollbar-thumb {\n    background-color: #444;\n    border-radius: 4px;\n}\n\n.search-button {\n    background: var(--primary-color);\n    color: var(--text-color);\n}\n\n.search-button:hover {\n    background: var(--primary-light);\n}\n\n::-webkit-scrollbar {\n    width: 8px;\n    height: 8px;\n}\n\n::-webkit-scrollbar-track {\n    background: #111;\n    border-radius: 4px;\n}\n\n::-webkit-scrollbar-thumb {\n    background: #333;\n    border-radius: 4px;\n    transition: all 0.3s ease;\n}\n\n::-webkit-scrollbar-thumb:hover {\n    background: #444;\n}\n\n* {\n    scrollbar-width: thin;\n    scrollbar-color: #333 #111;\n}\n\n.search-tag {\n    background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));\n    color: var(--text-color);\n    padding: 0.5rem 1rem;\n    border-radius: 0.5rem;\n    font-size: 0.875rem;\n    border: 1px solid var(--border-color);\n    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);\n}\n\n.search-tag:hover {\n    background: linear-gradient(135deg, var(--card-gradient-end), var(--card-gradient-start));\n    border-color: var(--primary-color);\n}\n\n.footer {\n    width: 100%;\n    transition: all 0.3s ease;\n    margin-top: auto;\n    background: linear-gradient(to bottom, transparent, var(--page-gradient-start));\n    border-top: 1px solid var(--border-color);\n}\n\n.footer a:hover {\n    text-decoration: underline;\n}\n\nbody {\n    display: flex;\n    flex-direction: column;\n    min-height: 100vh;\n}\n\n.container {\n    flex: 1;\n}\n\n@media screen and (min-height: 800px) {\n    body {\n        display: flex;\n        flex-direction: column;\n        min-height: 100vh;\n    }\n    \n    .container {\n        flex: 1;\n    }\n    \n    .footer {\n        margin-top: auto;\n    }\n}\n\n@media screen and (max-width: 640px) {\n    .footer {\n        padding-bottom: 2rem;\n    }\n}\n\n/* 移动端布局优化 */\n@media screen and (max-width: 768px) {\n    .card-hover h3 {\n        min-height: 2.5rem;\n    }\n    \n    .card-hover .flex-grow {\n        min-height: 80px;\n    }\n}\n\n@keyframes fadeIn {\n    from { opacity: 0; }\n    to { opacity: 1; }\n}\n\n@keyframes fadeOut {\n    from { opacity: 1; }\n    to { opacity: 0; }\n}\n\n#modal.show {\n    animation: fadeIn 0.3s forwards;\n}\n\n#modal.hide {\n    animation: fadeOut 0.3s forwards;\n}\n\n#modal > div {\n    background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));\n    border: 1px solid var(--primary-color);\n    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), 0 0 15px rgba(0, 204, 255, 0.1);\n    border-radius: 8px;\n}\n\n#episodesGrid button {\n    background: rgba(0, 204, 255, 0.08);\n    border: 1px solid rgba(0, 204, 255, 0.2);\n    transition: all 0.2s ease;\n}\n\n#episodesGrid button:hover {\n    background: rgba(0, 204, 255, 0.15);\n    border-color: var(--primary-color);\n    box-shadow: 0 0 8px rgba(0, 204, 255, 0.3);\n}\n\n#yellowFilterToggle:checked + .toggle-bg {\n    background-color: var(--primary-color);\n}\n\n#yellowFilterToggle:checked ~ .toggle-dot {\n    transform: translateX(1.5rem);\n}\n\n#yellowFilterToggle:focus + .toggle-bg,\n#yellowFilterToggle:hover + .toggle-bg {\n    box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3);\n}\n\n/* 添加广告过滤开关的CSS */\n#adFilterToggle:checked + .toggle-bg {\n    background-color: var(--primary-color);\n}\n\n#adFilterToggle:checked ~ .toggle-dot {\n    transform: translateX(1.5rem);\n}\n\n#adFilterToggle:focus + .toggle-bg,\n#adFilterToggle:hover + .toggle-bg {\n    box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3);\n}\n\n.toggle-dot {\n    transition: transform 0.3s ease-in-out;\n    box-shadow: 0 2px 4px rgba(0,0,0,0.2);\n}\n\n.toggle-bg {\n    transition: background-color 0.3s ease-in-out;\n}\n\n#yellowFilterToggle:checked ~ .toggle-dot {\n    box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3);\n}\n\n#adFilterToggle:checked ~ .toggle-dot {\n    box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3);\n}\n\n/* 添加API复选框样式 */\n.form-checkbox {\n    appearance: none;\n    -webkit-appearance: none;\n    -moz-appearance: none;\n    height: 14px;\n    width: 14px;\n    background-color: #222;\n    border: 1px solid #333;\n    border-radius: 3px;\n    cursor: pointer;\n    position: relative;\n    outline: none;\n}\n\n.form-checkbox:checked {\n    background-color: var(--primary-color);\n    border-color: var(--primary-color);\n}\n\n.form-checkbox:checked::after {\n    content: '';\n    position: absolute;\n    left: 4px;\n    top: 1px;\n    width: 4px;\n    height: 8px;\n    border: solid white;\n    border-width: 0 2px 2px 0;\n    transform: rotate(45deg);\n}\n\n/* API滚动区域美化 */\n#apiCheckboxes {\n    scrollbar-width: thin;\n    scrollbar-color: #444 #222;\n}\n\n#apiCheckboxes::-webkit-scrollbar {\n    width: 6px;\n}\n\n#apiCheckboxes::-webkit-scrollbar-track {\n    background: #222;\n    border-radius: 4px;\n}\n\n#apiCheckboxes::-webkit-scrollbar-thumb {\n    background-color: #444;\n    border-radius: 4px;\n}\n\n/* 自定义API列表样式 */\n#customApisList {\n    scrollbar-width: thin;\n    scrollbar-color: #444 #222;\n}\n\n#customApisList::-webkit-scrollbar {\n    width: 6px;\n}\n\n#customApisList::-webkit-scrollbar-track {\n    background: transparent;\n}\n\n#customApisList::-webkit-scrollbar-thumb {\n    background-color: #444;\n    border-radius: 4px;\n}\n\n/* 设置面板滚动样式 */\n.settings-panel {\n    scrollbar-width: thin;\n    scrollbar-color: #444 #222;\n}\n\n.settings-panel::-webkit-scrollbar {\n    width: 6px;\n}\n\n.settings-panel::-webkit-scrollbar-track {\n    background: transparent;\n}\n\n.settings-panel::-webkit-scrollbar-thumb {\n    background-color: #444;\n    border-radius: 4px;\n}\n\n/* 添加自定义API表单动画 */\n#addCustomApiForm {\n    transition: all 0.3s ease;\n    max-height: 0;\n    opacity: 0;\n    overflow: hidden;\n}\n\n#addCustomApiForm.hidden {\n    max-height: 0;\n    padding: 0;\n    opacity: 0;\n}\n\n#addCustomApiForm:not(.hidden) {\n    max-height: 230px;\n    opacity: 1;\n}\n\n/* 成人内容API标记样式 */\n.api-adult + label {\n    color: #ff6b8b !important;\n}\n\n/* 添加警告图标和标签样式 */\n.adult-warning {\n    display: inline-flex;\n    align-items: center;\n    margin-left: 0.25rem;\n    color: #ff6b8b;\n}\n\n.adult-warning svg {\n    width: 12px;\n    height: 12px;\n    margin-right: 4px;\n}\n\n/* 过滤器禁用样式 */\n.filter-disabled {\n    opacity: 0.5;\n    pointer-events: none;\n    cursor: not-allowed;\n}\n\n/* API组标题样式 */\n.api-group-title {\n    grid-column: span 2;\n    padding: 0.25rem 0;\n    margin-top: 0.5rem;\n    border-top: 1px solid #333;\n    color: #8599b2;\n    font-size: 0.75rem;\n    text-transform: uppercase;\n    letter-spacing: 0.05em;\n}\n\n.api-group-title.adult {\n    color: #ff6b8b;\n}\n\n/* 过滤器禁用样式 - 改进版本 */\n.filter-disabled {\n    position: relative;\n}\n\n.filter-disabled::after {\n    content: '';\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    background-color: rgba(0,0,0,0.4);\n    border-radius: 0.5rem;\n    z-index: 5;\n}\n\n.filter-disabled > * {\n    opacity: 0.7;\n}\n\n.filter-disabled .toggle-bg {\n    background-color: #333 !important;\n}\n\n.filter-disabled .toggle-dot {\n    transform: translateX(0) !important;\n    background-color: #666 !important;\n}\n\n/* 改进过滤器禁用样式 */\n.filter-disabled .filter-description {\n    color: #ff6b8b !important;\n    font-style: italic;\n    font-weight: 500;\n}\n\n/* 修改过滤器禁用样式，确保文字清晰可见 */\n.filter-disabled {\n    position: relative;\n}\n\n.filter-disabled::after {\n    content: '';\n    position: absolute;\n    top: 0;\n    left: 0;\n    width: 100%;\n    height: 100%;\n    background-color: rgba(0,0,0,0.3);\n    border-radius: 0.5rem;\n    z-index: 5;\n}\n\n.filter-disabled > * {\n    opacity: 1; /* 提高子元素不透明度，保证可见性 */\n    z-index: 6; /* 确保内容在遮罩上方 */\n}\n\n/* 改进过滤器禁用状态下的描述样式 */\n.filter-disabled .filter-description {\n    color: #ff7b9d !important; /* 更亮的粉色 */\n    font-style: italic;\n    font-weight: 500;\n    text-shadow: 0 0 2px rgba(0,0,0,0.8); /* 添加文字阴影提高对比度 */\n}\n\n/* 开关的禁用样式 */\n.filter-disabled .toggle-bg {\n    background-color: #444 !important;\n    opacity: 0.8;\n}\n\n.filter-disabled .toggle-dot {\n    transform: translateX(0) ;\n    background-color: #777 ;\n    opacity: 0.9;\n}\n\n/* 警告提示样式改进 */\n.filter-tooltip {\n    background-color: rgba(255, 61, 87, 0.1);\n    border: 1px solid rgba(255, 61, 87, 0.2);\n    border-radius: 0.25rem;\n    padding: 0.5rem;\n    margin-top: 0.5rem;\n    display: flex;\n    align-items: center;\n    font-size: 0.75rem;\n    line-height: 1.25;\n    position: relative;\n    z-index: 10;\n}\n\n.filter-tooltip svg {\n    flex-shrink: 0;\n    width: 14px;\n    height: 14px;\n    margin-right: 0.35rem;\n}\n\n/* 编辑按钮样式 */\n.custom-api-edit {\n    color: #3b82f6;\n    transition: color 0.2s ease;\n}\n\n.custom-api-edit:hover {\n    color: #2563eb;\n}\n\n/* 自定义API条目样式改进 */\n#customApisList .api-item {\n    display: flex;\n    align-items: center;\n    justify-content: space-between;\n    padding: 0.25rem 0.5rem;\n    margin-bottom: 0.25rem;\n    background-color: #222;\n    border-radius: 0.25rem;\n    transition: background-color 0.2s ease;\n}\n\n#customApisList .api-item:hover {\n    background-color: #2a2a2a;\n}\n\n/* 成人内容标签样式 */\n.adult-tag {\n    display: inline-flex;\n    align-items: center;\n    color: #ff6b8b;\n    font-size: 0.7rem;\n    font-weight: 500;\n    margin-right: 0.35rem;\n}\n\n/* 历史记录面板样式 */\n.history-panel {\n    box-shadow: 2px 0 10px rgba(0,0,0,0.5);\n    transition: transform 0.3s ease-in-out;\n    overflow-y: scroll; /* 始终显示滚动条，防止宽度变化 */\n    overflow-x: hidden; /* 防止水平滚动 */\n    width: 320px; /* 固定宽度 */\n    box-sizing: border-box; /* 确保padding不影响总宽度 */\n    scrollbar-gutter: stable; /* 现代浏览器：为滚动条预留空间 */\n}\n\n.history-panel.show {\n    transform: translateX(0);\n}\n\n#historyList {\n    padding-right: 6px; /* 为滚动条预留空间，确保内容不被挤压 */\n}\n\n/* 历史记录项样式优化 */\n.history-item {\n    background: #1a1a1a;\n    border-radius: 6px; /* 减小圆角 */\n    border: 1px solid #333;\n    overflow: hidden;\n    transition: all 0.2s ease;\n    padding: 10px 14px;\n    position: relative;\n    margin-bottom: 8px; /* 减小底部间距 */\n    width: 100%; /* 确保宽度一致 */\n}\n\n.history-item:hover {\n    transform: translateY(-2px);\n    border-color: #444;\n    box-shadow: 0 4px 8px rgba(0,0,0,0.2);\n}\n\n/* 添加组悬停效果，使删除按钮在悬停时显示 */\n.history-item .delete-btn {\n    opacity: 0;\n    transition: opacity 0.2s ease;\n}\n\n.history-item:hover .delete-btn {\n    opacity: 1;\n}\n\n.history-info {\n    padding: 0; /* 移除额外的内边距 */\n    min-height: 70px;\n}\n\n.history-title {\n    font-weight: 500;\n    font-size: 0.95rem; /* 减小字体大小 */\n    margin-bottom: 2px; /* 减小底部边距 */\n    overflow: hidden;\n    text-overflow: ellipsis;\n    white-space: nowrap;\n}\n\n.history-meta {\n    color: #bbb;\n    font-size: 0.75rem; /* 减小字体大小 */\n    display: flex;\n    flex-wrap: wrap;\n    margin-bottom: 1px; /* 减小边距 */\n}\n\n.history-episode {\n    color: #3b82f6;\n}\n\n.history-source {\n    color: #10b981;\n}\n\n.history-time {\n    color: #888;\n    font-size: 0.7rem; /* 减小字体大小 */\n    margin-top: 1px; /* 减小顶部边距 */\n}\n\n.history-separator {\n    color: #666;\n}\n\n.history-thumbnail {\n    width: 100%;\n    height: 90px;\n    background-color: #222;\n    overflow: hidden;\n}\n\n.history-thumbnail img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n\n.history-info {\n    padding: 10px;\n}\n\n.history-time {\n    color: #888;\n    font-size: 0.8rem;\n    margin-top: 4px;\n}\n\n.history-title {\n    font-weight: 500;\n    white-space: nowrap;\n    overflow: hidden;\n    text-overflow: ellipsis;\n}\n\n/* 添加播放进度条样式 */\n.history-progress {\n    margin: 5px 0;\n}\n\n.progress-bar {\n    height: 3px;\n    background-color: rgba(255, 255, 255, 0.1);\n    border-radius: 2px;\n    overflow: hidden;\n    margin-bottom: 2px;\n}\n\n.progress-filled {\n    height: 100%;\n    background: linear-gradient(to right, #00ccff, #3b82f6);\n    border-radius: 2px;\n}\n\n.progress-text {\n    font-size: 10px;\n    color: #888;\n    text-align: right;\n}\n\n/* 添加恢复播放提示样式 */\n.position-restore-hint {\n    position: absolute;\n    bottom: 60px;\n    left: 50%;\n    transform: translateX(-50%) translateY(20px);\n    background-color: rgba(0, 0, 0, 0.7);\n    color: white;\n    padding: 8px 16px;\n    border-radius: 4px;\n    font-size: 14px;\n    z-index: 100;\n    opacity: 0;\n    transition: all 0.3s ease;\n}\n\n.position-restore-hint.show {\n    opacity: 1;\n    transform: translateX(-50%) translateY(0);\n}\n\n/* 锁定控制时屏蔽交互 */\n.player-container.controls-locked .dplayer-controller,\n.player-container.controls-locked .dplayer-mask,\n.player-container.controls-locked .dplayer-bar-wrap,\n.player-container.controls-locked .dplayer-statusbar,\n.player-container.controls-locked .shortcut-hint {\n    opacity: 0 !important;\n    pointer-events: none !important;\n}\n/* 保留锁按钮可见可点 */\n.player-container.controls-locked #lockToggle {\n    opacity: 1 !important;\n    pointer-events: auto !important;\n}\n\n/* 播放器顶部header移动端优化 */\n.player-header {\n    gap: 0.5rem;\n}\n.custom-title-scroll {\n    overflow-x: auto;\n    white-space: nowrap;\n    text-overflow: ellipsis;\n    -webkit-overflow-scrolling: touch;\n    scrollbar-width: none;\n}\n.custom-title-scroll::-webkit-scrollbar {\n    display: none;\n}\n.logo-text {\n    display: inline;\n}\n.home-btn-text {\n    display: inline;\n}\n@media (max-width: 640px) {\n    .logo-text {\n        display: none;\n    }\n    .home-btn-text {\n        display: none;\n    }\n    .logo-icon {\n        margin-right: 0;\n    }\n    .home-btn svg {\n        margin-right: 0;\n    }\n    .player-header {\n        padding-left: 2px !important;\n        padding-right: 2px !important;\n    }\n    .custom-title-scroll {\n        font-size: 1rem;\n    }\n}\n\n/* 搜索结果卡片优化：横向布局 */\n.search-card-img-container {\n    width: 100px;  /* 增加宽度，从80px到100px */\n    height: 150px; /* 增加高度，从120px到150px */\n    overflow: hidden;\n    background-color: #191919;\n}\n\n/* 确保图片不会被拉伸，并且能够正确显示 */\n.search-card-img-container img {\n    width: 100%;\n    height: 100%;\n    object-fit: cover;\n}\n\n/* 针对搜索结果卡片修改网格布局以适应横向卡片 */\n@media (max-width: 640px) {\n    #results {\n        grid-template-columns: repeat(1, minmax(0, 1fr)) !important;\n    }\n}\n\n/* 响应式调整：在小屏幕上依然保持较好的视觉效果 */\n@media (min-width: 641px) and (max-width: 768px) {\n    #results {\n        grid-template-columns: repeat(2, minmax(0, 1fr)) !important;\n    }\n}\n\n/* 调整网格布局，减少每行卡片数量以适应更大尺寸的卡片 */\n@media (min-width: 769px) and (max-width: 1024px) {\n    #results {\n        grid-template-columns: repeat(3, minmax(0, 1fr)) !important;\n    }\n}\n\n@media (min-width: 1025px) {\n    #results {\n        grid-template-columns: repeat(4, minmax(0, 1fr)) !important;\n    }\n}\n\n/* 优化卡片内元素间距 */\n.card-hover .p-2 {\n    padding: 0.75rem; /* 增加内边距 */\n}\n\n/* 增加卡片内字体大小 */\n.card-hover h3 {\n    font-size: 0.95rem; /* 增加标题字体大小 */\n    line-height: 1.3rem;\n    margin-bottom: 0.5rem;\n}\n\n.card-hover p {\n    font-size: 0.8rem; /* 增加描述字体大小 */\n}\n\n/* 优化卡片内元素间距 */\n.card-hover .p-2 {\n    padding: 0.5rem;\n}\n\n/* 确保Toast显示在顶层并有适当的转换效果 */\n#toast {\n    z-index: 9999; /* 确保显示在最上层 */\n    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);\n    min-width: 200px;\n    text-align: center;\n    pointer-events: none; /* 防止toast阻挡点击事件 */\n    transform: translateX(-50%) translateY(0);\n    transition: opacity 0.3s ease, transform 0.3s ease;\n}\n\n#toast.hidden {\n    opacity: 0;\n    transform: translateX(-50%) translateY(-100%);\n}\n\n/* 详情模态框样式优化 */\n#modal .modal-detail-info {\n    background: linear-gradient(135deg, #0a0a0a, #111);\n    border: 1px solid #222;\n    border-radius: 8px;\n    padding: 1rem;\n    margin-bottom: 1.5rem;\n}\n\n#modal .detail-grid {\n    display: grid;\n    grid-template-columns: 1fr;\n    gap: 0.75rem;\n    font-size: 0.875rem;\n}\n\n@media (min-width: 768px) {\n    #modal .detail-grid {\n        grid-template-columns: 1fr 1fr;\n    }\n}\n\n#modal .detail-item {\n    display: flex;\n    flex-wrap: wrap;\n    gap: 0.5rem;\n}\n\n#modal .detail-label {\n    color: #9ca3af;\n    font-weight: 500;\n    min-width: 3rem;\n    flex-shrink: 0;\n}\n\n#modal .detail-value {\n    color: white;\n    flex: 1;\n    word-break: break-word;\n}\n\n#modal .detail-desc {\n    margin-top: 1rem;\n    padding-top: 1rem;\n    border-top: 1px solid #333;\n}\n\n#modal .detail-desc-content {\n    color: #d1d5db;\n    font-size: 0.875rem;\n    line-height: 1.6;\n    max-height: 8rem;\n    overflow-y: auto;\n    scrollbar-width: thin;\n    scrollbar-color: #444 #222;\n}\n\n#modal .detail-desc-content::-webkit-scrollbar {\n    width: 6px;\n}\n\n#modal .detail-desc-content::-webkit-scrollbar-track {\n    background: #222;\n    border-radius: 4px;\n}\n\n#modal .detail-desc-content::-webkit-scrollbar-thumb {\n    background-color: #444;\n    border-radius: 4px;\n}\n\n/* 集数统计信息样式 */\n#modal .episode-stats {\n    color: #9ca3af;\n    font-size: 0.875rem;\n    display: flex;\n    align-items: center;\n    gap: 0.5rem;\n}\n\n/* 移动端优化 */\n@media (max-width: 640px) {\n    #modal .detail-grid {\n        gap: 0.5rem;\n        font-size: 0.8rem;\n    }\n    \n    #modal .detail-label {\n        min-width: 2.5rem;\n    }\n    \n    #modal .detail-desc-content {\n        max-height: 6rem;\n        font-size: 0.8rem;\n    }\n}\n"
  },
  {
    "path": "css/watch.css",
    "content": "/* 添加重定向页面的基本样式 */\nbody {\n    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n    background-color: #0f1622;\n    color: white;\n    margin: 0;\n    padding: 0;\n    height: 100vh;\n    display: flex;\n    align-items: center;\n    justify-content: center;\n}\n\n.redirect-container {\n    text-align: center;\n    max-width: 90%;\n    width: 380px;\n    padding: 2rem;\n    background-color: rgba(0, 0, 0, 0.3);\n    border-radius: 16px;\n    backdrop-filter: blur(10px);\n    -webkit-backdrop-filter: blur(10px);\n    box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);\n    border: 1px solid rgba(255, 255, 255, 0.1);\n}\n\n.logo-container {\n    display: flex;\n    align-items: center;\n    justify-content: center;\n    margin-bottom: 1.5rem;\n}\n\n.logo-icon {\n    width: 40px;\n    height: 40px;\n    color: #00ccff;\n    margin-right: 10px;\n}\n\n.logo-text {\n    font-size: 2rem;\n    margin: 0;\n    background: linear-gradient(to right, #00ccff, #ff3c78);\n    -webkit-background-clip: text;\n    -webkit-text-fill-color: transparent;\n    background-clip: text;\n}\n\n.loading-animation {\n    display: inline-block;\n    width: 50px;\n    height: 50px;\n    border: 3px solid rgba(255, 255, 255, 0.2);\n    border-radius: 50%;\n    border-top-color: #00ccff;\n    animation: spin 1s linear infinite;\n    margin-bottom: 20px;\n}\n\n@keyframes spin {\n    to { transform: rotate(360deg); }\n}\n\n.redirect-message {\n    font-size: 1.2rem;\n    margin-bottom: 10px;\n    font-weight: 500;\n}\n\n#redirect-status {\n    font-size: 0.9rem;\n    color: #8599b2;\n    margin-bottom: 1.5rem;\n    height: 20px;\n}\n\n.redirect-hint {\n    font-size: 0.9rem;\n    color: #8599b2;\n    margin-top: 20px;\n}\n\n.redirect-hint a {\n    color: #00ccff;\n    text-decoration: none;\n    font-weight: 500;\n    transition: all 0.2s ease;\n    padding: 5px 10px;\n    border-radius: 4px;\n    background-color: rgba(0, 204, 255, 0.1);\n}\n\n.redirect-hint a:hover {\n    background-color: rgba(0, 204, 255, 0.2);\n    text-decoration: underline;\n}\n\n/* 移动端优化 */\n@media (max-width: 480px) {\n    .redirect-container {\n        padding: 1.5rem;\n        width: 85%;\n    }\n\n    .logo-icon {\n        width: 30px;\n        height: 30px;\n    }\n\n    .logo-text {\n        font-size: 1.7rem;\n    }\n\n    .loading-animation {\n        width: 40px;\n        height: 40px;\n        margin-bottom: 15px;\n    }\n\n    .redirect-message {\n        font-size: 1rem;\n    }\n\n    #redirect-status {\n        font-size: 0.8rem;\n    }\n}\n"
  },
  {
    "path": "docker-compose.yml",
    "content": "services:\n  libretv:\n    image: bestzwei/libretv:latest\n    container_name: libretv\n    ports:\n      - \"8899:8080\" # 将内部 8080 端口映射到主机的 8899 端口\n    environment:\n      - PASSWORD=${PASSWORD:-your_password} # 可将 your_password 修改为你想要的密码，默认为 your_password\n#    volumes:\n#      - libretv_data:/app # 不要修改\n    restart: unless-stopped\n\n#volumes:\n#  libretv_data:\n#    driver: local\n#    driver_opts:\n#      type: none\n#      o: bind\n#      device: ${PWD:-.}/data # 可将 ${PWD:-.} 修改为你想要的路径，默认为当前目录下的 data 文件夹\n"
  },
  {
    "path": "functions/_middleware.js",
    "content": "import { sha256 } from '../js/sha256.js';\n\nexport async function onRequest(context) {\n  const { request, env, next } = context;\n  const response = await next();\n  const contentType = response.headers.get(\"content-type\") || \"\";\n  \n  if (contentType.includes(\"text/html\")) {\n    let html = await response.text();\n    \n    // 处理普通密码\n    const password = env.PASSWORD || \"\";\n    let passwordHash = \"\";\n    if (password) {\n      passwordHash = await sha256(password);\n    }\n    html = html.replace('window.__ENV__.PASSWORD = \"{{PASSWORD}}\";', \n      `window.__ENV__.PASSWORD = \"${passwordHash}\";`);\n    \n    return new Response(html, {\n      headers: response.headers,\n      status: response.status,\n      statusText: response.statusText,\n    });\n  }\n  \n  return response;\n}"
  },
  {
    "path": "functions/proxy/[[path]].js",
    "content": "// functions/proxy/[[path]].js\n\n// --- 配置 (现在从 Cloudflare 环境变量读取) ---\n// 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量:\n// CACHE_TTL (例如 86400)\n// MAX_RECURSION (例如 5)\n// FILTER_DISCONTINUITY (不再需要，设为 false 或移除)\n// USER_AGENTS_JSON (例如 [\"UA1\", \"UA2\"]) - JSON 字符串数组\n// DEBUG (例如 false 或 true)\n// PASSWORD (例如 \"your_password\") - 鉴权密码\n// --- 配置结束 ---\n\n// --- 常量 (之前在 config.js 中，现在移到这里，因为它们与代理逻辑相关) ---\nconst MEDIA_FILE_EXTENSIONS = [\n    '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts',\n    '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus',\n    '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic'\n];\nconst MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/'];\n// --- 常量结束 ---\n\n\n/**\n * 主要的 Pages Function 处理函数\n * 拦截发往 /proxy/* 的请求\n */\nexport async function onRequest(context) {\n    const { request, env, next, waitUntil } = context; // next 和 waitUntil 可能需要\n    const url = new URL(request.url);\n\n    // 验证鉴权（主函数调用）\n    const isValidAuth = await validateAuth(request, env);\n    if (!isValidAuth) {\n        return new Response(JSON.stringify({\n            success: false,\n            error: '代理访问未授权：请检查密码配置或鉴权参数'\n        }), { \n            status: 401,\n            headers: {\n                'Access-Control-Allow-Origin': '*',\n                'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',\n                'Access-Control-Allow-Headers': '*',\n                'Content-Type': 'application/json'\n            }\n        });\n    }\n\n    // --- 从环境变量读取配置 ---\n    const DEBUG_ENABLED = (env.DEBUG === 'true');\n    const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); // 默认 24 小时\n    const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); // 默认 5 层\n    // 广告过滤已移至播放器处理，代理不再执行\n    let USER_AGENTS = [ // 提供一个基础的默认值\n      '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    try {\n        // 尝试从环境变量解析 USER_AGENTS_JSON\n        const agentsJson = env.USER_AGENTS_JSON;\n        if (agentsJson) {\n            const parsedAgents = JSON.parse(agentsJson);\n            if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {\n                USER_AGENTS = parsedAgents;\n            } else {\n                 logDebug(\"环境变量 USER_AGENTS_JSON 格式无效或为空，使用默认值\");\n            }\n        }\n    } catch (e) {\n        logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message}，使用默认值`);\n    }\n    // --- 配置读取结束 ---\n\n\n    // --- 辅助函数 ---\n\n    // 验证代理请求的鉴权\n    async function validateAuth(request, env) {\n        const url = new URL(request.url);\n        const authHash = url.searchParams.get('auth');\n        const timestamp = url.searchParams.get('t');\n        \n        // 获取服务器端密码\n        const serverPassword = env.PASSWORD;\n        if (!serverPassword) {\n            console.error('服务器未设置 PASSWORD 环境变量，代理访问被拒绝');\n            return false;\n        }\n        \n        // 使用 SHA-256 哈希算法（与其他平台保持一致）\n        // 在 Cloudflare Workers 中使用 crypto.subtle\n        try {\n            const encoder = new TextEncoder();\n            const data = encoder.encode(serverPassword);\n            const hashBuffer = await crypto.subtle.digest('SHA-256', data);\n            const hashArray = Array.from(new Uint8Array(hashBuffer));\n            const serverPasswordHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n            \n            if (!authHash || authHash !== serverPasswordHash) {\n                console.warn('代理请求鉴权失败：密码哈希不匹配');\n                return false;\n            }\n        } catch (error) {\n            console.error('计算密码哈希失败:', error);\n            return false;\n        }\n        \n        // 验证时间戳（10分钟有效期）\n        if (timestamp) {\n            const now = Date.now();\n            const maxAge = 10 * 60 * 1000; // 10分钟\n            if (now - parseInt(timestamp) > maxAge) {\n                console.warn('代理请求鉴权失败：时间戳过期');\n                return false;\n            }\n        }\n        \n        return true;\n    }\n\n    // 验证鉴权（主函数调用）\n    if (!validateAuth(request, env)) {\n        return new Response('Unauthorized', { \n            status: 401,\n            headers: {\n                'Access-Control-Allow-Origin': '*',\n                'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',\n                'Access-Control-Allow-Headers': '*'\n            }\n        });\n    }\n\n    // 输出调试日志 (需要设置 DEBUG: true 环境变量)\n    function logDebug(message) {\n        if (DEBUG_ENABLED) {\n            console.log(`[Proxy Func] ${message}`);\n        }\n    }\n\n    // 从请求路径中提取目标 URL\n    function getTargetUrlFromPath(pathname) {\n        // 路径格式: /proxy/经过编码的URL\n        // 例如: /proxy/https%3A%2F%2Fexample.com%2Fplaylist.m3u8\n        const encodedUrl = pathname.replace(/^\\/proxy\\//, '');\n        if (!encodedUrl) return null;\n        try {\n            // 解码\n            let decodedUrl = decodeURIComponent(encodedUrl);\n\n             // 简单检查解码后是否是有效的 http/https URL\n             if (!decodedUrl.match(/^https?:\\/\\//i)) {\n                 // 也许原始路径就没有编码？如果看起来像URL就直接用\n                 if (encodedUrl.match(/^https?:\\/\\//i)) {\n                     decodedUrl = encodedUrl;\n                     logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`);\n                 } else {\n                    logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`);\n                    return null;\n                 }\n             }\n             return decodedUrl;\n\n        } catch (e) {\n            logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`);\n            return null;\n        }\n    }\n\n    // 创建标准化的响应\n    function createResponse(body, status = 200, headers = {}) {\n        const responseHeaders = new Headers(headers);\n        // 关键：添加 CORS 跨域头，允许前端 JS 访问代理后的响应\n        responseHeaders.set(\"Access-Control-Allow-Origin\", \"*\"); // 允许任何来源访问\n        responseHeaders.set(\"Access-Control-Allow-Methods\", \"GET, HEAD, POST, OPTIONS\"); // 允许的方法\n        responseHeaders.set(\"Access-Control-Allow-Headers\", \"*\"); // 允许所有请求头\n\n        // 处理 CORS 预检请求 (OPTIONS) - 放在这里确保所有响应都处理\n         if (request.method === \"OPTIONS\") {\n             // 使用下面的 onOptions 函数可以更规范，但在这里处理也可以\n             return new Response(null, {\n                 status: 204, // No Content\n                 headers: responseHeaders // 包含上面设置的 CORS 头\n             });\n         }\n\n        return new Response(body, { status, headers: responseHeaders });\n    }\n\n    // 创建 M3U8 类型的响应\n    function createM3u8Response(content) {\n        return createResponse(content, 200, {\n            \"Content-Type\": \"application/vnd.apple.mpegurl\", // M3U8 的标准 MIME 类型\n            \"Cache-Control\": `public, max-age=${CACHE_TTL}` // 允许浏览器和CDN缓存\n        });\n    }\n\n    // 获取随机 User-Agent\n    function getRandomUserAgent() {\n        return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];\n    }\n\n    // 获取 URL 的基础路径 (用于解析相对路径)\n    function getBaseUrl(urlStr) {\n        try {\n            const parsedUrl = new URL(urlStr);\n            // 如果路径是根目录，或者没有斜杠，直接返回 origin + /\n            if (!parsedUrl.pathname || parsedUrl.pathname === '/') {\n                return `${parsedUrl.origin}/`;\n            }\n            const pathParts = parsedUrl.pathname.split('/');\n            pathParts.pop(); // 移除文件名或最后一个路径段\n            return `${parsedUrl.origin}${pathParts.join('/')}/`;\n        } catch (e) {\n            logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`);\n            // 备用方法：找到最后一个斜杠\n            const lastSlashIndex = urlStr.lastIndexOf('/');\n            // 确保不是协议部分的斜杠 (http://)\n            return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/';\n        }\n    }\n\n\n    // 将相对 URL 转换为绝对 URL\n    function resolveUrl(baseUrl, relativeUrl) {\n        // 如果已经是绝对 URL，直接返回\n        if (relativeUrl.match(/^https?:\\/\\//i)) {\n            return relativeUrl;\n        }\n        try {\n            // 使用 URL 对象来处理相对路径\n            return new URL(relativeUrl, baseUrl).toString();\n        } catch (e) {\n            logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`);\n            // 简单的备用方法\n            if (relativeUrl.startsWith('/')) {\n                // 处理根路径相对 URL\n                const urlObj = new URL(baseUrl);\n                return `${urlObj.origin}${relativeUrl}`;\n            }\n            // 处理同级目录相对 URL\n            return `${baseUrl.replace(/\\/[^/]*$/, '/')}${relativeUrl}`; // 确保baseUrl以 / 结尾\n        }\n    }\n\n    // 将目标 URL 重写为内部代理路径 (/proxy/...)\n    function rewriteUrlToProxy(targetUrl) {\n        // 确保目标URL被正确编码，以便作为路径的一部分\n        return `/proxy/${encodeURIComponent(targetUrl)}`;\n    }\n\n    // 获取远程内容及其类型\n    async function fetchContentWithType(targetUrl) {\n        const headers = new Headers({\n            'User-Agent': getRandomUserAgent(),\n            'Accept': '*/*',\n            // 尝试传递一些原始请求的头信息\n            'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8',\n            // 尝试设置 Referer 为目标网站的域名，或者传递原始 Referer\n            'Referer': request.headers.get('Referer') || new URL(targetUrl).origin\n        });\n\n        try {\n            // 直接请求目标 URL\n            logDebug(`开始直接请求: ${targetUrl}`);\n            // Cloudflare Functions 的 fetch 默认支持重定向\n            const response = await fetch(targetUrl, { headers, redirect: 'follow' });\n\n            if (!response.ok) {\n                 const errorBody = await response.text().catch(() => '');\n                 logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);\n                 throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`);\n            }\n\n            // 读取响应内容为文本\n            const content = await response.text();\n            const contentType = response.headers.get('Content-Type') || '';\n            logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);\n            return { content, contentType, responseHeaders: response.headers }; // 同时返回原始响应头\n\n        } catch (error) {\n             logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`);\n            // 抛出更详细的错误\n            throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`);\n        }\n    }\n\n    // 判断是否是 M3U8 内容\n    function isM3u8Content(content, contentType) {\n        // 检查 Content-Type\n        if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {\n            return true;\n        }\n        // 检查内容本身是否以 #EXTM3U 开头\n        return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');\n    }\n\n    // 判断是否是媒体文件 (根据扩展名和 Content-Type) - 这部分在此代理中似乎未使用，但保留\n    function isMediaFile(url, contentType) {\n        if (contentType) {\n            for (const mediaType of MEDIA_CONTENT_TYPES) {\n                if (contentType.toLowerCase().startsWith(mediaType)) {\n                    return true;\n                }\n            }\n        }\n        const urlLower = url.toLowerCase();\n        for (const ext of MEDIA_FILE_EXTENSIONS) {\n            if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) {\n                return true;\n            }\n        }\n        return false;\n    }\n\n    // 处理 M3U8 中的 #EXT-X-KEY 行 (加密密钥)\n    function processKeyLine(line, baseUrl) {\n        return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => {\n            const absoluteUri = resolveUrl(baseUrl, uri);\n            logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);\n            return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`; // 重写为代理路径\n        });\n    }\n\n    // 处理 M3U8 中的 #EXT-X-MAP 行 (初始化片段)\n    function processMapLine(line, baseUrl) {\n         return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => {\n             const absoluteUri = resolveUrl(baseUrl, uri);\n             logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);\n             return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`; // 重写为代理路径\n         });\n     }\n\n    // 处理媒体 M3U8 播放列表 (包含视频/音频片段)\n    function processMediaPlaylist(url, content) {\n        const baseUrl = getBaseUrl(url);\n        const lines = content.split('\\n');\n        const output = [];\n\n        for (let i = 0; i < lines.length; i++) {\n            const line = lines[i].trim();\n            // 保留最后的空行\n            if (!line && i === lines.length - 1) {\n                output.push(line);\n                continue;\n            }\n            if (!line) continue; // 跳过中间的空行\n\n            if (line.startsWith('#EXT-X-KEY')) {\n                output.push(processKeyLine(line, baseUrl));\n                continue;\n            }\n            if (line.startsWith('#EXT-X-MAP')) {\n                output.push(processMapLine(line, baseUrl));\n                 continue;\n            }\n             if (line.startsWith('#EXTINF')) {\n                 output.push(line);\n                 continue;\n             }\n             if (!line.startsWith('#')) {\n                 const absoluteUrl = resolveUrl(baseUrl, line);\n                 logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`);\n                 output.push(rewriteUrlToProxy(absoluteUrl));\n                 continue;\n             }\n             // 其他 M3U8 标签直接添加\n             output.push(line);\n        }\n        return output.join('\\n');\n    }\n\n    // 递归处理 M3U8 内容\n     async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) {\n         if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {\n             logDebug(`检测到主播放列表: ${targetUrl}`);\n             return await processMasterPlaylist(targetUrl, content, recursionDepth, env);\n         }\n         logDebug(`检测到媒体播放列表: ${targetUrl}`);\n         return processMediaPlaylist(targetUrl, content);\n     }\n\n    // 处理主 M3U8 播放列表\n    async function processMasterPlaylist(url, content, recursionDepth, env) {\n        if (recursionDepth > MAX_RECURSION) {\n            throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`);\n        }\n\n        const baseUrl = getBaseUrl(url);\n        const lines = content.split('\\n');\n        let highestBandwidth = -1;\n        let bestVariantUrl = '';\n\n        for (let i = 0; i < lines.length; i++) {\n            if (lines[i].startsWith('#EXT-X-STREAM-INF')) {\n                const bandwidthMatch = lines[i].match(/BANDWIDTH=(\\d+)/);\n                const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;\n\n                 let variantUriLine = '';\n                 for (let j = i + 1; j < lines.length; j++) {\n                     const line = lines[j].trim();\n                     if (line && !line.startsWith('#')) {\n                         variantUriLine = line;\n                         i = j;\n                         break;\n                     }\n                 }\n\n                 if (variantUriLine && currentBandwidth >= highestBandwidth) {\n                     highestBandwidth = currentBandwidth;\n                     bestVariantUrl = resolveUrl(baseUrl, variantUriLine);\n                 }\n            }\n        }\n\n         if (!bestVariantUrl) {\n             logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF，尝试查找第一个子列表引用: ${url}`);\n             for (let i = 0; i < lines.length; i++) {\n                 const line = lines[i].trim();\n                 if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { // 修复：检查是否包含 .m3u8?\n                    bestVariantUrl = resolveUrl(baseUrl, line);\n                     logDebug(`备选方案：找到第一个子列表引用: ${bestVariantUrl}`);\n                     break;\n                 }\n             }\n         }\n\n        if (!bestVariantUrl) {\n            logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`);\n            return processMediaPlaylist(url, content);\n        }\n\n        // --- 获取并处理选中的子 M3U8 ---\n\n        const cacheKey = `m3u8_processed:${bestVariantUrl}`; // 使用处理后的缓存键\n\n        let kvNamespace = null;\n        try {\n            kvNamespace = env.LIBRETV_PROXY_KV; // 从环境获取 KV 命名空间 (变量名在 Cloudflare 设置)\n            if (!kvNamespace) throw new Error(\"KV 命名空间未绑定\");\n        } catch (e) {\n            logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);\n            kvNamespace = null; // 确保设为 null\n        }\n\n        if (kvNamespace) {\n            try {\n                const cachedContent = await kvNamespace.get(cacheKey);\n                if (cachedContent) {\n                    logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`);\n                    return cachedContent;\n                } else {\n                    logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`);\n                }\n            } catch (kvError) {\n                logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`);\n                // 出错则继续执行，不影响功能\n            }\n        }\n\n        logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);\n        const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl);\n\n        if (!isM3u8Content(variantContent, variantContentType)) {\n            logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件，返回原始内容。`);\n             // 如果不是M3U8，但看起来像媒体内容，直接返回代理后的内容\n             // 注意：这里可能需要决定是否直接代理这个非 M3U8 的 URL\n             // 为了简化，我们假设如果不是 M3U8，则流程中断或按原样处理\n             // 或者，尝试将其作为媒体列表处理？（当前行为）\n             // return createResponse(variantContent, 200, { 'Content-Type': variantContentType || 'application/octet-stream' });\n             // 尝试按媒体列表处理，以防万一\n             return processMediaPlaylist(bestVariantUrl, variantContent);\n\n        }\n\n        const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env);\n\n        if (kvNamespace) {\n             try {\n                 // 使用 waitUntil 异步写入缓存，不阻塞响应返回\n                 // 注意 KV 的写入限制 (免费版每天 1000 次)\n                 waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL }));\n                 logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`);\n             } catch (kvError) {\n                 logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);\n                 // 写入失败不影响返回结果\n             }\n        }\n\n        return processedVariant;\n    }\n\n    // --- 主要请求处理逻辑 ---\n\n    try {\n        const targetUrl = getTargetUrlFromPath(url.pathname);\n\n        if (!targetUrl) {\n            logDebug(`无效的代理请求路径: ${url.pathname}`);\n            return createResponse(\"无效的代理请求。路径应为 /proxy/<经过编码的URL>\", 400);\n        }\n\n        logDebug(`收到代理请求: ${targetUrl}`);\n\n        // --- 缓存检查 (KV) ---\n        const cacheKey = `proxy_raw:${targetUrl}`; // 使用原始内容的缓存键\n        let kvNamespace = null;\n        try {\n            kvNamespace = env.LIBRETV_PROXY_KV;\n            if (!kvNamespace) throw new Error(\"KV 命名空间未绑定\");\n        } catch (e) {\n            logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);\n            kvNamespace = null;\n        }\n\n        if (kvNamespace) {\n            try {\n                const cachedDataJson = await kvNamespace.get(cacheKey); // 直接获取字符串\n                if (cachedDataJson) {\n                    logDebug(`[缓存命中] 原始内容: ${targetUrl}`);\n                    const cachedData = JSON.parse(cachedDataJson); // 解析 JSON\n                    const content = cachedData.body;\n                    let headers = {};\n                    try { headers = JSON.parse(cachedData.headers); } catch(e){} // 解析头部\n                    const contentType = headers['content-type'] || headers['Content-Type'] || '';\n\n                    if (isM3u8Content(content, contentType)) {\n                        logDebug(`缓存内容是 M3U8，重新处理: ${targetUrl}`);\n                        const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);\n                        return createM3u8Response(processedM3u8);\n                    } else {\n                        logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`);\n                        return createResponse(content, 200, new Headers(headers));\n                    }\n                } else {\n                     logDebug(`[缓存未命中] 原始内容: ${targetUrl}`);\n                 }\n            } catch (kvError) {\n                 logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`);\n                 // 出错则继续执行，不影响功能\n            }\n        }\n\n        // --- 实际请求 ---\n        const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl);\n\n        // --- 写入缓存 (KV) ---\n        if (kvNamespace) {\n             try {\n                 const headersToCache = {};\n                 responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; });\n                 const cacheValue = { body: content, headers: JSON.stringify(headersToCache) };\n                 // 注意 KV 写入限制\n                 waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL }));\n                 logDebug(`已将原始内容写入缓存: ${targetUrl}`);\n            } catch (kvError) {\n                 logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);\n                 // 写入失败不影响返回结果\n            }\n        }\n\n        // --- 处理响应 ---\n        if (isM3u8Content(content, contentType)) {\n            logDebug(`内容是 M3U8，开始处理: ${targetUrl}`);\n            const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);\n            return createM3u8Response(processedM3u8);\n        } else {\n            logDebug(`内容不是 M3U8 (类型: ${contentType})，直接返回: ${targetUrl}`);\n            const finalHeaders = new Headers(responseHeaders);\n            finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`);\n            // 添加 CORS 头，确保非 M3U8 内容也能跨域访问（例如图片、字幕文件等）\n            finalHeaders.set(\"Access-Control-Allow-Origin\", \"*\");\n            finalHeaders.set(\"Access-Control-Allow-Methods\", \"GET, HEAD, POST, OPTIONS\");\n            finalHeaders.set(\"Access-Control-Allow-Headers\", \"*\");\n            return createResponse(content, 200, finalHeaders);\n        }\n\n    } catch (error) {\n        logDebug(`处理代理请求时发生严重错误: ${error.message} \\n ${error.stack}`);\n        return createResponse(`代理处理错误: ${error.message}`, 500);\n    }\n}\n\n// 处理 OPTIONS 预检请求的函数\nexport async function onOptions(context) {\n    // 直接返回允许跨域的头信息\n    return new Response(null, {\n        status: 204, // No Content\n        headers: {\n            \"Access-Control-Allow-Origin\": \"*\",\n            \"Access-Control-Allow-Methods\": \"GET, HEAD, POST, OPTIONS\",\n            \"Access-Control-Allow-Headers\": \"*\", // 允许所有请求头\n            \"Access-Control-Max-Age\": \"86400\", // 预检请求结果缓存一天\n        },\n    });\n}\n"
  },
  {
    "path": "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\">\n    <title>LibreTV - 免费在线视频搜索与观看平台</title>\n    <meta name=\"description\" content=\"LibreTV是一个免费的在线视频搜索平台，无广告、安全，提供来自多个视频源的内容搜索与观看服务，无需注册即可使用。\">\n    <meta name=\"keywords\" content=\"在线视频,免费视频,视频搜索,电影,电视剧,LibreTV\">\n    <meta name=\"author\" content=\"LibreTV Team\">\n\n    <!-- Favicon -->\n    <link rel=\"icon\" href=\"image/logo.png\">\n    <link rel=\"apple-touch-icon\" href=\"image/logo-black.png\">\n    <link rel=\"manifest\" href=\"manifest.json\">\n    \n    <script src=\"libs/tailwindcss.min.js\"></script>\n    <link rel=\"stylesheet\" href=\"css/styles.css\">\n    <link rel=\"stylesheet\" href=\"css/index.css\">\n</head>\n<body class=\"page-bg text-white\">\n    <!-- 将历史记录按钮移到左上角，并缩小尺寸 -->\n    <div class=\"fixed top-4 left-4 z-10\">\n        <button onclick=\"toggleHistory(event)\" class=\"bg-[#222] hover:bg-[#333] border border-[#333] hover:border-white rounded-lg px-3 py-1.5 transition-colors\" aria-label=\"观看历史\">\n            <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z\"></path>\n            </svg>\n        </button>\n    </div>\n\n    <!-- 设置按钮保留在右上角，并缩小尺寸 -->\n    <div class=\"fixed top-4 right-4 z-10\">\n        <button onclick=\"toggleSettings(event)\" class=\"bg-[#222] hover:bg-[#333] border border-[#333] hover:border-white rounded-lg px-3 py-1.5 transition-colors\" aria-label=\"打开设置\">\n            <svg class=\"w-5 h-5\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z\"></path>\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 12a3 3 0 11-6 0 3 3 0 016 0z\"></path>\n            </svg>\n        </button>\n    </div>\n    \n    <!-- 历史记录面板 - 标题居中 -->\n    <div id=\"historyPanel\" class=\"history-panel fixed left-0 top-0 h-full bg-[#111] border-r border-[#333] p-6 z-40 transform -translate-x-full transition-transform duration-300\" aria-label=\"观看历史\" aria-hidden=\"true\">\n        <div class=\"flex justify-between items-center mb-6\">\n                <button onclick=\"toggleHistory()\" class=\"close-btn\">\n                    <svg fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                    </svg>\n                </button>\n            <h3 class=\"text-xl font-bold gradient-text mx-auto\">观看历史</h3>\n            <div class=\"w-4\"></div> <!-- 添加一个占位元素以确保标题居中 -->\n        </div>\n        <div id=\"historyList\" class=\"pb-4\">\n            <!-- 历史记录将在这里动态显示 -->\n            <div class=\"text-center text-gray-500 py-8\">暂无观看记录</div>\n        </div>\n        <div class=\"mt-4 text-center sticky bottom-0 pb-2 pt-2 bg-[#111]\">\n            <button onclick=\"clearViewingHistory()\" class=\"px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg\">\n                清空历史记录\n            </button>\n        </div>\n    </div>\n\n    <!-- 设置面板 -->\n    <div id=\"settingsPanel\" class=\"settings-panel fixed right-0 top-0 h-full w-80 bg-[#111] border-l border-[#333] p-6 z-40 overflow-y-auto\" aria-label=\"设置面板\" aria-hidden=\"true\">\n        <div class=\"flex justify-between items-center mb-6\">\n            <h3 class=\"text-xl font-bold gradient-text\">设置</h3>\n            <button onclick=\"toggleSettings()\" class=\"close-btn\">\n                <svg fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" aria-hidden=\"true\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                </svg>\n            </button>\n        </div>\n        <div class=\"space-y-5\">\n            <!-- 数据源设置区域 -->\n            <div class=\"p-3 bg-[#151515] rounded-lg shadow-inner\">\n                <label class=\"block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1\">数据源设置</label>\n                \n                <!-- 批量操作按钮 -->\n                <div class=\"flex space-x-2 mb-3\">\n                    <button onclick=\"selectAllAPIs(true)\" class=\"px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded\">全选</button>\n                    <button onclick=\"selectAllAPIs(false)\" class=\"px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded\">全不选</button>\n                    <button onclick=\"selectAllAPIs(true, true)\" class=\"px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded\">全选普通资源</button>\n                </div>\n                \n                <!-- API选择区域 - 使用滚动区域 -->\n                <div class=\"max-h-40 overflow-y-auto bg-[#191919] p-2 rounded-lg mb-3\">\n                    <div id=\"apiCheckboxes\">\n                        <!-- 这里将动态插入API复选框 -->\n                    </div>\n                </div>\n                \n                <!-- API信息显示 -->\n                <div class=\"text-xs text-gray-500 flex justify-between items-center\">\n                    <span>已选API数量：<span id=\"selectedApiCount\" class=\"text-white\">0</span></span>\n                    <span id=\"siteStatus\" class=\"ml-2\"></span>\n                </div>\n            </div>\n\n            <!-- 自定义API管理区域 -->\n            <div class=\"p-3 bg-[#151515] rounded-lg shadow-inner\">\n                <div class=\"flex justify-between items-center mb-2\">\n                    <label class=\"block text-sm font-medium text-gray-400 border-b border-[#333] w-full pb-1\">自定义API</label>\n                    <button onclick=\"showAddCustomApiForm()\" class=\"bg-[#333] hover:bg-[#444] text-white w-6 h-6 rounded-full text-center leading-none text-lg ml-1\">+</button>\n                </div>\n                <div id=\"customApisList\" class=\"max-h-32 overflow-y-auto mb-2\">\n                    <!-- 自定义API将显示在这里 -->\n                </div>\n                \n                <!-- 添加自定义API表单 (默认隐藏) -->\n                <div id=\"addCustomApiForm\" class=\"hidden mt-2 p-2 bg-[#191919] rounded-lg\">\n                    <input type=\"text\" id=\"customApiName\" placeholder=\"API名称\" class=\"w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2\" autocomplete=\"off\">\n                    <input type=\"text\" id=\"customApiUrl\" placeholder=\"https://abc.com\" class=\"w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2\" autocomplete=\"off\">\n                    <!-- 新增 detail 地址输入框 -->\n                    <input type=\"text\" id=\"customApiDetail\" placeholder=\"detail地址（可选）\" class=\"w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2\" autocomplete=\"off\">\n                    <!-- 添加成人内容切换 -->\n                    <div class=\"flex items-center mb-2\">\n                        <input type=\"checkbox\" id=\"customApiIsAdult\" class=\"form-checkbox h-4 w-4 text-pink-500 bg-[#222] border border-[#333]\">\n                        <label for=\"customApiIsAdult\" class=\"ml-2 text-xs text-pink-400\">黄色资源站</label>\n                    </div>\n                    <div class=\"flex space-x-2\">\n                        <button onclick=\"addCustomApi()\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs\">添加</button>\n                        <button onclick=\"cancelAddCustomApi()\" class=\"bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs\">取消</button>\n                    </div>\n                </div>\n            </div>\n            \n            <!-- 内容过滤设置区域 -->\n            <div class=\"p-3 bg-[#151515] rounded-lg shadow-inner\">\n                <label class=\"block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1\">功能开关</label>\n                \n                <!-- 黄色内容过滤开关 -->\n                <div class=\"flex flex-col mb-3 pb-3 border-b border-[#222] relative\">\n                    <div class=\"flex items-center justify-between\">\n                        <div>\n                            <label class=\"text-sm font-medium text-gray-400\">黄色内容过滤</label>\n                            <p class=\"text-xs text-gray-500 mt-1 filter-description\">过滤\"伦理片\"等黄色内容</p>\n                        </div>\n                        <div class=\"relative inline-block w-12 align-middle select-none\">\n                            <input type=\"checkbox\" id=\"yellowFilterToggle\" class=\"opacity-0 absolute w-full h-full cursor-pointer z-10\">\n                            <div class=\"toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out\"></div>\n                            <div class=\"toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out\"></div>\n                        </div>\n                    </div>\n                    <!-- 警告提示将在这里动态插入 -->\n                </div>\n                \n                <!-- 广告过滤开关 -->\n                <div class=\"flex items-center justify-between mb-3 pb-3 border-b border-[#222]\">\n                    <div>\n                        <label class=\"text-sm font-medium text-gray-400\">分片广告过滤</label>\n                        <p class=\"text-xs text-gray-500 mt-1\">关闭可减少旧版浏览器卡顿</p>\n                    </div>\n                    <div class=\"relative inline-block w-12 align-middle select-none\">\n                        <input type=\"checkbox\" id=\"adFilterToggle\" class=\"opacity-0 absolute w-full h-full cursor-pointer z-10\">\n                        <div class=\"toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out\"></div>\n                        <div class=\"toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out\"></div>\n                    </div>\n                </div>\n                \n                <!-- 豆瓣热门开关 -->\n                <div class=\"flex items-center justify-between\">\n                    <div>\n                        <label class=\"text-sm font-medium text-gray-400\">豆瓣热门推荐</label>\n                        <p class=\"text-xs text-gray-500 mt-1\">首页显示豆瓣热门影视内容</p>\n                    </div>\n                    <div class=\"relative inline-block w-12 align-middle select-none\">\n                        <input type=\"checkbox\" id=\"doubanToggle\" class=\"opacity-0 absolute w-full h-full cursor-pointer z-10\">\n                        <div class=\"toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out\"></div>\n                        <div class=\"toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out\"></div>\n                    </div>\n                </div>\n            </div>\n\n            <!-- 一般功能区域 -->\n            <div class=\"p-3 bg-[#151515] rounded-lg shadow-inner\">\n                <label class=\"block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1\">一般功能</label>\n                <button onclick=\"importConfig()\" class=\"px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg mb-2\">导入配置</button>\n                <button onclick=\"exportConfig()\" class=\"px-4 py-2 mb-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg\">导出配置</button>\n                <button onclick=\"clearLocalStorage()\" class=\"px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg\">清除Cookie</button>\n            </div>\n        </div>\n    </div>\n\n    <div class=\"container mx-auto px-4 py-8 flex flex-col h-screen\">\n        <div class=\"flex-1 flex flex-col\">\n            <!-- 网站标志和口号 -->\n            <header class=\"text-center mb-2\">\n                <div class=\"flex justify-center items-center mb-4\">\n                    <a href=\"#\" onclick=\"resetToHome(); return false;\" class=\"flex items-center\">\n                        <svg class=\"w-10 h-10 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                        </svg>\n                        <h1 class=\"text-5xl font-bold gradient-text\">LibreTV</h1>\n                    </a>\n                </div>\n                <p class=\"text-gray-400 mb-8\">自由观影，畅享精彩</p>\n            </header>\n            \n\n            <div id=\"searchArea\" class=\"flex-1 flex flex-col items-center justify-center\">\n                <div class=\"w-full max-w-2xl\">\n                    <div class=\"flex items-stretch mb-3 h-14 shadow-lg rounded-lg overflow-hidden\">\n                        <!-- 首页按钮 -->\n                        <button onclick=\"resetToHome()\" \n                                class=\"w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors\" \n                                aria-label=\"返回首页\" title=\"返回首页\">\n                            首页\n                        </button>\n                        <!-- 搜索输入 -->\n                        <input type=\"text\" \n                               id=\"searchInput\" \n                               class=\"flex-1 bg-[#111] border-y border-[#333] text-white px-6 py-0 focus:outline-none transition-colors min-w-0\" \n                               placeholder=\"搜索你喜欢的视频...\" \n                               autocomplete=\"off\"\n                               aria-label=\"视频搜索框\"\n                               oninput=\"toggleClearButton()\">\n                        <!-- 清空按钮 -->\n                        <button id=\"clearSearchInput\" \n                                class=\"flex pr-2 bg-[#111] border-y border-[#333] items-center justify-center text-gray-400 hover:text-white hidden\"\n                                onclick=\"clearSearchInput()\"\n                                aria-label=\"清空搜索框\"\n                                title=\"清空搜索框\">\n                            <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                            </svg>\n                        </button>\n                        <!-- 搜索按钮 -->\n                        <button onclick=\"search()\" \n                                class=\"w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors\" \n                                aria-label=\"搜索按钮\">\n                            搜索\n                        </button>\n                    </div>\n                    \n                    <!-- 添加最近搜索记录部分 -->\n                    <div id=\"recentSearches\" class=\"mt-4 flex flex-wrap gap-2\" aria-label=\"最近搜索记录\">\n                        <!-- 这里会动态插入最近的搜索记录 -->\n                    </div>\n                </div>\n            </div>\n            \n            <!-- 豆瓣热门推荐区域: 默认隐藏，现在位于搜索区域下方，调整宽度 -->\n            <div id=\"doubanArea\" class=\"w-full my-8 hidden\">\n                <div class=\"mx-auto max-w-screen-xl px-2\">\n                    <!-- 改进标题和标签区域布局 -->\n                    <div class=\"mb-4\">\n                        <!-- 标题和刷新按钮一行 -->\n                        <div class=\"flex items-center justify-between mb-4\">\n                            <div class=\"flex items-center\">\n                                <h2 class=\"text-xl font-bold text-white mr-4\">豆瓣热门</h2>\n                                <!-- 添加电影/电视剧切换开关 -->\n                                <div class=\"flex items-center bg-[#222] rounded-full p-1\">\n                                    <button id=\"douban-movie-toggle\" class=\"px-3 py-1 text-sm rounded-full bg-pink-600 text-white\">电影</button>\n                                    <button id=\"douban-tv-toggle\" class=\"px-3 py-1 text-sm rounded-full text-gray-300 hover:text-white\">电视剧</button>\n                                </div>\n                            </div>\n                            <button id=\"douban-refresh\" class=\"text-sm px-3 py-1 bg-pink-600 hover:bg-pink-700 text-white rounded-lg flex items-center gap-1\">\n                                <span>换一批</span>\n                                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15\" />\n                                </svg>\n                            </button>\n                        </div>\n                        <!-- 分类标签独立成行，添加滚动支持以适应移动设备 -->\n                        <div class=\"overflow-x-auto pb-2\">\n                            <div id=\"douban-tags\" class=\"flex space-x-2 min-w-max\"></div>\n                        </div>\n                    </div>\n                    \n                    <!-- 推荐内容 -->\n                    <div id=\"douban-results\" class=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3\"></div>\n                </div>\n            </div>\n            \n            <!-- 搜索结果：初始隐藏 -->\n            <div id=\"resultsArea\" class=\"w-full hidden\">\n                <div class=\"mx-auto max-w-7xl px-2\"> <!-- 添加最大宽度限制并居中 -->\n                    <div class=\"flex justify-end items-center mb-4\">\n                        <div class=\"text-sm text-gray-400\">\n                            <span id=\"searchResultsCount\">0</span> 个结果\n                        </div>\n                    </div>\n                    <!-- 修改网格布局以适应大一些的横向卡片 -->\n                    <div id=\"results\" class=\"grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4\">\n                        <!-- 结果将在这里动态生成 -->\n                    </div>\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <!-- 页脚区域 -->\n    <footer class=\"footer mt-8 py-6 border-t border-[#333] bg-[#0a0a0a]\">\n        <div class=\"container mx-auto px-4\">\n            <div class=\"flex flex-col md:flex-row justify-between items-center\">\n                <div class=\"mb-4 md:mb-0\">\n                    <div class=\"flex items-center justify-center md:justify-start\">\n                        <svg class=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                        </svg>\n                        <span class=\"gradient-text font-bold\">LibreTV</span>\n                    </div>\n                    <p class=\"text-gray-500 text-sm mt-2 text-center md:text-left\">© 2025 LibreTV - 自由观影，畅享精彩</p>\n                </div>\n                \n                <div class=\"text-center md:text-right\">\n                    <p class=\"text-gray-500 text-sm max-w-md\">\n                        免责声明：本站仅为视频搜索工具，不存储、上传或分发任何视频内容。\n                        所有视频均来自第三方API接口。如有侵权，请联系相关内容提供方。\n                    </p>\n                    <div class=\"mt-2 flex justify-center md:justify-end space-x-4\">\n                        <a href=\"about.html\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">关于我们</a>\n                        <a href=\"about.html\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">隐私政策</a>\n                        <a href=\"https://www.msf.hk/zh-hant/donate/general?type=one-off\" target=\"_blank\" rel=\"noopener\" class=\"text-blue-400 hover:text-blue-300 text-sm transition-colors\">捐赠</a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </footer>\n\n    <!-- 详情模态框 -->\n    <div id=\"modal\" class=\"fixed inset-0 bg-black/95 hidden flex items-center justify-center transition-opacity duration-300 z-40\">\n        <div class=\"bg-[#111] p-8 rounded-lg w-11/12 max-w-4xl border border-[#333] max-h-[90vh] flex flex-col\">\n            <div class=\"flex justify-between items-center mb-6 flex-none\">\n                <h2 id=\"modalTitle\" class=\"text-2xl font-bold gradient-text break-words pr-4 max-w-[80%]\"></h2>\n                <button onclick=\"closeModal()\" class=\"text-gray-400 hover:text-white text-2xl transition-colors flex-shrink-0\">&times;</button>\n            </div>\n            <div id=\"modalContent\" class=\"overflow-auto flex-1 min-h-0\">\n                <div class=\"grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2\">\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <!-- 密码验证弹窗 -->\n    <div id=\"passwordModal\" class=\"fixed inset-0 bg-black/95 hidden items-center justify-center z-[65] transition-opacity duration-300\">\n        <div class=\"bg-[#111] p-8 rounded-lg w-11/12 max-w-md border border-[#333] max-h-[90vh] flex flex-col\">\n            <div class=\"flex justify-between items-center mb-6 flex-none\">\n                <h2 class=\"text-2xl font-bold gradient-text\">访问验证</h2>\n            </div>\n            <div class=\"mb-6\">\n                <p class=\"text-gray-300 mb-4\">请输入密码继续访问</p>\n                <form id=\"passwordForm\" onsubmit=\"handlePasswordSubmit(); return false;\">\n                    <input type=\"text\" name=\"username\" id=\"username\" autocomplete=\"username\" style=\"display:none\" tabindex=\"-1\" aria-hidden=\"true\">\n                    <input type=\"password\" id=\"passwordInput\" class=\"w-full bg-[#111] border border-[#333] text-white px-4 py-3 rounded-lg focus:outline-none focus:border-white transition-colors\" placeholder=\"密码...\" autocomplete=\"new-password\">\n                    <div class=\"mt-4 w-full flex space-x-4\">\n                        <button id=\"passwordSubmitBtn\" type=\"submit\" class=\"flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded\">提交</button>\n                        <button id=\"passwordCancelBtn\" type=\"button\" onclick=\"hidePasswordModal()\" class=\"flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded\">取消</button>\n                    </div>\n                </form>\n                <p id=\"passwordError\" class=\"text-red-500 mt-2 hidden\">密码错误，请重试</p>\n            </div>\n        </div>\n    </div>\n\n    <!-- 版权声明弹窗 -->\n    <div id=\"disclaimerModal\" class=\"fixed inset-0 bg-black/90 hidden items-center justify-center z-[60]\">\n        <div class=\"bg-[#111] p-8 rounded-lg border border-[#333] w-11/12 max-w-2xl max-h-[90vh] overflow-y-auto\">\n            <h2 class=\"text-2xl font-bold gradient-text mb-6 text-center\">使用声明</h2>\n            <div class=\"text-gray-300 space-y-4\">\n                <p>\n                    欢迎使用 LibreTV。在开始使用前，请您了解并同意以下条款：\n                </p>\n                <p>\n                    <strong class=\"text-blue-400\">服务性质：</strong> LibreTV 仅提供视频搜索服务，不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。\n                </p>\n                <p>\n                    <strong class=\"text-blue-400\">用户责任：</strong> 用户在使用本站服务时，须遵守相关法律法规，不得利用搜索结果从事侵权行为，如下载、传播未经授权的作品等。\n                </p>\n                <p>\n                    <strong class=\"text-blue-400\">广告风险提示：</strong> 本站所有视频均来自第三方采集站，视频中出现的广告与本站无关，请勿相信或点击视频中的任何广告内容，谨防上当受骗。\n                </p>\n            </div>\n            <div class=\"mt-6 flex justify-center\">\n                <button id=\"acceptDisclaimerBtn\" class=\"px-6 py-3 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-semibold rounded-lg hover:shadow-lg transition-all duration-300\">\n                    我已了解并接受\n                </button>\n            </div>\n        </div>\n    </div>\n\n    <!-- 错误提示框 -->\n    <div id=\"toast\" class=\"fixed top-4 left-1/2 -translate-x-1/2 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 opacity-0 -translate-y-full z-50\">\n        <p id=\"toastMessage\"></p>\n    </div>\n\n    <!-- 添加 loading 提示框 -->\n    <div id=\"loading\" class=\"fixed inset-0 bg-black/80 hidden items-center justify-center z-50\">\n        <div class=\"bg-[#111] p-8 rounded-lg border border-[#333] flex items-center space-x-4\">\n            <div class=\"w-8 h-8 border-4 border-white border-t-transparent rounded-full animate-spin\"></div>\n            <p class=\"text-white text-lg\">加载中...</p>\n        </div>\n    </div>\n\n    <!-- JSON-LD 结构化数据 -->\n    <script type=\"application/ld+json\">\n    {\n        \"@context\": \"https://schema.org\",\n        \"@type\": \"WebSite\",\n        \"name\": \"LibreTV\",\n        \"url\": \"https://libretv.is-an.org/\",\n        \"description\": \"免费在线视频搜索与观看平台\",\n        \"potentialAction\": {\n            \"@type\": \"SearchAction\",\n            \"target\": \"https://libretv.is-an.org/?s={search_term_string}\",\n            \"query-input\": \"required name=search_term_string\"\n        }\n    }\n    </script>\n\n    <!-- 引入纯 JS sha256（HTTP 下依然可用） -->\n    <script src=\"libs/sha256.min.js\"></script>\n    <script>\n        // 保存原始 js‑sha256 实现，避免被 password.js 覆盖\n        window._jsSha256 = window.sha256;\n    </script>\n    <script src=\"js/config.js\"></script>\n    <script src=\"js/proxy-auth.js\"></script>\n    <script src=\"js/customer_site.js\"></script>\n    <script src=\"js/ui.js\"></script>\n    <script src=\"js/api.js\"></script>\n    <script src=\"js/douban.js\"></script>\n    <script src=\"js/password.js\"></script>\n    <script src=\"js/search.js\"></script>\n    <script src=\"js/app.js\"></script>\n\n    <!-- PWA 注册 -->\n    <script src=\"js/pwa-register.js\"></script>\n\n    <!-- 环境变量注入脚本 -->\n    <script>\n        // 创建全局环境变量对象\n        window.__ENV__ = window.__ENV__ || {};\n        \n        // 注入服务器端环境变量 (将由服务器端替换)\n        // PASSWORD 变量将在这里被服务器端注入\n        window.__ENV__.PASSWORD = \"{{PASSWORD}}\";\n    </script>\n\n    <!-- 版本检测脚本 -->\n    <script src=\"js/version-check.js\"></script>\n\n    <!-- 添加弹窗和URL搜索参数处理脚本 -->\n    <script src=\"js/index-page.js\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "js/api.js",
    "content": "// 改进的API请求处理函数\nasync function handleApiRequest(url) {\n    const customApi = url.searchParams.get('customApi') || '';\n    const customDetail = url.searchParams.get('customDetail') || '';\n    const source = url.searchParams.get('source') || 'heimuer';\n    \n    try {\n        if (url.pathname === '/api/search') {\n            const searchQuery = url.searchParams.get('wd');\n            if (!searchQuery) {\n                throw new Error('缺少搜索参数');\n            }\n            \n            // 验证API和source的有效性\n            if (source === 'custom' && !customApi) {\n                throw new Error('使用自定义API时必须提供API地址');\n            }\n            \n            if (!API_SITES[source] && source !== 'custom') {\n                throw new Error('无效的API来源');\n            }\n            \n            const apiUrl = customApi\n                ? `${customApi}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`\n                : `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;\n            \n            // 添加超时处理\n            const controller = new AbortController();\n            const timeoutId = setTimeout(() => controller.abort(), 10000);\n            \n            try {\n                // 添加鉴权参数到代理URL\n                const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n                    await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) :\n                    PROXY_URL + encodeURIComponent(apiUrl);\n                    \n                const response = await fetch(proxiedUrl, {\n                    headers: API_CONFIG.search.headers,\n                    signal: controller.signal\n                });\n                \n                clearTimeout(timeoutId);\n                \n                if (!response.ok) {\n                    throw new Error(`API请求失败: ${response.status}`);\n                }\n                \n                const data = await response.json();\n                \n                // 检查JSON格式的有效性\n                if (!data || !Array.isArray(data.list)) {\n                    throw new Error('API返回的数据格式无效');\n                }\n                \n                // 添加源信息到每个结果\n                data.list.forEach(item => {\n                    item.source_name = source === 'custom' ? '自定义源' : API_SITES[source].name;\n                    item.source_code = source;\n                    // 对于自定义源，添加API URL信息\n                    if (source === 'custom') {\n                        item.api_url = customApi;\n                    }\n                });\n                \n                return JSON.stringify({\n                    code: 200,\n                    list: data.list || [],\n                });\n            } catch (fetchError) {\n                clearTimeout(timeoutId);\n                throw fetchError;\n            }\n        }\n\n        // 详情处理\n        if (url.pathname === '/api/detail') {\n            const id = url.searchParams.get('id');\n            const sourceCode = url.searchParams.get('source') || 'heimuer'; // 获取源代码\n            \n            if (!id) {\n                throw new Error('缺少视频ID参数');\n            }\n            \n            // 验证ID格式 - 只允许数字和有限的特殊字符\n            if (!/^[\\w-]+$/.test(id)) {\n                throw new Error('无效的视频ID格式');\n            }\n\n            // 验证API和source的有效性\n            if (sourceCode === 'custom' && !customApi) {\n                throw new Error('使用自定义API时必须提供API地址');\n            }\n            \n            if (!API_SITES[sourceCode] && sourceCode !== 'custom') {\n                throw new Error('无效的API来源');\n            }\n\n            // 对于有detail参数的源，都使用特殊处理方式\n            if (sourceCode !== 'custom' && API_SITES[sourceCode].detail) {\n                return await handleSpecialSourceDetail(id, sourceCode);\n            }\n            \n            // 如果是自定义API，并且传递了detail参数，尝试特殊处理\n            // 优先 customDetail\n            if (sourceCode === 'custom' && customDetail) {\n                return await handleCustomApiSpecialDetail(id, customDetail);\n            }\n            if (sourceCode === 'custom' && url.searchParams.get('useDetail') === 'true') {\n                return await handleCustomApiSpecialDetail(id, customApi);\n            }\n            \n            const detailUrl = customApi\n                ? `${customApi}${API_CONFIG.detail.path}${id}`\n                : `${API_SITES[sourceCode].api}${API_CONFIG.detail.path}${id}`;\n            \n            // 添加超时处理\n            const controller = new AbortController();\n            const timeoutId = setTimeout(() => controller.abort(), 10000);\n            \n            try {\n                // 添加鉴权参数到代理URL\n                const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n                    await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) :\n                    PROXY_URL + encodeURIComponent(detailUrl);\n                    \n                const response = await fetch(proxiedUrl, {\n                    headers: API_CONFIG.detail.headers,\n                    signal: controller.signal\n                });\n                \n                clearTimeout(timeoutId);\n                \n                if (!response.ok) {\n                    throw new Error(`详情请求失败: ${response.status}`);\n                }\n                \n                // 解析JSON\n                const data = await response.json();\n                \n                // 检查返回的数据是否有效\n                if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {\n                    throw new Error('获取到的详情内容无效');\n                }\n                \n                // 获取第一个匹配的视频详情\n                const videoDetail = data.list[0];\n                \n                // 提取播放地址\n                let episodes = [];\n                \n                if (videoDetail.vod_play_url) {\n                    // 分割不同播放源\n                    const playSources = videoDetail.vod_play_url.split('$$$');\n                    \n                    // 提取第一个播放源的集数（通常为主要源）\n                    if (playSources.length > 0) {\n                        const mainSource = playSources[0];\n                        const episodeList = mainSource.split('#');\n                        \n                        // 从每个集数中提取URL\n                        episodes = episodeList.map(ep => {\n                            const parts = ep.split('$');\n                            // 返回URL部分(通常是第二部分，如果有的话)\n                            return parts.length > 1 ? parts[1] : '';\n                        }).filter(url => url && (url.startsWith('http://') || url.startsWith('https://')));\n                    }\n                }\n                \n                // 如果没有找到播放地址，尝试使用正则表达式查找m3u8链接\n                if (episodes.length === 0 && videoDetail.vod_content) {\n                    const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];\n                    episodes = matches.map(link => link.replace(/^\\$/, ''));\n                }\n                \n                return JSON.stringify({\n                    code: 200,\n                    episodes: episodes,\n                    detailUrl: detailUrl,\n                    videoInfo: {\n                        title: videoDetail.vod_name,\n                        cover: videoDetail.vod_pic,\n                        desc: videoDetail.vod_content,\n                        type: videoDetail.type_name,\n                        year: videoDetail.vod_year,\n                        area: videoDetail.vod_area,\n                        director: videoDetail.vod_director,\n                        actor: videoDetail.vod_actor,\n                        remarks: videoDetail.vod_remarks,\n                        // 添加源信息\n                        source_name: sourceCode === 'custom' ? '自定义源' : API_SITES[sourceCode].name,\n                        source_code: sourceCode\n                    }\n                });\n            } catch (fetchError) {\n                clearTimeout(timeoutId);\n                throw fetchError;\n            }\n        }\n\n        throw new Error('未知的API路径');\n    } catch (error) {\n        console.error('API处理错误:', error);\n        return JSON.stringify({\n            code: 400,\n            msg: error.message || '请求处理失败',\n            list: [],\n            episodes: [],\n        });\n    }\n}\n\n// 处理自定义API的特殊详情页\nasync function handleCustomApiSpecialDetail(id, customApi) {\n    try {\n        // 构建详情页URL\n        const detailUrl = `${customApi}/index.php/vod/detail/id/${id}.html`;\n        \n        // 添加超时处理\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 10000);\n        \n        // 添加鉴权参数到代理URL\n        const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n            await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) :\n            PROXY_URL + encodeURIComponent(detailUrl);\n            \n        // 获取详情页HTML\n        const response = await fetch(proxiedUrl, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',\n            },\n            signal: controller.signal\n        });\n        \n        clearTimeout(timeoutId);\n        \n        if (!response.ok) {\n            throw new Error(`自定义API详情页请求失败: ${response.status}`);\n        }\n        \n        // 获取HTML内容\n        const html = await response.text();\n        \n        // 使用通用模式提取m3u8链接\n        const generalPattern = /\\$(https?:\\/\\/[^\"'\\s]+?\\.m3u8)/g;\n        let matches = html.match(generalPattern) || [];\n        \n        // 处理链接\n        matches = matches.map(link => {\n            link = link.substring(1, link.length);\n            const parenIndex = link.indexOf('(');\n            return parenIndex > 0 ? link.substring(0, parenIndex) : link;\n        });\n        \n        // 提取基本信息\n        const titleMatch = html.match(/<h1[^>]*>([^<]+)<\\/h1>/);\n        const titleText = titleMatch ? titleMatch[1].trim() : '';\n        \n        const descMatch = html.match(/<div[^>]*class=[\"']sketch[\"'][^>]*>([\\s\\S]*?)<\\/div>/);\n        const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';\n        \n        return JSON.stringify({\n            code: 200,\n            episodes: matches,\n            detailUrl: detailUrl,\n            videoInfo: {\n                title: titleText,\n                desc: descText,\n                source_name: '自定义源',\n                source_code: 'custom'\n            }\n        });\n    } catch (error) {\n        console.error(`自定义API详情获取失败:`, error);\n        throw error;\n    }\n}\n\n// 通用特殊源详情处理函数\nasync function handleSpecialSourceDetail(id, sourceCode) {\n    try {\n        // 构建详情页URL（使用配置中的detail URL而不是api URL）\n        const detailUrl = `${API_SITES[sourceCode].detail}/index.php/vod/detail/id/${id}.html`;\n        \n        // 添加超时处理\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 10000);\n        \n        // 添加鉴权参数到代理URL\n        const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n            await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) :\n            PROXY_URL + encodeURIComponent(detailUrl);\n            \n        // 获取详情页HTML\n        const response = await fetch(proxiedUrl, {\n            headers: {\n                'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',\n            },\n            signal: controller.signal\n        });\n        \n        clearTimeout(timeoutId);\n        \n        if (!response.ok) {\n            throw new Error(`详情页请求失败: ${response.status}`);\n        }\n        \n        // 获取HTML内容\n        const html = await response.text();\n        \n        // 根据不同源类型使用不同的正则表达式\n        let matches = [];\n        \n        if (sourceCode === 'ffzy') {\n            // 非凡影视使用特定的正则表达式\n            const ffzyPattern = /\\$(https?:\\/\\/[^\"'\\s]+?\\/\\d{8}\\/\\d+_[a-f0-9]+\\/index\\.m3u8)/g;\n            matches = html.match(ffzyPattern) || [];\n        }\n        \n        // 如果没有找到链接或者是其他源类型，尝试一个更通用的模式\n        if (matches.length === 0) {\n            const generalPattern = /\\$(https?:\\/\\/[^\"'\\s]+?\\.m3u8)/g;\n            matches = html.match(generalPattern) || [];\n        }\n        // 去重处理，避免一个播放源多集显示\n        matches = [...new Set(matches)];\n        // 处理链接\n        matches = matches.map(link => {\n            link = link.substring(1, link.length);\n            const parenIndex = link.indexOf('(');\n            return parenIndex > 0 ? link.substring(0, parenIndex) : link;\n        });\n        \n        // 提取可能存在的标题、简介等基本信息\n        const titleMatch = html.match(/<h1[^>]*>([^<]+)<\\/h1>/);\n        const titleText = titleMatch ? titleMatch[1].trim() : '';\n        \n        const descMatch = html.match(/<div[^>]*class=[\"']sketch[\"'][^>]*>([\\s\\S]*?)<\\/div>/);\n        const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';\n        \n        return JSON.stringify({\n            code: 200,\n            episodes: matches,\n            detailUrl: detailUrl,\n            videoInfo: {\n                title: titleText,\n                desc: descText,\n                source_name: API_SITES[sourceCode].name,\n                source_code: sourceCode\n            }\n        });\n    } catch (error) {\n        console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error);\n        throw error;\n    }\n}\n\n// 处理聚合搜索\nasync function handleAggregatedSearch(searchQuery) {\n    // 获取可用的API源列表（排除aggregated和custom）\n    const availableSources = Object.keys(API_SITES).filter(key => \n        key !== 'aggregated' && key !== 'custom'\n    );\n    \n    if (availableSources.length === 0) {\n        throw new Error('没有可用的API源');\n    }\n    \n    // 创建所有API源的搜索请求\n    const searchPromises = availableSources.map(async (source) => {\n        try {\n            const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;\n            \n            // 使用Promise.race添加超时处理\n            const timeoutPromise = new Promise((_, reject) => \n                setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000)\n            );\n            \n            // 添加鉴权参数到代理URL\n            const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n                await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) :\n                PROXY_URL + encodeURIComponent(apiUrl);\n            \n            const fetchPromise = fetch(proxiedUrl, {\n                headers: API_CONFIG.search.headers\n            });\n            \n            const response = await Promise.race([fetchPromise, timeoutPromise]);\n            \n            if (!response.ok) {\n                throw new Error(`${source}源请求失败: ${response.status}`);\n            }\n            \n            const data = await response.json();\n            \n            if (!data || !Array.isArray(data.list)) {\n                throw new Error(`${source}源返回的数据格式无效`);\n            }\n            \n            // 为搜索结果添加源信息\n            const results = data.list.map(item => ({\n                ...item,\n                source_name: API_SITES[source].name,\n                source_code: source\n            }));\n            \n            return results;\n        } catch (error) {\n            console.warn(`${source}源搜索失败:`, error);\n            return []; // 返回空数组表示该源搜索失败\n        }\n    });\n    \n    try {\n        // 并行执行所有搜索请求\n        const resultsArray = await Promise.all(searchPromises);\n        \n        // 合并所有结果\n        let allResults = [];\n        resultsArray.forEach(results => {\n            if (Array.isArray(results) && results.length > 0) {\n                allResults = allResults.concat(results);\n            }\n        });\n        \n        // 如果没有搜索结果，返回空结果\n        if (allResults.length === 0) {\n            return JSON.stringify({\n                code: 200,\n                list: [],\n                msg: '所有源均无搜索结果'\n            });\n        }\n        \n        // 去重（根据vod_id和source_code组合）\n        const uniqueResults = [];\n        const seen = new Set();\n        \n        allResults.forEach(item => {\n            const key = `${item.source_code}_${item.vod_id}`;\n            if (!seen.has(key)) {\n                seen.add(key);\n                uniqueResults.push(item);\n            }\n        });\n        \n        // 按照视频名称和来源排序\n        uniqueResults.sort((a, b) => {\n            // 首先按照视频名称排序\n            const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');\n            if (nameCompare !== 0) return nameCompare;\n            \n            // 如果名称相同，则按照来源排序\n            return (a.source_name || '').localeCompare(b.source_name || '');\n        });\n        \n        return JSON.stringify({\n            code: 200,\n            list: uniqueResults,\n        });\n    } catch (error) {\n        console.error('聚合搜索处理错误:', error);\n        return JSON.stringify({\n            code: 400,\n            msg: '聚合搜索处理失败: ' + error.message,\n            list: []\n        });\n    }\n}\n\n// 处理多个自定义API源的聚合搜索\nasync function handleMultipleCustomSearch(searchQuery, customApiUrls) {\n    // 解析自定义API列表\n    const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator)\n        .map(url => url.trim())\n        .filter(url => url.length > 0 && /^https?:\\/\\//.test(url))\n        .slice(0, CUSTOM_API_CONFIG.maxSources);\n    \n    if (apiUrls.length === 0) {\n        throw new Error('没有提供有效的自定义API地址');\n    }\n    \n    // 为每个API创建搜索请求\n    const searchPromises = apiUrls.map(async (apiUrl, index) => {\n        try {\n            const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;\n            \n            // 使用Promise.race添加超时处理\n            const timeoutPromise = new Promise((_, reject) => \n                setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000)\n            );\n            \n            // 添加鉴权参数到代理URL\n            const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n                await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(fullUrl)) :\n                PROXY_URL + encodeURIComponent(fullUrl);\n            \n            const fetchPromise = fetch(proxiedUrl, {\n                headers: API_CONFIG.search.headers\n            });\n            \n            const response = await Promise.race([fetchPromise, timeoutPromise]);\n            \n            if (!response.ok) {\n                throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`);\n            }\n            \n            const data = await response.json();\n            \n            if (!data || !Array.isArray(data.list)) {\n                throw new Error(`自定义API ${index+1} 返回的数据格式无效`);\n            }\n            \n            // 为搜索结果添加源信息\n            const results = data.list.map(item => ({\n                ...item,\n                source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`,\n                source_code: 'custom',\n                api_url: apiUrl // 保存API URL以便详情获取\n            }));\n            \n            return results;\n        } catch (error) {\n            console.warn(`自定义API ${index+1} 搜索失败:`, error);\n            return []; // 返回空数组表示该源搜索失败\n        }\n    });\n    \n    try {\n        // 并行执行所有搜索请求\n        const resultsArray = await Promise.all(searchPromises);\n        \n        // 合并所有结果\n        let allResults = [];\n        resultsArray.forEach(results => {\n            if (Array.isArray(results) && results.length > 0) {\n                allResults = allResults.concat(results);\n            }\n        });\n        \n        // 如果没有搜索结果，返回空结果\n        if (allResults.length === 0) {\n            return JSON.stringify({\n                code: 200,\n                list: [],\n                msg: '所有自定义API源均无搜索结果'\n            });\n        }\n        \n        // 去重（根据vod_id和api_url组合）\n        const uniqueResults = [];\n        const seen = new Set();\n        \n        allResults.forEach(item => {\n            const key = `${item.api_url || ''}_${item.vod_id}`;\n            if (!seen.has(key)) {\n                seen.add(key);\n                uniqueResults.push(item);\n            }\n        });\n        \n        return JSON.stringify({\n            code: 200,\n            list: uniqueResults,\n        });\n    } catch (error) {\n        console.error('自定义API聚合搜索处理错误:', error);\n        return JSON.stringify({\n            code: 400,\n            msg: '自定义API聚合搜索处理失败: ' + error.message,\n            list: []\n        });\n    }\n}\n\n// 拦截API请求\n(function() {\n    const originalFetch = window.fetch;\n    \n    window.fetch = async function(input, init) {\n        const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url;\n        \n        if (requestUrl.pathname.startsWith('/api/')) {\n            if (window.isPasswordProtected && window.isPasswordVerified) {\n                if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n                    return;\n                }\n            }\n            try {\n                const data = await handleApiRequest(requestUrl);\n                return new Response(data, {\n                    headers: {\n                        'Content-Type': 'application/json',\n                        'Access-Control-Allow-Origin': '*',\n                    },\n                });\n            } catch (error) {\n                return new Response(JSON.stringify({\n                    code: 500,\n                    msg: '服务器内部错误',\n                }), {\n                    status: 500,\n                    headers: {\n                        'Content-Type': 'application/json',\n                    },\n                });\n            }\n        }\n        \n        // 非API请求使用原始fetch\n        return originalFetch.apply(this, arguments);\n    };\n})();\n\nasync function testSiteAvailability(apiUrl) {\n    try {\n        // 使用更简单的测试查询\n        const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), {\n            // 添加超时\n            signal: AbortSignal.timeout(5000)\n        });\n        \n        // 检查响应状态\n        if (!response.ok) {\n            return false;\n        }\n        \n        const data = await response.json();\n        \n        // 检查API响应的有效性\n        return data && data.code !== 400 && Array.isArray(data.list);\n    } catch (error) {\n        console.error('站点可用性测试失败:', error);\n        return false;\n    }\n}\n"
  },
  {
    "path": "js/app.js",
    "content": "// 全局变量\nlet selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[\"tyyszy\",\"dyttzy\", \"bfzy\", \"ruyi\"]'); // 默认选中资源\nlet customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表\n\n// 添加当前播放的集数索引\nlet currentEpisodeIndex = 0;\n// 添加当前视频的所有集数\nlet currentEpisodes = [];\n// 添加当前视频的标题\nlet currentVideoTitle = '';\n// 全局变量用于倒序状态\nlet episodesReversed = false;\n\n// 页面初始化\ndocument.addEventListener('DOMContentLoaded', function () {\n    // 初始化API复选框\n    initAPICheckboxes();\n\n    // 初始化自定义API列表\n    renderCustomAPIsList();\n\n    // 初始化显示选中的API数量\n    updateSelectedApiCount();\n\n    // 渲染搜索历史\n    renderSearchHistory();\n\n    // 设置默认API选择（如果是第一次加载）\n    if (!localStorage.getItem('hasInitializedDefaults')) {\n        // 默认选中资源\n        selectedAPIs = [\"tyyszy\", \"bfzy\", \"dyttzy\", \"ruyi\"];\n        localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));\n\n        // 默认选中过滤开关\n        localStorage.setItem('yellowFilterEnabled', 'true');\n        localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, 'true');\n\n        // 默认启用豆瓣功能\n        localStorage.setItem('doubanEnabled', 'true');\n\n        // 标记已初始化默认值\n        localStorage.setItem('hasInitializedDefaults', 'true');\n    }\n\n    // 设置黄色内容过滤器开关初始状态\n    const yellowFilterToggle = document.getElementById('yellowFilterToggle');\n    if (yellowFilterToggle) {\n        yellowFilterToggle.checked = localStorage.getItem('yellowFilterEnabled') === 'true';\n    }\n\n    // 设置广告过滤开关初始状态\n    const adFilterToggle = document.getElementById('adFilterToggle');\n    if (adFilterToggle) {\n        adFilterToggle.checked = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true\n    }\n\n    // 设置事件监听器\n    setupEventListeners();\n\n    // 初始检查成人API选中状态\n    setTimeout(checkAdultAPIsSelected, 100);\n});\n\n// 初始化API复选框\nfunction initAPICheckboxes() {\n    const container = document.getElementById('apiCheckboxes');\n    container.innerHTML = '';\n\n    // 添加普通API组标题\n    const normaldiv = document.createElement('div');\n    normaldiv.id = 'normaldiv';\n    normaldiv.className = 'grid grid-cols-2 gap-2';\n    const normalTitle = document.createElement('div');\n    normalTitle.className = 'api-group-title';\n    normalTitle.textContent = '普通资源';\n    normaldiv.appendChild(normalTitle);\n\n    // 创建普通API源的复选框\n    Object.keys(API_SITES).forEach(apiKey => {\n        const api = API_SITES[apiKey];\n        if (api.adult) return; // 跳过成人内容API，稍后添加\n\n        const checked = selectedAPIs.includes(apiKey);\n\n        const checkbox = document.createElement('div');\n        checkbox.className = 'flex items-center';\n        checkbox.innerHTML = `\n            <input type=\"checkbox\" id=\"api_${apiKey}\" \n                   class=\"form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333]\" \n                   ${checked ? 'checked' : ''} \n                   data-api=\"${apiKey}\">\n            <label for=\"api_${apiKey}\" class=\"ml-1 text-xs text-gray-400 truncate\">${api.name}</label>\n        `;\n        normaldiv.appendChild(checkbox);\n\n        // 添加事件监听器\n        checkbox.querySelector('input').addEventListener('change', function () {\n            updateSelectedAPIs();\n            checkAdultAPIsSelected();\n        });\n    });\n    container.appendChild(normaldiv);\n\n    // 添加成人API列表\n    addAdultAPI();\n\n    // 初始检查成人内容状态\n    checkAdultAPIsSelected();\n}\n\n// 添加成人API列表\nfunction addAdultAPI() {\n    // 仅在隐藏设置为false时添加成人API组\n    if (!HIDE_BUILTIN_ADULT_APIS && (localStorage.getItem('yellowFilterEnabled') === 'false')) {\n        const container = document.getElementById('apiCheckboxes');\n\n        // 添加成人API组标题\n        const adultdiv = document.createElement('div');\n        adultdiv.id = 'adultdiv';\n        adultdiv.className = 'grid grid-cols-2 gap-2';\n        const adultTitle = document.createElement('div');\n        adultTitle.className = 'api-group-title adult';\n        adultTitle.innerHTML = `黄色资源采集站 <span class=\"adult-warning\">\n            <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z\" />\n            </svg>\n        </span>`;\n        adultdiv.appendChild(adultTitle);\n\n        // 创建成人API源的复选框\n        Object.keys(API_SITES).forEach(apiKey => {\n            const api = API_SITES[apiKey];\n            if (!api.adult) return; // 仅添加成人内容API\n\n            const checked = selectedAPIs.includes(apiKey);\n\n            const checkbox = document.createElement('div');\n            checkbox.className = 'flex items-center';\n            checkbox.innerHTML = `\n                <input type=\"checkbox\" id=\"api_${apiKey}\" \n                       class=\"form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333] api-adult\" \n                       ${checked ? 'checked' : ''} \n                       data-api=\"${apiKey}\">\n                <label for=\"api_${apiKey}\" class=\"ml-1 text-xs text-pink-400 truncate\">${api.name}</label>\n            `;\n            adultdiv.appendChild(checkbox);\n\n            // 添加事件监听器\n            checkbox.querySelector('input').addEventListener('change', function () {\n                updateSelectedAPIs();\n                checkAdultAPIsSelected();\n            });\n        });\n        container.appendChild(adultdiv);\n    }\n}\n\n// 检查是否有成人API被选中\nfunction checkAdultAPIsSelected() {\n    // 查找所有内置成人API复选框\n    const adultBuiltinCheckboxes = document.querySelectorAll('#apiCheckboxes .api-adult:checked');\n\n    // 查找所有自定义成人API复选框\n    const customApiCheckboxes = document.querySelectorAll('#customApisList .api-adult:checked');\n\n    const hasAdultSelected = adultBuiltinCheckboxes.length > 0 || customApiCheckboxes.length > 0;\n\n    const yellowFilterToggle = document.getElementById('yellowFilterToggle');\n    const yellowFilterContainer = yellowFilterToggle.closest('div').parentNode;\n    const filterDescription = yellowFilterContainer.querySelector('p.filter-description');\n\n    // 如果选择了成人API，禁用黄色内容过滤器\n    if (hasAdultSelected) {\n        yellowFilterToggle.checked = false;\n        yellowFilterToggle.disabled = true;\n        localStorage.setItem('yellowFilterEnabled', 'false');\n\n        // 添加禁用样式\n        yellowFilterContainer.classList.add('filter-disabled');\n\n        // 修改描述文字\n        if (filterDescription) {\n            filterDescription.innerHTML = '<strong class=\"text-pink-300\">选中黄色资源站时无法启用此过滤</strong>';\n        }\n\n        // 移除提示信息（如果存在）\n        const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');\n        if (existingTooltip) {\n            existingTooltip.remove();\n        }\n    } else {\n        // 启用黄色内容过滤器\n        yellowFilterToggle.disabled = false;\n        yellowFilterContainer.classList.remove('filter-disabled');\n\n        // 恢复原来的描述文字\n        if (filterDescription) {\n            filterDescription.innerHTML = '过滤\"伦理片\"等黄色内容';\n        }\n\n        // 移除提示信息\n        const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');\n        if (existingTooltip) {\n            existingTooltip.remove();\n        }\n    }\n}\n\n// 渲染自定义API列表\nfunction renderCustomAPIsList() {\n    const container = document.getElementById('customApisList');\n    if (!container) return;\n\n    if (customAPIs.length === 0) {\n        container.innerHTML = '<p class=\"text-xs text-gray-500 text-center my-2\">未添加自定义API</p>';\n        return;\n    }\n\n    container.innerHTML = '';\n    customAPIs.forEach((api, index) => {\n        const apiItem = document.createElement('div');\n        apiItem.className = 'flex items-center justify-between p-1 mb-1 bg-[#222] rounded';\n        const textColorClass = api.isAdult ? 'text-pink-400' : 'text-white';\n        const adultTag = api.isAdult ? '<span class=\"text-xs text-pink-400 mr-1\">(18+)</span>' : '';\n        // 新增 detail 地址显示\n        const detailLine = api.detail ? `<div class=\"text-xs text-gray-400 truncate\">detail: ${api.detail}</div>` : '';\n        apiItem.innerHTML = `\n            <div class=\"flex items-center flex-1 min-w-0\">\n                <input type=\"checkbox\" id=\"custom_api_${index}\" \n                       class=\"form-checkbox h-3 w-3 text-blue-600 mr-1 ${api.isAdult ? 'api-adult' : ''}\" \n                       ${selectedAPIs.includes('custom_' + index) ? 'checked' : ''} \n                       data-custom-index=\"${index}\">\n                <div class=\"flex-1 min-w-0\">\n                    <div class=\"text-xs font-medium ${textColorClass} truncate\">\n                        ${adultTag}${api.name}\n                    </div>\n                    <div class=\"text-xs text-gray-500 truncate\">${api.url}</div>\n                    ${detailLine}\n                </div>\n            </div>\n            <div class=\"flex items-center\">\n                <button class=\"text-blue-500 hover:text-blue-700 text-xs px-1\" onclick=\"editCustomApi(${index})\">✎</button>\n                <button class=\"text-red-500 hover:text-red-700 text-xs px-1\" onclick=\"removeCustomApi(${index})\">✕</button>\n            </div>\n        `;\n        container.appendChild(apiItem);\n        apiItem.querySelector('input').addEventListener('change', function () {\n            updateSelectedAPIs();\n            checkAdultAPIsSelected();\n        });\n    });\n}\n\n// 编辑自定义API\nfunction editCustomApi(index) {\n    if (index < 0 || index >= customAPIs.length) return;\n    const api = customAPIs[index];\n    document.getElementById('customApiName').value = api.name;\n    document.getElementById('customApiUrl').value = api.url;\n    document.getElementById('customApiDetail').value = api.detail || '';\n    const isAdultInput = document.getElementById('customApiIsAdult');\n    if (isAdultInput) isAdultInput.checked = api.isAdult || false;\n    const form = document.getElementById('addCustomApiForm');\n    if (form) {\n        form.classList.remove('hidden');\n        const buttonContainer = form.querySelector('div:last-child');\n        buttonContainer.innerHTML = `\n            <button onclick=\"updateCustomApi(${index})\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs\">更新</button>\n            <button onclick=\"cancelEditCustomApi()\" class=\"bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs\">取消</button>\n        `;\n    }\n}\n\n// 更新自定义API\nfunction updateCustomApi(index) {\n    if (index < 0 || index >= customAPIs.length) return;\n    const nameInput = document.getElementById('customApiName');\n    const urlInput = document.getElementById('customApiUrl');\n    const detailInput = document.getElementById('customApiDetail');\n    const isAdultInput = document.getElementById('customApiIsAdult');\n    const name = nameInput.value.trim();\n    let url = urlInput.value.trim();\n    const detail = detailInput ? detailInput.value.trim() : '';\n    const isAdult = isAdultInput ? isAdultInput.checked : false;\n    if (!name || !url) {\n        showToast('请输入API名称和链接', 'warning');\n        return;\n    }\n    if (!/^https?:\\/\\/.+/.test(url)) {\n        showToast('API链接格式不正确，需以http://或https://开头', 'warning');\n        return;\n    }\n    if (url.endsWith('/')) url = url.slice(0, -1);\n    // 保存 detail 字段\n    customAPIs[index] = { name, url, detail, isAdult };\n    localStorage.setItem('customAPIs', JSON.stringify(customAPIs));\n    renderCustomAPIsList();\n    checkAdultAPIsSelected();\n    restoreAddCustomApiButtons();\n    nameInput.value = '';\n    urlInput.value = '';\n    if (detailInput) detailInput.value = '';\n    if (isAdultInput) isAdultInput.checked = false;\n    document.getElementById('addCustomApiForm').classList.add('hidden');\n    showToast('已更新自定义API: ' + name, 'success');\n}\n\n// 取消编辑自定义API\nfunction cancelEditCustomApi() {\n    // 清空表单\n    document.getElementById('customApiName').value = '';\n    document.getElementById('customApiUrl').value = '';\n    document.getElementById('customApiDetail').value = '';\n    const isAdultInput = document.getElementById('customApiIsAdult');\n    if (isAdultInput) isAdultInput.checked = false;\n\n    // 隐藏表单\n    document.getElementById('addCustomApiForm').classList.add('hidden');\n\n    // 恢复添加按钮\n    restoreAddCustomApiButtons();\n}\n\n// 恢复自定义API添加按钮\nfunction restoreAddCustomApiButtons() {\n    const form = document.getElementById('addCustomApiForm');\n    const buttonContainer = form.querySelector('div:last-child');\n    buttonContainer.innerHTML = `\n        <button onclick=\"addCustomApi()\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs\">添加</button>\n        <button onclick=\"cancelAddCustomApi()\" class=\"bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs\">取消</button>\n    `;\n}\n\n// 更新选中的API列表\nfunction updateSelectedAPIs() {\n    // 获取所有内置API复选框\n    const builtInApiCheckboxes = document.querySelectorAll('#apiCheckboxes input:checked');\n\n    // 获取选中的内置API\n    const builtInApis = Array.from(builtInApiCheckboxes).map(input => input.dataset.api);\n\n    // 获取选中的自定义API\n    const customApiCheckboxes = document.querySelectorAll('#customApisList input:checked');\n    const customApiIndices = Array.from(customApiCheckboxes).map(input => 'custom_' + input.dataset.customIndex);\n\n    // 合并内置和自定义API\n    selectedAPIs = [...builtInApis, ...customApiIndices];\n\n    // 保存到localStorage\n    localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));\n\n    // 更新显示选中的API数量\n    updateSelectedApiCount();\n}\n\n// 更新选中的API数量显示\nfunction updateSelectedApiCount() {\n    const countEl = document.getElementById('selectedApiCount');\n    if (countEl) {\n        countEl.textContent = selectedAPIs.length;\n    }\n}\n\n// 全选或取消全选API\nfunction selectAllAPIs(selectAll = true, excludeAdult = false) {\n    const checkboxes = document.querySelectorAll('#apiCheckboxes input[type=\"checkbox\"]');\n\n    checkboxes.forEach(checkbox => {\n        if (excludeAdult && checkbox.classList.contains('api-adult')) {\n            checkbox.checked = false;\n        } else {\n            checkbox.checked = selectAll;\n        }\n    });\n\n    updateSelectedAPIs();\n    checkAdultAPIsSelected();\n}\n\n// 显示添加自定义API表单\nfunction showAddCustomApiForm() {\n    const form = document.getElementById('addCustomApiForm');\n    if (form) {\n        form.classList.remove('hidden');\n    }\n}\n\n// 取消添加自定义API - 修改函数来重用恢复按钮逻辑\nfunction cancelAddCustomApi() {\n    const form = document.getElementById('addCustomApiForm');\n    if (form) {\n        form.classList.add('hidden');\n        document.getElementById('customApiName').value = '';\n        document.getElementById('customApiUrl').value = '';\n        document.getElementById('customApiDetail').value = '';\n        const isAdultInput = document.getElementById('customApiIsAdult');\n        if (isAdultInput) isAdultInput.checked = false;\n\n        // 确保按钮是添加按钮\n        restoreAddCustomApiButtons();\n    }\n}\n\n// 添加自定义API\nfunction addCustomApi() {\n    const nameInput = document.getElementById('customApiName');\n    const urlInput = document.getElementById('customApiUrl');\n    const detailInput = document.getElementById('customApiDetail');\n    const isAdultInput = document.getElementById('customApiIsAdult');\n    const name = nameInput.value.trim();\n    let url = urlInput.value.trim();\n    const detail = detailInput ? detailInput.value.trim() : '';\n    const isAdult = isAdultInput ? isAdultInput.checked : false;\n    if (!name || !url) {\n        showToast('请输入API名称和链接', 'warning');\n        return;\n    }\n    if (!/^https?:\\/\\/.+/.test(url)) {\n        showToast('API链接格式不正确，需以http://或https://开头', 'warning');\n        return;\n    }\n    if (url.endsWith('/')) {\n        url = url.slice(0, -1);\n    }\n    // 保存 detail 字段\n    customAPIs.push({ name, url, detail, isAdult });\n    localStorage.setItem('customAPIs', JSON.stringify(customAPIs));\n    const newApiIndex = customAPIs.length - 1;\n    selectedAPIs.push('custom_' + newApiIndex);\n    localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));\n\n    // 重新渲染自定义API列表\n    renderCustomAPIsList();\n    updateSelectedApiCount();\n    checkAdultAPIsSelected();\n    nameInput.value = '';\n    urlInput.value = '';\n    if (detailInput) detailInput.value = '';\n    if (isAdultInput) isAdultInput.checked = false;\n    document.getElementById('addCustomApiForm').classList.add('hidden');\n    showToast('已添加自定义API: ' + name, 'success');\n}\n\n// 移除自定义API\nfunction removeCustomApi(index) {\n    if (index < 0 || index >= customAPIs.length) return;\n\n    const apiName = customAPIs[index].name;\n\n    // 从列表中移除API\n    customAPIs.splice(index, 1);\n    localStorage.setItem('customAPIs', JSON.stringify(customAPIs));\n\n    // 从选中列表中移除此API\n    const customApiId = 'custom_' + index;\n    selectedAPIs = selectedAPIs.filter(id => id !== customApiId);\n\n    // 更新大于此索引的自定义API索引\n    selectedAPIs = selectedAPIs.map(id => {\n        if (id.startsWith('custom_')) {\n            const currentIndex = parseInt(id.replace('custom_', ''));\n            if (currentIndex > index) {\n                return 'custom_' + (currentIndex - 1);\n            }\n        }\n        return id;\n    });\n\n    localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));\n\n    // 重新渲染自定义API列表\n    renderCustomAPIsList();\n\n    // 更新选中的API数量\n    updateSelectedApiCount();\n\n    // 重新检查成人API选中状态\n    checkAdultAPIsSelected();\n\n    showToast('已移除自定义API: ' + apiName, 'info');\n}\n\nfunction toggleSettings(e) {\n    const settingsPanel = document.getElementById('settingsPanel');\n    if (!settingsPanel) return;\n\n    if (settingsPanel.classList.contains('show')) {\n        settingsPanel.classList.remove('show');\n    } else {\n        settingsPanel.classList.add('show');\n    }\n\n    if (e) {\n        e.preventDefault();\n        e.stopPropagation();\n    }\n}\n\n// 设置事件监听器\nfunction setupEventListeners() {\n    // 回车搜索\n    document.getElementById('searchInput').addEventListener('keypress', function (e) {\n        if (e.key === 'Enter') {\n            search();\n        }\n    });\n\n    // 点击外部关闭设置面板和历史记录面板\n    document.addEventListener('click', function (e) {\n        // 关闭设置面板\n        const settingsPanel = document.querySelector('#settingsPanel.show');\n        const settingsButton = document.querySelector('#settingsPanel .close-btn');\n\n        if (settingsPanel && settingsButton &&\n            !settingsPanel.contains(e.target) &&\n            !settingsButton.contains(e.target)) {\n            settingsPanel.classList.remove('show');\n        }\n\n        // 关闭历史记录面板\n        const historyPanel = document.querySelector('#historyPanel.show');\n        const historyButton = document.querySelector('#historyPanel .close-btn');\n\n        if (historyPanel && historyButton &&\n            !historyPanel.contains(e.target) &&\n            !historyButton.contains(e.target)) {\n            historyPanel.classList.remove('show');\n        }\n    });\n\n    // 黄色内容过滤开关事件绑定\n    const yellowFilterToggle = document.getElementById('yellowFilterToggle');\n    if (yellowFilterToggle) {\n        yellowFilterToggle.addEventListener('change', function (e) {\n            localStorage.setItem('yellowFilterEnabled', e.target.checked);\n\n            // 控制黄色内容接口的显示状态\n            const adultdiv = document.getElementById('adultdiv');\n            if (adultdiv) {\n                if (e.target.checked === true) {\n                    adultdiv.style.display = 'none';\n                } else if (e.target.checked === false) {\n                    adultdiv.style.display = ''\n                }\n            } else {\n                // 添加成人API列表\n                addAdultAPI();\n            }\n        });\n    }\n\n    // 广告过滤开关事件绑定\n    const adFilterToggle = document.getElementById('adFilterToggle');\n    if (adFilterToggle) {\n        adFilterToggle.addEventListener('change', function (e) {\n            localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, e.target.checked);\n        });\n    }\n}\n\n// 重置搜索区域\nfunction resetSearchArea() {\n    // 清理搜索结果\n    document.getElementById('results').innerHTML = '';\n    document.getElementById('searchInput').value = '';\n\n    // 恢复搜索区域的样式\n    document.getElementById('searchArea').classList.add('flex-1');\n    document.getElementById('searchArea').classList.remove('mb-8');\n    document.getElementById('resultsArea').classList.add('hidden');\n\n    // 确保页脚正确显示，移除相对定位\n    const footer = document.querySelector('.footer');\n    if (footer) {\n        footer.style.position = '';\n    }\n\n    // 如果有豆瓣功能，检查是否需要显示豆瓣推荐区域\n    if (typeof updateDoubanVisibility === 'function') {\n        updateDoubanVisibility();\n    }\n\n    // 重置URL为主页\n    try {\n        window.history.pushState(\n            {},\n            `LibreTV - 免费在线视频搜索与观看平台`,\n            `/`\n        );\n        // 更新页面标题\n        document.title = `LibreTV - 免费在线视频搜索与观看平台`;\n    } catch (e) {\n        console.error('更新浏览器历史失败:', e);\n    }\n}\n\n// 获取自定义API信息\nfunction getCustomApiInfo(customApiIndex) {\n    const index = parseInt(customApiIndex);\n    if (isNaN(index) || index < 0 || index >= customAPIs.length) {\n        return null;\n    }\n    return customAPIs[index];\n}\n\n// 搜索功能 - 修改为支持多选API和多页结果\nasync function search() {\n    // 强化的密码保护校验 - 防止绕过\n    try {\n        if (window.ensurePasswordProtection) {\n            window.ensurePasswordProtection();\n        } else {\n            // 兼容性检查\n            if (window.isPasswordProtected && window.isPasswordVerified) {\n                if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n                    showPasswordModal && showPasswordModal();\n                    return;\n                }\n            }\n        }\n    } catch (error) {\n        console.warn('Password protection check failed:', error.message);\n        return;\n    }\n    const query = document.getElementById('searchInput').value.trim();\n\n    if (!query) {\n        showToast('请输入搜索内容', 'info');\n        return;\n    }\n\n    if (selectedAPIs.length === 0) {\n        showToast('请至少选择一个API源', 'warning');\n        return;\n    }\n\n    showLoading();\n\n    try {\n        // 保存搜索历史\n        saveSearchHistory(query);\n\n        // 从所有选中的API源搜索\n        let allResults = [];\n        const searchPromises = selectedAPIs.map(apiId => \n            searchByAPIAndKeyWord(apiId, query)\n        );\n\n        // 等待所有搜索请求完成\n        const resultsArray = await Promise.all(searchPromises);\n\n        // 合并所有结果\n        resultsArray.forEach(results => {\n            if (Array.isArray(results) && results.length > 0) {\n                allResults = allResults.concat(results);\n            }\n        });\n\n        // 对搜索结果进行排序：按名称优先，名称相同时按接口源排序\n        allResults.sort((a, b) => {\n            // 首先按照视频名称排序\n            const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');\n            if (nameCompare !== 0) return nameCompare;\n            \n            // 如果名称相同，则按照来源排序\n            return (a.source_name || '').localeCompare(b.source_name || '');\n        });\n\n        // 更新搜索结果计数\n        const searchResultsCount = document.getElementById('searchResultsCount');\n        if (searchResultsCount) {\n            searchResultsCount.textContent = allResults.length;\n        }\n\n        // 显示结果区域，调整搜索区域\n        document.getElementById('searchArea').classList.remove('flex-1');\n        document.getElementById('searchArea').classList.add('mb-8');\n        document.getElementById('resultsArea').classList.remove('hidden');\n\n        // 隐藏豆瓣推荐区域（如果存在）\n        const doubanArea = document.getElementById('doubanArea');\n        if (doubanArea) {\n            doubanArea.classList.add('hidden');\n        }\n\n        const resultsDiv = document.getElementById('results');\n\n        // 如果没有结果\n        if (!allResults || allResults.length === 0) {\n            resultsDiv.innerHTML = `\n                <div class=\"col-span-full text-center py-16\">\n                    <svg class=\"mx-auto h-12 w-12 text-gray-500\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" \n                              d=\"M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z\" />\n                    </svg>\n                    <h3 class=\"mt-2 text-lg font-medium text-gray-400\">没有找到匹配的结果</h3>\n                    <p class=\"mt-1 text-sm text-gray-500\">请尝试其他关键词或更换数据源</p>\n                </div>\n            `;\n            hideLoading();\n            return;\n        }\n\n        // 有搜索结果时，才更新URL\n        try {\n            // 使用URI编码确保特殊字符能够正确显示\n            const encodedQuery = encodeURIComponent(query);\n            // 使用HTML5 History API更新URL，不刷新页面\n            window.history.pushState(\n                { search: query },\n                `搜索: ${query} - LibreTV`,\n                `/s=${encodedQuery}`\n            );\n            // 更新页面标题\n            document.title = `搜索: ${query} - LibreTV`;\n        } catch (e) {\n            console.error('更新浏览器历史失败:', e);\n            // 如果更新URL失败，继续执行搜索\n        }\n\n        // 处理搜索结果过滤：如果启用了黄色内容过滤，则过滤掉分类含有敏感内容的项目\n        const yellowFilterEnabled = localStorage.getItem('yellowFilterEnabled') === 'true';\n        if (yellowFilterEnabled) {\n            const banned = ['伦理片', '福利', '里番动漫', '门事件', '萝莉少女', '制服诱惑', '国产传媒', 'cosplay', '黑丝诱惑', '无码', '日本无码', '有码', '日本有码', 'SWAG', '网红主播', '色情片', '同性片', '福利视频', '福利片'];\n            allResults = allResults.filter(item => {\n                const typeName = item.type_name || '';\n                return !banned.some(keyword => typeName.includes(keyword));\n            });\n        }\n\n        // 添加XSS保护，使用textContent和属性转义\n        const safeResults = allResults.map(item => {\n            const safeId = item.vod_id ? item.vod_id.toString().replace(/[^\\w-]/g, '') : '';\n            const safeName = (item.vod_name || '').toString()\n                .replace(/</g, '&lt;')\n                .replace(/>/g, '&gt;')\n                .replace(/\"/g, '&quot;');\n            const sourceInfo = item.source_name ?\n                `<span class=\"bg-[#222] text-xs px-1.5 py-0.5 rounded-full\">${item.source_name}</span>` : '';\n            const sourceCode = item.source_code || '';\n\n            // 添加API URL属性，用于详情获取\n            const apiUrlAttr = item.api_url ?\n                `data-api-url=\"${item.api_url.replace(/\"/g, '&quot;')}\"` : '';\n\n            // 修改为水平卡片布局，图片在左侧，文本在右侧，并优化样式\n            const hasCover = item.vod_pic && item.vod_pic.startsWith('http');\n\n            return `\n                <div class=\"card-hover bg-[#111] rounded-lg overflow-hidden cursor-pointer transition-all hover:scale-[1.02] h-full shadow-sm hover:shadow-md\" \n                     onclick=\"showDetails('${safeId}','${safeName}','${sourceCode}')\" ${apiUrlAttr}>\n                    <div class=\"flex h-full\">\n                        ${hasCover ? `\n                        <div class=\"relative flex-shrink-0 search-card-img-container\">\n                            <img src=\"${item.vod_pic}\" alt=\"${safeName}\" \n                                 class=\"h-full w-full object-cover transition-transform hover:scale-110\" \n                                 onerror=\"this.onerror=null; this.src='https://via.placeholder.com/300x450?text=无封面'; this.classList.add('object-contain');\" \n                                 loading=\"lazy\">\n                            <div class=\"absolute inset-0 bg-gradient-to-r from-black/30 to-transparent\"></div>\n                        </div>` : ''}\n                        \n                        <div class=\"p-2 flex flex-col flex-grow\">\n                            <div class=\"flex-grow\">\n                                <h3 class=\"font-semibold mb-2 break-words line-clamp-2 ${hasCover ? '' : 'text-center'}\" title=\"${safeName}\">${safeName}</h3>\n                                \n                                <div class=\"flex flex-wrap ${hasCover ? '' : 'justify-center'} gap-1 mb-2\">\n                                    ${(item.type_name || '').toString().replace(/</g, '&lt;') ?\n                    `<span class=\"text-xs py-0.5 px-1.5 rounded bg-opacity-20 bg-blue-500 text-blue-300\">\n                                          ${(item.type_name || '').toString().replace(/</g, '&lt;')}\n                                      </span>` : ''}\n                                    ${(item.vod_year || '') ?\n                    `<span class=\"text-xs py-0.5 px-1.5 rounded bg-opacity-20 bg-purple-500 text-purple-300\">\n                                          ${item.vod_year}\n                                      </span>` : ''}\n                                </div>\n                                <p class=\"text-gray-400 line-clamp-2 overflow-hidden ${hasCover ? '' : 'text-center'} mb-2\">\n                                    ${(item.vod_remarks || '暂无介绍').toString().replace(/</g, '&lt;')}\n                                </p>\n                            </div>\n                            \n                            <div class=\"flex justify-between items-center mt-1 pt-1 border-t border-gray-800\">\n                                ${sourceInfo ? `<div>${sourceInfo}</div>` : '<div></div>'}\n                                <!-- 接口名称过长会被挤变形\n                                <div>\n                                    <span class=\"text-gray-500 flex items-center hover:text-blue-400 transition-colors\">\n                                        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z\" />\n                                        </svg>\n                                        播放\n                                    </span>\n                                </div>\n                                -->\n                            </div>\n                        </div>\n                    </div>\n                </div>\n            `;\n        }).join('');\n\n        resultsDiv.innerHTML = safeResults;\n    } catch (error) {\n        console.error('搜索错误:', error);\n        if (error.name === 'AbortError') {\n            showToast('搜索请求超时，请检查网络连接', 'error');\n        } else {\n            showToast('搜索请求失败，请稍后重试', 'error');\n        }\n    } finally {\n        hideLoading();\n    }\n}\n\n// 切换清空按钮的显示状态\nfunction toggleClearButton() {\n    const searchInput = document.getElementById('searchInput');\n    const clearButton = document.getElementById('clearSearchInput');\n    if (searchInput.value !== '') {\n        clearButton.classList.remove('hidden');\n    } else {\n        clearButton.classList.add('hidden');\n    }\n}\n\n// 清空搜索框内容\nfunction clearSearchInput() {\n    const searchInput = document.getElementById('searchInput');\n    searchInput.value = '';\n    const clearButton = document.getElementById('clearSearchInput');\n    clearButton.classList.add('hidden');\n}\n\n// 劫持搜索框的value属性以检测外部修改\nfunction hookInput() {\n    const input = document.getElementById('searchInput');\n    const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value');\n\n    // 重写 value 属性的 getter 和 setter\n    Object.defineProperty(input, 'value', {\n        get: function () {\n            // 确保读取时返回字符串（即使原始值为 undefined/null）\n            const originalValue = descriptor.get.call(this);\n            return originalValue != null ? String(originalValue) : '';\n        },\n        set: function (value) {\n            // 显式将值转换为字符串后写入\n            const strValue = String(value);\n            descriptor.set.call(this, strValue);\n            this.dispatchEvent(new Event('input', { bubbles: true }));\n        }\n    });\n\n    // 初始化输入框值为空字符串（避免初始值为 undefined）\n    input.value = '';\n}\ndocument.addEventListener('DOMContentLoaded', hookInput);\n\n// 显示详情 - 修改为支持自定义API\nasync function showDetails(id, vod_name, sourceCode) {\n    // 密码保护校验\n    if (window.isPasswordProtected && window.isPasswordVerified) {\n        if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n            showPasswordModal && showPasswordModal();\n            return;\n        }\n    }\n    if (!id) {\n        showToast('视频ID无效', 'error');\n        return;\n    }\n\n    showLoading();\n    try {\n        // 构建API参数\n        let apiParams = '';\n\n        // 处理自定义API源\n        if (sourceCode.startsWith('custom_')) {\n            const customIndex = sourceCode.replace('custom_', '');\n            const customApi = getCustomApiInfo(customIndex);\n            if (!customApi) {\n                showToast('自定义API配置无效', 'error');\n                hideLoading();\n                return;\n            }\n            // 传递 detail 字段\n            if (customApi.detail) {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';\n            } else {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';\n            }\n        } else {\n            // 内置API\n            apiParams = '&source=' + sourceCode;\n        }\n\n        // Add a timestamp to prevent caching\n        const timestamp = new Date().getTime();\n        const cacheBuster = `&_t=${timestamp}`;\n        const response = await fetch(`/api/detail?id=${encodeURIComponent(id)}${apiParams}${cacheBuster}`);\n\n        const data = await response.json();\n\n        const modal = document.getElementById('modal');\n        const modalTitle = document.getElementById('modalTitle');\n        const modalContent = document.getElementById('modalContent');\n\n        // 显示来源信息\n        const sourceName = data.videoInfo && data.videoInfo.source_name ?\n            ` <span class=\"text-sm font-normal text-gray-400\">(${data.videoInfo.source_name})</span>` : '';\n\n        // 不对标题进行截断处理，允许完整显示\n        modalTitle.innerHTML = `<span class=\"break-words\">${vod_name || '未知视频'}</span>${sourceName}`;\n        currentVideoTitle = vod_name || '未知视频';\n\n        if (data.episodes && data.episodes.length > 0) {\n            // 构建详情信息HTML\n            let detailInfoHtml = '';\n            if (data.videoInfo) {\n                // Prepare description text, strip HTML and trim whitespace\n                const descriptionText = data.videoInfo.desc ? data.videoInfo.desc.replace(/<[^>]+>/g, '').trim() : '';\n\n                // Check if there's any actual grid content\n                const hasGridContent = data.videoInfo.type || data.videoInfo.year || data.videoInfo.area || data.videoInfo.director || data.videoInfo.actor || data.videoInfo.remarks;\n\n                if (hasGridContent || descriptionText) { // Only build if there's something to show\n                    detailInfoHtml = `\n                <div class=\"modal-detail-info\">\n                    ${hasGridContent ? `\n                    <div class=\"detail-grid\">\n                        ${data.videoInfo.type ? `<div class=\"detail-item\"><span class=\"detail-label\">类型:</span> <span class=\"detail-value\">${data.videoInfo.type}</span></div>` : ''}\n                        ${data.videoInfo.year ? `<div class=\"detail-item\"><span class=\"detail-label\">年份:</span> <span class=\"detail-value\">${data.videoInfo.year}</span></div>` : ''}\n                        ${data.videoInfo.area ? `<div class=\"detail-item\"><span class=\"detail-label\">地区:</span> <span class=\"detail-value\">${data.videoInfo.area}</span></div>` : ''}\n                        ${data.videoInfo.director ? `<div class=\"detail-item\"><span class=\"detail-label\">导演:</span> <span class=\"detail-value\">${data.videoInfo.director}</span></div>` : ''}\n                        ${data.videoInfo.actor ? `<div class=\"detail-item\"><span class=\"detail-label\">主演:</span> <span class=\"detail-value\">${data.videoInfo.actor}</span></div>` : ''}\n                        ${data.videoInfo.remarks ? `<div class=\"detail-item\"><span class=\"detail-label\">备注:</span> <span class=\"detail-value\">${data.videoInfo.remarks}</span></div>` : ''}\n                    </div>` : ''}\n                    ${descriptionText ? `\n                    <div class=\"detail-desc\">\n                        <p class=\"detail-label\">简介:</p>\n                        <p class=\"detail-desc-content\">${descriptionText}</p>\n                    </div>` : ''}\n                </div>\n                `;\n                }\n            }\n\n            currentEpisodes = data.episodes;\n            currentEpisodeIndex = 0;\n\n            modalContent.innerHTML = `\n                ${detailInfoHtml}\n                <div class=\"flex flex-wrap items-center justify-between mb-4 gap-2\">\n                    <div class=\"flex items-center gap-2\">\n                        <button onclick=\"toggleEpisodeOrder('${sourceCode}', '${id}')\" \n                                class=\"px-3 py-1.5 bg-[#333] hover:bg-[#444] border border-[#444] rounded text-sm transition-colors flex items-center gap-1\">\n                            <svg class=\"w-4 h-4 transform ${episodesReversed ? 'rotate-180' : ''}\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 14l-7 7m0 0l-7-7m7 7V3\"></path>\n                            </svg>\n                            <span>${episodesReversed ? '正序排列' : '倒序排列'}</span>\n                        </button>\n                        <span class=\"text-gray-400 text-sm\">共 ${data.episodes.length} 集</span>\n                    </div>\n                    <button onclick=\"copyLinks()\" class=\"px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors\">\n                        复制链接\n                    </button>\n                </div>\n                <div id=\"episodesGrid\" class=\"grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2\">\n                    ${renderEpisodes(vod_name, sourceCode, id)}\n                </div>\n            `;\n        } else {\n            modalContent.innerHTML = `\n                <div class=\"text-center py-8\">\n                    <div class=\"text-red-400 mb-2\">❌ 未找到播放资源</div>\n                    <div class=\"text-gray-500 text-sm\">该视频可能暂时无法播放，请尝试其他视频</div>\n                </div>\n            `;\n        }\n\n        modal.classList.remove('hidden');\n    } catch (error) {\n        console.error('获取详情错误:', error);\n        showToast('获取详情失败，请稍后重试', 'error');\n    } finally {\n        hideLoading();\n    }\n}\n\n// 更新播放视频函数，修改为使用/watch路径而不是直接打开player.html\nfunction playVideo(url, vod_name, sourceCode, episodeIndex = 0, vodId = '') {\n    // 密码保护校验\n    if (window.isPasswordProtected && window.isPasswordVerified) {\n        if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n            showPasswordModal && showPasswordModal();\n            return;\n        }\n    }\n\n    // 获取当前路径作为返回页面\n    let currentPath = window.location.href;\n\n    // 构建播放页面URL，使用watch.html作为中间跳转页\n    let watchUrl = `watch.html?id=${vodId || ''}&source=${sourceCode || ''}&url=${encodeURIComponent(url)}&index=${episodeIndex}&title=${encodeURIComponent(vod_name || '')}`;\n\n    // 添加返回URL参数\n    if (currentPath.includes('index.html') || currentPath.endsWith('/')) {\n        watchUrl += `&back=${encodeURIComponent(currentPath)}`;\n    }\n\n    // 保存当前状态到localStorage\n    try {\n        localStorage.setItem('currentVideoTitle', vod_name || '未知视频');\n        localStorage.setItem('currentEpisodes', JSON.stringify(currentEpisodes));\n        localStorage.setItem('currentEpisodeIndex', episodeIndex);\n        localStorage.setItem('currentSourceCode', sourceCode || '');\n        localStorage.setItem('lastPlayTime', Date.now());\n        localStorage.setItem('lastSearchPage', currentPath);\n        localStorage.setItem('lastPageUrl', currentPath);  // 确保保存返回页面URL\n    } catch (e) {\n        console.error('保存播放状态失败:', e);\n    }\n\n    // 在当前标签页中打开播放页面\n    window.location.href = watchUrl;\n}\n\n// 弹出播放器页面\nfunction showVideoPlayer(url) {\n    // 在打开播放器前，隐藏详情弹窗\n    const detailModal = document.getElementById('modal');\n    if (detailModal) {\n        detailModal.classList.add('hidden');\n    }\n    // 临时隐藏搜索结果和豆瓣区域，防止高度超出播放器而出现滚动条\n    document.getElementById('resultsArea').classList.add('hidden');\n    document.getElementById('doubanArea').classList.add('hidden');\n    // 在框架中打开播放页面\n    videoPlayerFrame = document.createElement('iframe');\n    videoPlayerFrame.id = 'VideoPlayerFrame';\n    videoPlayerFrame.className = 'fixed w-full h-screen z-40';\n    videoPlayerFrame.src = url;\n    document.body.appendChild(videoPlayerFrame);\n    // 将焦点移入iframe\n    videoPlayerFrame.focus();\n}\n\n// 关闭播放器页面\nfunction closeVideoPlayer(home = false) {\n    videoPlayerFrame = document.getElementById('VideoPlayerFrame');\n    if (videoPlayerFrame) {\n        videoPlayerFrame.remove();\n        // 恢复搜索结果显示\n        document.getElementById('resultsArea').classList.remove('hidden');\n        // 关闭播放器时也隐藏详情弹窗\n        const detailModal = document.getElementById('modal');\n        if (detailModal) {\n            detailModal.classList.add('hidden');\n        }\n        // 如果启用豆瓣区域则显示豆瓣区域\n        if (localStorage.getItem('doubanEnabled') === 'true') {\n            document.getElementById('doubanArea').classList.remove('hidden');\n        }\n    }\n    if (home) {\n        // 刷新主页\n        window.location.href = '/'\n    }\n}\n\n// 播放上一集\nfunction playPreviousEpisode(sourceCode) {\n    if (currentEpisodeIndex > 0) {\n        const prevIndex = currentEpisodeIndex - 1;\n        const prevUrl = currentEpisodes[prevIndex];\n        playVideo(prevUrl, currentVideoTitle, sourceCode, prevIndex);\n    }\n}\n\n// 播放下一集\nfunction playNextEpisode(sourceCode) {\n    if (currentEpisodeIndex < currentEpisodes.length - 1) {\n        const nextIndex = currentEpisodeIndex + 1;\n        const nextUrl = currentEpisodes[nextIndex];\n        playVideo(nextUrl, currentVideoTitle, sourceCode, nextIndex);\n    }\n}\n\n// 处理播放器加载错误\nfunction handlePlayerError() {\n    hideLoading();\n    showToast('视频播放加载失败，请尝试其他视频源', 'error');\n}\n\n// 辅助函数用于渲染剧集按钮（使用当前的排序状态）\nfunction renderEpisodes(vodName, sourceCode, vodId) {\n    const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;\n    return episodes.map((episode, index) => {\n        // 根据倒序状态计算真实的剧集索引\n        const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;\n        return `\n            <button id=\"episode-${realIndex}\" onclick=\"playVideo('${episode}','${vodName.replace(/\"/g, '&quot;')}', '${sourceCode}', ${realIndex}, '${vodId}')\" \n                    class=\"px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors text-center episode-btn\">\n                ${realIndex + 1}\n            </button>\n        `;\n    }).join('');\n}\n\n// 复制视频链接到剪贴板\nfunction copyLinks() {\n    const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;\n    const linkList = episodes.join('\\r\\n');\n    navigator.clipboard.writeText(linkList).then(() => {\n        showToast('播放链接已复制', 'success');\n    }).catch(err => {\n        showToast('复制失败，请检查浏览器权限', 'error');\n    });\n}\n\n// 切换排序状态的函数\nfunction toggleEpisodeOrder(sourceCode, vodId) {\n    episodesReversed = !episodesReversed;\n    // 重新渲染剧集区域，使用 currentVideoTitle 作为视频标题\n    const episodesGrid = document.getElementById('episodesGrid');\n    if (episodesGrid) {\n        episodesGrid.innerHTML = renderEpisodes(currentVideoTitle, sourceCode, vodId);\n    }\n\n    // 更新按钮文本和箭头方向\n    const toggleBtn = document.querySelector(`button[onclick=\"toggleEpisodeOrder('${sourceCode}', '${vodId}')\"]`);\n    if (toggleBtn) {\n        toggleBtn.querySelector('span').textContent = episodesReversed ? '正序排列' : '倒序排列';\n        const arrowIcon = toggleBtn.querySelector('svg');\n        if (arrowIcon) {\n            arrowIcon.style.transform = episodesReversed ? 'rotate(180deg)' : 'rotate(0deg)';\n        }\n    }\n}\n\n// 从URL导入配置\nasync function importConfigFromUrl() {\n    // 创建模态框元素\n    let modal = document.getElementById('importUrlModal');\n    if (modal) {\n        document.body.removeChild(modal);\n    }\n\n    modal = document.createElement('div');\n    modal.id = 'importUrlModal';\n    modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';\n\n    modal.innerHTML = `\n        <div class=\"bg-[#191919] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto relative\">\n            <button id=\"closeUrlModal\" class=\"absolute top-4 right-4 text-gray-400 hover:text-white text-xl\">&times;</button>\n            \n            <h3 class=\"text-xl font-bold mb-4\">从URL导入配置</h3>\n            \n            <div class=\"mb-4\">\n                <input type=\"text\" id=\"configUrl\" placeholder=\"输入配置文件URL\" \n                       class=\"w-full px-3 py-2 bg-[#222] border border-[#333] rounded-lg text-white focus:outline-none focus:ring-1 focus:ring-blue-500\">\n            </div>\n            \n            <div class=\"flex justify-end space-x-2\">\n                <button id=\"confirmUrlImport\" class=\"bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded\">导入</button>\n                <button id=\"cancelUrlImport\" class=\"bg-[#444] hover:bg-[#555] text-white px-4 py-2 rounded\">取消</button>\n            </div>\n        </div>`;\n\n    document.body.appendChild(modal);\n\n    // 关闭按钮事件\n    document.getElementById('closeUrlModal').addEventListener('click', () => {\n        document.body.removeChild(modal);\n    });\n\n    // 取消按钮事件\n    document.getElementById('cancelUrlImport').addEventListener('click', () => {\n        document.body.removeChild(modal);\n    });\n\n    // 确认导入按钮事件\n    document.getElementById('confirmUrlImport').addEventListener('click', async () => {\n        const url = document.getElementById('configUrl').value.trim();\n        if (!url) {\n            showToast('请输入配置文件URL', 'warning');\n            return;\n        }\n\n        // 验证URL格式\n        try {\n            const urlObj = new URL(url);\n            if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {\n                showToast('URL必须以http://或https://开头', 'warning');\n                return;\n            }\n        } catch (e) {\n            showToast('URL格式不正确', 'warning');\n            return;\n        }\n\n        showLoading('正在从URL导入配置...');\n\n        try {\n            // 获取配置文件 - 直接请求URL\n            const response = await fetch(url, {\n                mode: 'cors',\n                headers: {\n                    'Accept': 'application/json'\n                }\n            });\n            if (!response.ok) throw '获取配置文件失败';\n\n            // 验证响应内容类型\n            const contentType = response.headers.get('content-type');\n            if (!contentType || !contentType.includes('application/json')) {\n                throw '响应不是有效的JSON格式';\n            }\n\n            const config = await response.json();\n            if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确';\n\n            // 验证哈希\n            const dataHash = await sha256(JSON.stringify(config.data));\n            if (dataHash !== config.hash) throw '配置文件哈希值不匹配';\n\n            // 导入配置\n            for (let item in config.data) {\n                localStorage.setItem(item, config.data[item]);\n            }\n\n            showToast('配置文件导入成功，3 秒后自动刷新本页面。', 'success');\n            setTimeout(() => {\n                window.location.reload();\n            }, 3000);\n        } catch (error) {\n            const message = typeof error === 'string' ? error : '导入配置失败';\n            showToast(`从URL导入配置出错 (${message})`, 'error');\n        } finally {\n            hideLoading();\n            document.body.removeChild(modal);\n        }\n    });\n\n    // 点击模态框外部关闭\n    modal.addEventListener('click', (e) => {\n        if (e.target === modal) {\n            document.body.removeChild(modal);\n        }\n    });\n}\n\n// 配置文件导入功能\nasync function importConfig() {\n    showImportBox(async (file) => {\n        try {\n            // 检查文件类型\n            if (!(file.type === 'application/json' || file.name.endsWith('.json'))) throw '文件类型不正确';\n\n            // 检查文件大小\n            if (file.size > 1024 * 1024 * 10) throw new Error('文件大小超过 10MB');\n\n            // 读取文件内容\n            const content = await new Promise((resolve, reject) => {\n                const reader = new FileReader();\n                reader.onload = () => resolve(reader.result);\n                reader.onerror = () => reject('文件读取失败');\n                reader.readAsText(file);\n            });\n\n            // 解析并验证配置\n            const config = JSON.parse(content);\n            if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确';\n\n            // 验证哈希\n            const dataHash = await sha256(JSON.stringify(config.data));\n            if (dataHash !== config.hash) throw '配置文件哈希值不匹配';\n\n            // 导入配置\n            for (let item in config.data) {\n                localStorage.setItem(item, config.data[item]);\n            }\n\n            showToast('配置文件导入成功，3 秒后自动刷新本页面。', 'success');\n            setTimeout(() => {\n                window.location.reload();\n            }, 3000);\n        } catch (error) {\n            const message = typeof error === 'string' ? error : '配置文件格式错误';\n            showToast(`配置文件读取出错 (${message})`, 'error');\n        }\n    });\n}\n\n// 配置文件导出功能\nasync function exportConfig() {\n    // 存储配置数据\n    const config = {};\n    const items = {};\n\n    const settingsToExport = [\n        'selectedAPIs',\n        'customAPIs',\n        'yellowFilterEnabled',\n        'adFilteringEnabled',\n        'doubanEnabled',\n        'hasInitializedDefaults'\n    ];\n\n    // 导出设置项\n    settingsToExport.forEach(key => {\n        const value = localStorage.getItem(key);\n        if (value !== null) {\n            items[key] = value;\n        }\n    });\n\n    // 导出历史记录\n    const viewingHistory = localStorage.getItem('viewingHistory');\n    if (viewingHistory) {\n        items['viewingHistory'] = viewingHistory;\n    }\n\n    const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY);\n    if (searchHistory) {\n        items[SEARCH_HISTORY_KEY] = searchHistory;\n    }\n\n    const times = Date.now().toString();\n    config['name'] = 'LibreTV-Settings';  // 配置文件名，用于校验\n    config['time'] = times;               // 配置文件生成时间\n    config['cfgVer'] = '1.0.0';           // 配置文件版本\n    config['data'] = items;               // 配置文件数据\n    config['hash'] = await sha256(JSON.stringify(config['data']));  // 计算数据的哈希值，用于校验\n\n    // 将配置数据保存为 JSON 文件\n    saveStringAsFile(JSON.stringify(config), 'LibreTV-Settings_' + times + '.json');\n}\n\n// 将字符串保存为文件\nfunction saveStringAsFile(content, fileName) {\n    // 创建Blob对象并指定类型\n    const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });\n    // 生成临时URL\n    const url = window.URL.createObjectURL(blob);\n    // 创建<a>标签并触发下载\n    const a = document.createElement('a');\n    a.href = url;\n    a.download = fileName;\n    document.body.appendChild(a);\n    a.click();\n    // 清理临时对象\n    document.body.removeChild(a);\n    window.URL.revokeObjectURL(url);\n}\n\n// 移除Node.js的require语句，因为这是在浏览器环境中运行的\n"
  },
  {
    "path": "js/config.js",
    "content": "// 全局常量配置\nconst PROXY_URL = '/proxy/';    // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写)\n// const HOPLAYER_URL = 'https://hoplayer.com/index.html';\nconst SEARCH_HISTORY_KEY = 'videoSearchHistory';\nconst MAX_HISTORY_ITEMS = 5;\n\n// 密码保护配置\n// 注意：PASSWORD 环境变量是必需的，所有部署都必须设置密码以确保安全\nconst PASSWORD_CONFIG = {\n    localStorageKey: 'passwordVerified',  // 存储验证状态的键名\n    verificationTTL: 90 * 24 * 60 * 60 * 1000  // 验证有效期（90天，约3个月）\n};\n\n// 网站信息配置\nconst SITE_CONFIG = {\n    name: 'LibreTV',\n    url: 'https://libretv.is-an.org',\n    description: '免费在线视频搜索与观看平台',\n    logo: 'image/logo.png',\n    version: '1.0.3'\n};\n\n// API站点配置\nconst API_SITES = {\n    testSource: {\n        api: 'https://www.example.com/api.php/provide/vod',\n        name: '空内容测试源',\n        adult: true\n    }\n    //ARCHIVE https://telegra.ph/APIs-08-12\n};\n\n// 定义合并方法\nfunction extendAPISites(newSites) {\n    Object.assign(API_SITES, newSites);\n}\n\n// 暴露到全局\nwindow.API_SITES = API_SITES;\nwindow.extendAPISites = extendAPISites;\n\n\n// 添加聚合搜索的配置选项\nconst AGGREGATED_SEARCH_CONFIG = {\n    enabled: true,             // 是否启用聚合搜索\n    timeout: 8000,            // 单个源超时时间（毫秒）\n    maxResults: 10000,          // 最大结果数量\n    parallelRequests: true,   // 是否并行请求所有源\n    showSourceBadges: true    // 是否显示来源徽章\n};\n\n// 抽象API请求配置\nconst API_CONFIG = {\n    search: {\n        // 只拼接参数部分，不再包含 /api.php/provide/vod/\n        path: '?ac=videolist&wd=',\n        pagePath: '?ac=videolist&wd={query}&pg={page}',\n        maxPages: 50, // 最大获取页数\n        headers: {\n            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',\n            'Accept': 'application/json'\n        }\n    },\n    detail: {\n        // 只拼接参数部分\n        path: '?ac=videolist&ids=',\n        headers: {\n            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',\n            'Accept': 'application/json'\n        }\n    }\n};\n\n// 优化后的正则表达式模式\nconst M3U8_PATTERN = /\\$https?:\\/\\/[^\"'\\s]+?\\.m3u8/g;\n\n// 添加自定义播放器URL\nconst CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html\n\n// 增加视频播放相关配置\nconst PLAYER_CONFIG = {\n    autoplay: true,\n    allowFullscreen: true,\n    width: '100%',\n    height: '600',\n    timeout: 15000,  // 播放器加载超时时间\n    filterAds: true,  // 是否启用广告过滤\n    autoPlayNext: true,  // 默认启用自动连播功能\n    adFilteringEnabled: true, // 默认开启分片广告过滤\n    adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名\n};\n\n// 增加错误信息本地化\nconst ERROR_MESSAGES = {\n    NETWORK_ERROR: '网络连接错误，请检查网络设置',\n    TIMEOUT_ERROR: '请求超时，服务器响应时间过长',\n    API_ERROR: 'API接口返回错误，请尝试更换数据源',\n    PLAYER_ERROR: '播放器加载失败，请尝试其他视频源',\n    UNKNOWN_ERROR: '发生未知错误，请刷新页面重试'\n};\n\n// 添加进一步安全设置\nconst SECURITY_CONFIG = {\n    enableXSSProtection: true,  // 是否启用XSS保护\n    sanitizeUrls: true,         // 是否清理URL\n    maxQueryLength: 100,        // 最大搜索长度\n    // allowedApiDomains 不再需要，因为所有请求都通过内部代理\n};\n\n// 添加多个自定义API源的配置\nconst CUSTOM_API_CONFIG = {\n    separator: ',',           // 分隔符\n    maxSources: 5,            // 最大允许的自定义源数量\n    testTimeout: 5000,        // 测试超时时间(毫秒)\n    namePrefix: 'Custom-',    // 自定义源名称前缀\n    validateUrl: true,        // 验证URL格式\n    cacheResults: true,       // 缓存测试结果\n    cacheExpiry: 5184000000,  // 缓存过期时间(2个月)\n    adultPropName: 'isAdult' // 用于标记成人内容的属性名\n};\n\n// 隐藏内置黄色采集站API的变量\nconst HIDE_BUILTIN_ADULT_APIS = false;\n"
  },
  {
    "path": "js/customer_site.js",
    "content": "const CUSTOMER_SITES = {\n    qiqi: {\n        api: 'https://www.qiqidys.com/api.php/provide/vod',\n        name: '七七资源',\n    }\n};\n\n// 调用全局方法合并\nif (window.extendAPISites) {\n    window.extendAPISites(CUSTOMER_SITES);\n} else {\n    console.error(\"错误：请先加载 config.js！\");\n}\n"
  },
  {
    "path": "js/douban.js",
    "content": "// 豆瓣热门电影电视剧推荐功能\n\n// 豆瓣标签列表 - 修改为默认标签\nlet defaultMovieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '日综', '爱情', '科幻', '悬疑', '恐怖', '治愈'];\nlet defaultTvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片'];\n\n// 用户标签列表 - 存储用户实际使用的标签（包含保留的系统标签和用户添加的自定义标签）\nlet movieTags = [];\nlet tvTags = [];\n\n// 加载用户标签\nfunction loadUserTags() {\n    try {\n        // 尝试从本地存储加载用户保存的标签\n        const savedMovieTags = localStorage.getItem('userMovieTags');\n        const savedTvTags = localStorage.getItem('userTvTags');\n        \n        // 如果本地存储中有标签数据，则使用它\n        if (savedMovieTags) {\n            movieTags = JSON.parse(savedMovieTags);\n        } else {\n            // 否则使用默认标签\n            movieTags = [...defaultMovieTags];\n        }\n        \n        if (savedTvTags) {\n            tvTags = JSON.parse(savedTvTags);\n        } else {\n            // 否则使用默认标签\n            tvTags = [...defaultTvTags];\n        }\n    } catch (e) {\n        console.error('加载标签失败：', e);\n        // 初始化为默认值，防止错误\n        movieTags = [...defaultMovieTags];\n        tvTags = [...defaultTvTags];\n    }\n}\n\n// 保存用户标签\nfunction saveUserTags() {\n    try {\n        localStorage.setItem('userMovieTags', JSON.stringify(movieTags));\n        localStorage.setItem('userTvTags', JSON.stringify(tvTags));\n    } catch (e) {\n        console.error('保存标签失败：', e);\n        showToast('保存标签失败', 'error');\n    }\n}\n\nlet doubanMovieTvCurrentSwitch = 'movie';\nlet doubanCurrentTag = '热门';\nlet doubanPageStart = 0;\nconst doubanPageSize = 16; // 一次显示的项目数量\n\n// 初始化豆瓣功能\nfunction initDouban() {\n    // 设置豆瓣开关的初始状态\n    const doubanToggle = document.getElementById('doubanToggle');\n    if (doubanToggle) {\n        const isEnabled = localStorage.getItem('doubanEnabled') === 'true';\n        doubanToggle.checked = isEnabled;\n        \n        // 设置开关外观\n        const toggleBg = doubanToggle.nextElementSibling;\n        const toggleDot = toggleBg.nextElementSibling;\n        if (isEnabled) {\n            toggleBg.classList.add('bg-pink-600');\n            toggleDot.classList.add('translate-x-6');\n        }\n        \n        // 添加事件监听\n        doubanToggle.addEventListener('change', function(e) {\n            const isChecked = e.target.checked;\n            localStorage.setItem('doubanEnabled', isChecked);\n            \n            // 更新开关外观\n            if (isChecked) {\n                toggleBg.classList.add('bg-pink-600');\n                toggleDot.classList.add('translate-x-6');\n            } else {\n                toggleBg.classList.remove('bg-pink-600');\n                toggleDot.classList.remove('translate-x-6');\n            }\n            \n            // 更新显示状态\n            updateDoubanVisibility();\n        });\n        \n        // 初始更新显示状态\n        updateDoubanVisibility();\n\n        // 滚动到页面顶部\n        window.scrollTo(0, 0);\n    }\n\n    // 加载用户标签\n    loadUserTags();\n\n    // 渲染电影/电视剧切换\n    renderDoubanMovieTvSwitch();\n    \n    // 渲染豆瓣标签\n    renderDoubanTags();\n    \n    // 换一批按钮事件监听\n    setupDoubanRefreshBtn();\n    \n    // 初始加载热门内容\n    if (localStorage.getItem('doubanEnabled') === 'true') {\n        renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n    }\n}\n\n// 根据设置更新豆瓣区域的显示状态\nfunction updateDoubanVisibility() {\n    const doubanArea = document.getElementById('doubanArea');\n    if (!doubanArea) return;\n    \n    const isEnabled = localStorage.getItem('doubanEnabled') === 'true';\n    const isSearching = document.getElementById('resultsArea') && \n        !document.getElementById('resultsArea').classList.contains('hidden');\n    \n    // 只有在启用且没有搜索结果显示时才显示豆瓣区域\n    if (isEnabled && !isSearching) {\n        doubanArea.classList.remove('hidden');\n        // 如果豆瓣结果为空，重新加载\n        if (document.getElementById('douban-results').children.length === 0) {\n            renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n        }\n    } else {\n        doubanArea.classList.add('hidden');\n    }\n}\n\n// 只填充搜索框，不执行搜索，让用户自主决定搜索时机\nfunction fillSearchInput(title) {\n    if (!title) return;\n    \n    // 安全处理标题，防止XSS\n    const safeTitle = title\n        .replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;')\n        .replace(/\"/g, '&quot;');\n    \n    const input = document.getElementById('searchInput');\n    if (input) {\n        input.value = safeTitle;\n        \n        // 聚焦搜索框，便于用户立即使用键盘操作\n        input.focus();\n        \n        // 显示一个提示，告知用户点击搜索按钮进行搜索\n        showToast('已填充搜索内容，点击搜索按钮开始搜索', 'info');\n    }\n}\n\n// 填充搜索框并执行搜索\nfunction fillAndSearch(title) {\n    if (!title) return;\n    \n    // 安全处理标题，防止XSS\n    const safeTitle = title\n        .replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;')\n        .replace(/\"/g, '&quot;');\n    \n    const input = document.getElementById('searchInput');\n    if (input) {\n        input.value = safeTitle;\n        search(); // 使用已有的search函数执行搜索\n        \n        // 同时更新浏览器URL，使其反映当前的搜索状态\n        try {\n            // 使用URI编码确保特殊字符能够正确显示\n            const encodedQuery = encodeURIComponent(safeTitle);\n            // 使用HTML5 History API更新URL，不刷新页面\n            window.history.pushState(\n                { search: safeTitle }, \n                `搜索: ${safeTitle} - LibreTV`, \n                `/s=${encodedQuery}`\n            );\n            // 更新页面标题\n            document.title = `搜索: ${safeTitle} - LibreTV`;\n        } catch (e) {\n            console.error('更新浏览器历史失败:', e);\n        }\n    }\n}\n\n// 填充搜索框，确保豆瓣资源API被选中，然后执行搜索\nasync function fillAndSearchWithDouban(title) {\n    if (!title) return;\n    \n    // 安全处理标题，防止XSS\n    const safeTitle = title\n        .replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;')\n        .replace(/\"/g, '&quot;');\n    \n    // 确保豆瓣资源API被选中\n    if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) {\n        // 在设置中勾选豆瓣资源API复选框\n        const doubanCheckbox = document.querySelector('input[id=\"api_dbzy\"]');\n        if (doubanCheckbox) {\n            doubanCheckbox.checked = true;\n            \n            // 触发updateSelectedAPIs函数以更新状态\n            if (typeof updateSelectedAPIs === 'function') {\n                updateSelectedAPIs();\n            } else {\n                // 如果函数不可用，则手动添加到selectedAPIs\n                selectedAPIs.push('dbzy');\n                localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));\n                \n                // 更新选中API计数（如果有这个元素）\n                const countEl = document.getElementById('selectedAPICount');\n                if (countEl) {\n                    countEl.textContent = selectedAPIs.length;\n                }\n            }\n            \n            showToast('已自动选择豆瓣资源API', 'info');\n        }\n    }\n    \n    // 填充搜索框并执行搜索\n    const input = document.getElementById('searchInput');\n    if (input) {\n        input.value = safeTitle;\n        await search(); // 使用已有的search函数执行搜索\n        \n        // 更新浏览器URL，使其反映当前的搜索状态\n        try {\n            // 使用URI编码确保特殊字符能够正确显示\n            const encodedQuery = encodeURIComponent(safeTitle);\n            // 使用HTML5 History API更新URL，不刷新页面\n            window.history.pushState(\n                { search: safeTitle }, \n                `搜索: ${safeTitle} - LibreTV`, \n                `/s=${encodedQuery}`\n            );\n            // 更新页面标题\n            document.title = `搜索: ${safeTitle} - LibreTV`;\n        } catch (e) {\n            console.error('更新浏览器历史失败:', e);\n        }\n\n        if (window.innerWidth <= 768) {\n          window.scrollTo({\n              top: 0,\n              behavior: 'smooth'\n          });\n        }\n    }\n}\n\n// 渲染电影/电视剧切换器\nfunction renderDoubanMovieTvSwitch() {\n    // 获取切换按钮元素\n    const movieToggle = document.getElementById('douban-movie-toggle');\n    const tvToggle = document.getElementById('douban-tv-toggle');\n\n    if (!movieToggle ||!tvToggle) return;\n\n    movieToggle.addEventListener('click', function() {\n        if (doubanMovieTvCurrentSwitch !== 'movie') {\n            // 更新按钮样式\n            movieToggle.classList.add('bg-pink-600', 'text-white');\n            movieToggle.classList.remove('text-gray-300');\n            \n            tvToggle.classList.remove('bg-pink-600', 'text-white');\n            tvToggle.classList.add('text-gray-300');\n            \n            doubanMovieTvCurrentSwitch = 'movie';\n            doubanCurrentTag = '热门';\n\n            // 重新加载豆瓣内容\n            renderDoubanTags(movieTags);\n\n            // 换一批按钮事件监听\n            setupDoubanRefreshBtn();\n            \n            // 初始加载热门内容\n            if (localStorage.getItem('doubanEnabled') === 'true') {\n                renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n            }\n        }\n    });\n    \n    // 电视剧按钮点击事件\n    tvToggle.addEventListener('click', function() {\n        if (doubanMovieTvCurrentSwitch !== 'tv') {\n            // 更新按钮样式\n            tvToggle.classList.add('bg-pink-600', 'text-white');\n            tvToggle.classList.remove('text-gray-300');\n            \n            movieToggle.classList.remove('bg-pink-600', 'text-white');\n            movieToggle.classList.add('text-gray-300');\n            \n            doubanMovieTvCurrentSwitch = 'tv';\n            doubanCurrentTag = '热门';\n\n            // 重新加载豆瓣内容\n            renderDoubanTags(tvTags);\n\n            // 换一批按钮事件监听\n            setupDoubanRefreshBtn();\n            \n            // 初始加载热门内容\n            if (localStorage.getItem('doubanEnabled') === 'true') {\n                renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n            }\n        }\n    });\n}\n\n// 渲染豆瓣标签选择器\nfunction renderDoubanTags(tags) {\n    const tagContainer = document.getElementById('douban-tags');\n    if (!tagContainer) return;\n    \n    // 确定当前应该使用的标签列表\n    const currentTags = doubanMovieTvCurrentSwitch === 'movie' ? movieTags : tvTags;\n    \n    // 清空标签容器\n    tagContainer.innerHTML = '';\n\n    // 先添加标签管理按钮\n    const manageBtn = document.createElement('button');\n    manageBtn.className = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white border border-[#333] hover:border-white';\n    manageBtn.innerHTML = '<span class=\"flex items-center\"><svg class=\"w-3 h-3 mr-1\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M12 6v6m0 0v6m0-6h6m-6 0H6\"></path></svg>管理标签</span>';\n    manageBtn.onclick = function() {\n        showTagManageModal();\n    };\n    tagContainer.appendChild(manageBtn);\n\n    // 添加所有标签\n    currentTags.forEach(tag => {\n        const btn = document.createElement('button');\n        \n        // 设置样式\n        let btnClass = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 border ';\n        \n        // 当前选中的标签使用高亮样式\n        if (tag === doubanCurrentTag) {\n            btnClass += 'bg-pink-600 text-white shadow-md border-white';\n        } else {\n            btnClass += 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white border-[#333] hover:border-white';\n        }\n        \n        btn.className = btnClass;\n        btn.textContent = tag;\n        \n        btn.onclick = function() {\n            if (doubanCurrentTag !== tag) {\n                doubanCurrentTag = tag;\n                doubanPageStart = 0;\n                renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n                renderDoubanTags();\n            }\n        };\n        \n        tagContainer.appendChild(btn);\n    });\n}\n\n// 设置换一批按钮事件\nfunction setupDoubanRefreshBtn() {\n    // 修复ID，使用正确的ID douban-refresh 而不是 douban-refresh-btn\n    const btn = document.getElementById('douban-refresh');\n    if (!btn) return;\n    \n    btn.onclick = function() {\n        doubanPageStart += doubanPageSize;\n        if (doubanPageStart > 9 * doubanPageSize) {\n            doubanPageStart = 0;\n        }\n        \n        renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n    };\n}\n\nfunction fetchDoubanTags() {\n    const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie`\n    fetchDoubanData(movieTagsTarget)\n        .then(data => {\n            movieTags = data.tags;\n            if (doubanMovieTvCurrentSwitch === 'movie') {\n                renderDoubanTags(movieTags);\n            }\n        })\n        .catch(error => {\n            console.error(\"获取豆瓣热门电影标签失败：\", error);\n        });\n    const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv`\n    fetchDoubanData(tvTagsTarget)\n       .then(data => {\n            tvTags = data.tags;\n            if (doubanMovieTvCurrentSwitch === 'tv') {\n                renderDoubanTags(tvTags);\n            }\n        })\n       .catch(error => {\n            console.error(\"获取豆瓣热门电视剧标签失败：\", error);\n        });\n}\n\n// 渲染热门推荐内容\nfunction renderRecommend(tag, pageLimit, pageStart) {\n    const container = document.getElementById(\"douban-results\");\n    if (!container) return;\n\n    const loadingOverlayHTML = `\n        <div class=\"absolute inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-10\">\n            <div class=\"flex items-center justify-center\">\n                <div class=\"w-6 h-6 border-2 border-pink-500 border-t-transparent rounded-full animate-spin inline-block\"></div>\n                <span class=\"text-pink-500 ml-4\">加载中...</span>\n            </div>\n        </div>\n    `;\n\n    container.classList.add(\"relative\");\n    container.insertAdjacentHTML('beforeend', loadingOverlayHTML);\n    \n    const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;\n    \n    // 使用通用请求函数\n    fetchDoubanData(target)\n        .then(data => {\n            renderDoubanCards(data, container);\n        })\n        .catch(error => {\n            console.error(\"获取豆瓣数据失败：\", error);\n            container.innerHTML = `\n                <div class=\"col-span-full text-center py-8\">\n                    <div class=\"text-red-400\">❌ 获取豆瓣数据失败，请稍后重试</div>\n                    <div class=\"text-gray-500 text-sm mt-2\">提示：使用VPN可能有助于解决此问题</div>\n                </div>\n            `;\n        });\n}\n\nasync function fetchDoubanData(url) {\n    // 添加超时控制\n    const controller = new AbortController();\n    const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时\n    \n    // 设置请求选项，包括信号和头部\n    const fetchOptions = {\n        signal: controller.signal,\n        headers: {\n            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',\n            'Referer': 'https://movie.douban.com/',\n            'Accept': 'application/json, text/plain, */*',\n        }\n    };\n\n    try {\n        // 添加鉴权参数到代理URL\n        const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n            await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(url)) :\n            PROXY_URL + encodeURIComponent(url);\n            \n        // 尝试直接访问（豆瓣API可能允许部分CORS请求）\n        const response = await fetch(proxiedUrl, fetchOptions);\n        clearTimeout(timeoutId);\n        \n        if (!response.ok) {\n            throw new Error(`HTTP error! Status: ${response.status}`);\n        }\n        \n        return await response.json();\n    } catch (err) {\n        console.error(\"豆瓣 API 请求失败（直接代理）：\", err);\n        \n        // 失败后尝试备用方法：作为备选\n        const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`;\n        \n        try {\n            const fallbackResponse = await fetch(fallbackUrl);\n            \n            if (!fallbackResponse.ok) {\n                throw new Error(`备用API请求失败! 状态: ${fallbackResponse.status}`);\n            }\n            \n            const data = await fallbackResponse.json();\n            \n            // 解析原始内容\n            if (data && data.contents) {\n                return JSON.parse(data.contents);\n            } else {\n                throw new Error(\"无法获取有效数据\");\n            }\n        } catch (fallbackErr) {\n            console.error(\"豆瓣 API 备用请求也失败：\", fallbackErr);\n            throw fallbackErr; // 向上抛出错误，让调用者处理\n        }\n    }\n}\n\n// 抽取渲染豆瓣卡片的逻辑到单独函数\nfunction renderDoubanCards(data, container) {\n    // 创建文档片段以提高性能\n    const fragment = document.createDocumentFragment();\n    \n    // 如果没有数据\n    if (!data.subjects || data.subjects.length === 0) {\n        const emptyEl = document.createElement(\"div\");\n        emptyEl.className = \"col-span-full text-center py-8\";\n        emptyEl.innerHTML = `\n            <div class=\"text-pink-500\">❌ 暂无数据，请尝试其他分类或刷新</div>\n        `;\n        fragment.appendChild(emptyEl);\n    } else {\n        // 循环创建每个影视卡片\n        data.subjects.forEach(item => {\n            const card = document.createElement(\"div\");\n            card.className = \"bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg\";\n            \n            // 生成卡片内容，确保安全显示（防止XSS）\n            const safeTitle = item.title\n                .replace(/</g, '&lt;')\n                .replace(/>/g, '&gt;')\n                .replace(/\"/g, '&quot;');\n            \n            const safeRate = (item.rate || \"暂无\")\n                .replace(/</g, '&lt;')\n                .replace(/>/g, '&gt;');\n            \n            // 处理图片URL\n            // 1. 直接使用豆瓣图片URL (添加no-referrer属性)\n            const originalCoverUrl = item.cover;\n            \n            // 2. 也准备代理URL作为备选\n            const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl);\n            \n            // 为不同设备优化卡片布局\n            card.innerHTML = `\n                <div class=\"relative w-full aspect-[2/3] overflow-hidden cursor-pointer\" onclick=\"fillAndSearchWithDouban('${safeTitle}')\">\n                    <img src=\"${originalCoverUrl}\" alt=\"${safeTitle}\" \n                        class=\"w-full h-full object-cover transition-transform duration-500 hover:scale-110\"\n                        onerror=\"this.onerror=null; this.src='${proxiedCoverUrl}'; this.classList.add('object-contain');\"\n                        loading=\"lazy\" referrerpolicy=\"no-referrer\">\n                    <div class=\"absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-60\"></div>\n                    <div class=\"absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded-sm\">\n                        <span class=\"text-yellow-400\">★</span> ${safeRate}\n                    </div>\n                    <div class=\"absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-sm hover:bg-[#333] transition-colors\">\n                        <a href=\"${item.url}\" target=\"_blank\" rel=\"noopener noreferrer\" title=\"在豆瓣查看\" onclick=\"event.stopPropagation();\">\n                            🔗\n                        </a>\n                    </div>\n                </div>\n                <div class=\"p-2 text-center bg-[#111]\">\n                    <button onclick=\"fillAndSearchWithDouban('${safeTitle}')\" \n                            class=\"text-sm font-medium text-white truncate w-full hover:text-pink-400 transition\"\n                            title=\"${safeTitle}\">\n                        ${safeTitle}\n                    </button>\n                </div>\n            `;\n            \n            fragment.appendChild(card);\n        });\n    }\n    \n    // 清空并添加所有新元素\n    container.innerHTML = \"\";\n    container.appendChild(fragment);\n}\n\n// 重置到首页\nfunction resetToHome() {\n    resetSearchArea();\n    updateDoubanVisibility();\n}\n\n// 加载豆瓣首页内容\ndocument.addEventListener('DOMContentLoaded', initDouban);\n\n// 显示标签管理模态框\nfunction showTagManageModal() {\n    // 确保模态框在页面上只有一个实例\n    let modal = document.getElementById('tagManageModal');\n    if (modal) {\n        document.body.removeChild(modal);\n    }\n    \n    // 创建模态框元素\n    modal = document.createElement('div');\n    modal.id = 'tagManageModal';\n    modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';\n    \n    // 当前使用的标签类型和默认标签\n    const isMovie = doubanMovieTvCurrentSwitch === 'movie';\n    const currentTags = isMovie ? movieTags : tvTags;\n    const defaultTags = isMovie ? defaultMovieTags : defaultTvTags;\n    \n    // 模态框内容\n    modal.innerHTML = `\n        <div class=\"bg-[#191919] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto relative\">\n            <button id=\"closeTagModal\" class=\"absolute top-4 right-4 text-gray-400 hover:text-white text-xl\">&times;</button>\n            \n            <h3 class=\"text-xl font-bold text-white mb-4\">标签管理 (${isMovie ? '电影' : '电视剧'})</h3>\n            \n            <div class=\"mb-4\">\n                <div class=\"flex justify-between items-center mb-2\">\n                    <h4 class=\"text-lg font-medium text-gray-300\">标签列表</h4>\n                    <button id=\"resetTagsBtn\" class=\"text-xs px-2 py-1 bg-gray-700 hover:bg-gray-600 text-white rounded\">\n                        恢复默认标签\n                    </button>\n                </div>\n                <div class=\"grid grid-cols-2 sm:grid-cols-3 gap-2 mb-4\" id=\"tagsGrid\">\n                    ${currentTags.length ? currentTags.map(tag => {\n                        // \"热门\"标签不能删除\n                        const canDelete = tag !== '热门';\n                        return `\n                            <div class=\"bg-[#1a1a1a] text-gray-300 py-1.5 px-3 rounded text-sm font-medium flex justify-between items-center group\">\n                                <span>${tag}</span>\n                                ${canDelete ? \n                                    `<button class=\"delete-tag-btn text-gray-500 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity\" \n                                        data-tag=\"${tag}\">✕</button>` : \n                                    `<span class=\"text-gray-500 text-xs italic opacity-0 group-hover:opacity-100\">必需</span>`\n                                }\n                            </div>\n                        `;\n                    }).join('') : \n                    `<div class=\"col-span-full text-center py-4 text-gray-500\">无标签，请添加或恢复默认</div>`}\n                </div>\n            </div>\n            \n            <div class=\"border-t border-gray-700 pt-4\">\n                <h4 class=\"text-lg font-medium text-gray-300 mb-3\">添加新标签</h4>\n                <form id=\"addTagForm\" class=\"flex items-center\">\n                    <input type=\"text\" id=\"newTagInput\" placeholder=\"输入标签名称...\" \n                           class=\"flex-1 bg-[#222] text-white border border-gray-700 rounded px-3 py-2 focus:outline-none focus:border-pink-500\">\n                    <button type=\"submit\" class=\"ml-2 bg-pink-600 hover:bg-pink-700 text-white px-4 py-2 rounded\">添加</button>\n                </form>\n                <p class=\"text-xs text-gray-500 mt-2\">提示：标签名称不能为空，不能重复，不能包含特殊字符</p>\n            </div>\n        </div>\n    `;\n    \n    // 添加模态框到页面\n    document.body.appendChild(modal);\n    \n    // 焦点放在输入框上\n    setTimeout(() => {\n        document.getElementById('newTagInput').focus();\n    }, 100);\n    \n    // 添加事件监听器 - 关闭按钮\n    document.getElementById('closeTagModal').addEventListener('click', function() {\n        document.body.removeChild(modal);\n    });\n    \n    // 添加事件监听器 - 点击模态框外部关闭\n    modal.addEventListener('click', function(e) {\n        if (e.target === modal) {\n            document.body.removeChild(modal);\n        }\n    });\n    \n    // 添加事件监听器 - 恢复默认标签按钮\n    document.getElementById('resetTagsBtn').addEventListener('click', function() {\n        resetTagsToDefault();\n        showTagManageModal(); // 重新加载模态框\n    });\n    \n    // 添加事件监听器 - 删除标签按钮\n    const deleteButtons = document.querySelectorAll('.delete-tag-btn');\n    deleteButtons.forEach(btn => {\n        btn.addEventListener('click', function() {\n            const tagToDelete = this.getAttribute('data-tag');\n            deleteTag(tagToDelete);\n            showTagManageModal(); // 重新加载模态框\n        });\n    });\n    \n    // 添加事件监听器 - 表单提交\n    document.getElementById('addTagForm').addEventListener('submit', function(e) {\n        e.preventDefault();\n        const input = document.getElementById('newTagInput');\n        const newTag = input.value.trim();\n        \n        if (newTag) {\n            addTag(newTag);\n            input.value = '';\n            showTagManageModal(); // 重新加载模态框\n        }\n    });\n}\n\n// 添加标签\nfunction addTag(tag) {\n    // 安全处理标签名，防止XSS\n    const safeTag = tag\n        .replace(/</g, '&lt;')\n        .replace(/>/g, '&gt;')\n        .replace(/\"/g, '&quot;');\n    \n    // 确定当前使用的是电影还是电视剧标签\n    const isMovie = doubanMovieTvCurrentSwitch === 'movie';\n    const currentTags = isMovie ? movieTags : tvTags;\n    \n    // 检查是否已存在（忽略大小写）\n    const exists = currentTags.some(\n        existingTag => existingTag.toLowerCase() === safeTag.toLowerCase()\n    );\n    \n    if (exists) {\n        showToast('标签已存在', 'warning');\n        return;\n    }\n    \n    // 添加到对应的标签数组\n    if (isMovie) {\n        movieTags.push(safeTag);\n    } else {\n        tvTags.push(safeTag);\n    }\n    \n    // 保存到本地存储\n    saveUserTags();\n    \n    // 重新渲染标签\n    renderDoubanTags();\n    \n    showToast('标签添加成功', 'success');\n}\n\n// 删除标签\nfunction deleteTag(tag) {\n    // 热门标签不能删除\n    if (tag === '热门') {\n        showToast('热门标签不能删除', 'warning');\n        return;\n    }\n    \n    // 确定当前使用的是电影还是电视剧标签\n    const isMovie = doubanMovieTvCurrentSwitch === 'movie';\n    const currentTags = isMovie ? movieTags : tvTags;\n    \n    // 寻找标签索引\n    const index = currentTags.indexOf(tag);\n    \n    // 如果找到标签，则删除\n    if (index !== -1) {\n        currentTags.splice(index, 1);\n        \n        // 保存到本地存储\n        saveUserTags();\n        \n        // 如果当前选中的是被删除的标签，则重置为\"热门\"\n        if (doubanCurrentTag === tag) {\n            doubanCurrentTag = '热门';\n            doubanPageStart = 0;\n            renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n        }\n        \n        // 重新渲染标签\n        renderDoubanTags();\n        \n        showToast('标签删除成功', 'success');\n    }\n}\n\n// 重置为默认标签\nfunction resetTagsToDefault() {\n    // 确定当前使用的是电影还是电视剧\n    const isMovie = doubanMovieTvCurrentSwitch === 'movie';\n    \n    // 重置为默认标签\n    if (isMovie) {\n        movieTags = [...defaultMovieTags];\n    } else {\n        tvTags = [...defaultTvTags];\n    }\n    \n    // 设置当前标签为热门\n    doubanCurrentTag = '热门';\n    doubanPageStart = 0;\n    \n    // 保存到本地存储\n    saveUserTags();\n    \n    // 重新渲染标签和内容\n    renderDoubanTags();\n    renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);\n    \n    showToast('已恢复默认标签', 'success');\n}\n"
  },
  {
    "path": "js/index-page.js",
    "content": "// 页面加载后显示弹窗脚本\ndocument.addEventListener('DOMContentLoaded', function() {\n    // 弹窗显示脚本\n    // 检查用户是否已经看过声明\n    const hasSeenDisclaimer = localStorage.getItem('hasSeenDisclaimer');\n    \n    if (!hasSeenDisclaimer) {\n        // 显示弹窗\n        const disclaimerModal = document.getElementById('disclaimerModal');\n        disclaimerModal.style.display = 'flex';\n        \n        // 添加接受按钮事件\n        document.getElementById('acceptDisclaimerBtn').addEventListener('click', function() {\n            // 保存用户已看过声明的状态\n            localStorage.setItem('hasSeenDisclaimer', 'true');\n            // 隐藏弹窗\n            disclaimerModal.style.display = 'none';\n        });\n    }\n\n    // URL搜索参数处理脚本\n    // 首先检查是否是播放URL格式 (/watch 开头的路径)\n    if (window.location.pathname.startsWith('/watch')) {\n        // 播放URL，不做额外处理，watch.html会处理重定向\n        return;\n    }\n    \n    // 检查页面路径中的搜索参数 (格式: /s=keyword)\n    const path = window.location.pathname;\n    const searchPrefix = '/s=';\n    \n    if (path.startsWith(searchPrefix)) {\n        // 提取搜索关键词\n        const keyword = decodeURIComponent(path.substring(searchPrefix.length));\n        if (keyword) {\n            // 设置搜索框的值\n            document.getElementById('searchInput').value = keyword;\n            // 显示清空按钮\n            toggleClearButton();\n            // 执行搜索\n            setTimeout(() => {\n                // 使用setTimeout确保其他DOM加载和初始化完成\n                search();\n                // 更新浏览器历史，不改变URL (保持搜索参数在地址栏)\n                try {\n                    window.history.replaceState(\n                        { search: keyword }, \n                        `搜索: ${keyword} - LibreTV`, \n                        window.location.href\n                    );\n                } catch (e) {\n                    console.error('更新浏览器历史失败:', e);\n                }\n            }, 300);\n        }\n    }\n    \n    // 也检查查询字符串中的搜索参数 (格式: ?s=keyword)\n    const urlParams = new URLSearchParams(window.location.search);\n    const searchQuery = urlParams.get('s');\n    \n    if (searchQuery) {\n        // 设置搜索框的值\n        document.getElementById('searchInput').value = searchQuery;\n        // 执行搜索\n        setTimeout(() => {\n            search();\n            // 更新URL为规范格式\n            try {\n                window.history.replaceState(\n                    { search: searchQuery }, \n                    `搜索: ${searchQuery} - LibreTV`, \n                    `/s=${encodeURIComponent(searchQuery)}`\n                );\n            } catch (e) {\n                console.error('更新浏览器历史失败:', e);\n            }\n        }, 300);\n    }\n});\n"
  },
  {
    "path": "js/password.js",
    "content": "// 密码保护功能\n\n/**\n * 检查是否设置了密码保护\n * 通过读取页面上嵌入的环境变量来检查\n */\nfunction isPasswordProtected() {\n    // 只检查普通密码\n    const pwd = window.__ENV__ && window.__ENV__.PASSWORD;\n    \n    // 检查普通密码是否有效\n    return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd);\n}\n\n/**\n * 检查是否强制要求设置密码\n * 如果没有设置有效的 PASSWORD，则认为需要强制设置密码\n * 为了安全考虑，所有部署都必须设置密码\n */\nfunction isPasswordRequired() {\n    return !isPasswordProtected();\n}\n\n/**\n * 强制密码保护检查 - 防止绕过\n * 在关键操作前都应该调用此函数\n */\nfunction ensurePasswordProtection() {\n    if (isPasswordRequired()) {\n        showPasswordModal();\n        throw new Error('Password protection is required');\n    }\n    if (isPasswordProtected() && !isPasswordVerified()) {\n        showPasswordModal();\n        throw new Error('Password verification required');\n    }\n    return true;\n}\n\nwindow.isPasswordProtected = isPasswordProtected;\nwindow.isPasswordRequired = isPasswordRequired;\n\n/**\n * 验证用户输入的密码是否正确（异步，使用SHA-256哈希）\n */\nasync function verifyPassword(password) {\n    try {\n        const correctHash = window.__ENV__?.PASSWORD;\n        if (!correctHash) return false;\n\n        const inputHash = await sha256(password);\n        const isValid = inputHash === correctHash;\n\n        if (isValid) {\n            localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify({\n                verified: true,\n                timestamp: Date.now(),\n                passwordHash: correctHash\n            }));\n        }\n        return isValid;\n    } catch (error) {\n        console.error('验证密码时出错:', error);\n        return false;\n    }\n}\n\n// 验证状态检查\nfunction isPasswordVerified() {\n    try {\n        if (!isPasswordProtected()) return true;\n\n        const stored = localStorage.getItem(PASSWORD_CONFIG.localStorageKey);\n        if (!stored) return false;\n\n        const { timestamp, passwordHash } = JSON.parse(stored);\n        const currentHash = window.__ENV__?.PASSWORD;\n\n        return timestamp && passwordHash === currentHash &&\n            Date.now() - timestamp < PASSWORD_CONFIG.verificationTTL;\n    } catch (error) {\n        console.error('检查密码验证状态时出错:', error);\n        return false;\n    }\n}\n\n// 更新全局导出\nwindow.isPasswordProtected = isPasswordProtected;\nwindow.isPasswordRequired = isPasswordRequired;\nwindow.isPasswordVerified = isPasswordVerified;\nwindow.verifyPassword = verifyPassword;\nwindow.ensurePasswordProtection = ensurePasswordProtection;\n\n// SHA-256实现，可用Web Crypto API\nasync function sha256(message) {\n    if (window.crypto && crypto.subtle && crypto.subtle.digest) {\n        const msgBuffer = new TextEncoder().encode(message);\n        const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n        const hashArray = Array.from(new Uint8Array(hashBuffer));\n        return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n    }\n    // HTTP 下调用原始 js‑sha256\n    if (typeof window._jsSha256 === 'function') {\n        return window._jsSha256(message);\n    }\n    throw new Error('No SHA-256 implementation available.');\n}\n\n/**\n * 显示密码验证弹窗\n */\nfunction showPasswordModal() {\n    const passwordModal = document.getElementById('passwordModal');\n    if (passwordModal) {\n        // 防止出现豆瓣区域滚动条\n        document.getElementById('doubanArea').classList.add('hidden');\n        document.getElementById('passwordCancelBtn').classList.add('hidden');\n\n        // 检查是否需要强制设置密码\n        if (isPasswordRequired()) {\n            // 修改弹窗内容提示用户需要先设置密码\n            const title = passwordModal.querySelector('h2');\n            const description = passwordModal.querySelector('p');\n            if (title) title.textContent = '需要设置密码';\n            if (description) description.textContent = '请先在部署平台设置 PASSWORD 环境变量来保护您的实例';\n            \n            // 隐藏密码输入框和提交按钮，只显示提示信息\n            const form = passwordModal.querySelector('form');\n            const errorMsg = document.getElementById('passwordError');\n            if (form) form.style.display = 'none';\n            if (errorMsg) {\n                errorMsg.textContent = '为确保安全，必须设置 PASSWORD 环境变量才能使用本服务，请联系管理员进行配置';\n                errorMsg.classList.remove('hidden');\n                errorMsg.className = 'text-red-500 mt-2 font-medium'; // 改为更醒目的红色\n            }\n        } else {\n            // 正常的密码验证模式\n            const title = passwordModal.querySelector('h2');\n            const description = passwordModal.querySelector('p');\n            if (title) title.textContent = '访问验证';\n            if (description) description.textContent = '请输入密码继续访问';\n            \n            const form = passwordModal.querySelector('form');\n            if (form) form.style.display = 'block';\n        }\n\n        passwordModal.style.display = 'flex';\n\n        // 只有在非强制设置密码模式下才聚焦输入框\n        if (!isPasswordRequired()) {\n            // 确保输入框获取焦点\n            setTimeout(() => {\n                const passwordInput = document.getElementById('passwordInput');\n                if (passwordInput) {\n                    passwordInput.focus();\n                }\n            }, 100);\n        }\n    }\n}\n\n/**\n * 隐藏密码验证弹窗\n */\nfunction hidePasswordModal() {\n    const passwordModal = document.getElementById('passwordModal');\n    if (passwordModal) {\n        // 隐藏密码错误提示\n        hidePasswordError();\n\n        // 清空密码输入框\n        const passwordInput = document.getElementById('passwordInput');\n        if (passwordInput) passwordInput.value = '';\n\n        passwordModal.style.display = 'none';\n\n        // 如果启用豆瓣区域则显示豆瓣区域\n        if (localStorage.getItem('doubanEnabled') === 'true') {\n            document.getElementById('doubanArea').classList.remove('hidden');\n            initDouban();\n        }\n    }\n}\n\n/**\n * 显示密码错误信息\n */\nfunction showPasswordError() {\n    const errorElement = document.getElementById('passwordError');\n    if (errorElement) {\n        errorElement.classList.remove('hidden');\n    }\n}\n\n/**\n * 隐藏密码错误信息\n */\nfunction hidePasswordError() {\n    const errorElement = document.getElementById('passwordError');\n    if (errorElement) {\n        errorElement.classList.add('hidden');\n    }\n}\n\n/**\n * 处理密码提交事件（异步）\n */\nasync function handlePasswordSubmit() {\n    const passwordInput = document.getElementById('passwordInput');\n    const password = passwordInput ? passwordInput.value.trim() : '';\n    if (await verifyPassword(password)) {\n        hidePasswordModal();\n\n        // 触发密码验证成功事件\n        document.dispatchEvent(new CustomEvent('passwordVerified'));\n    } else {\n        showPasswordError();\n        if (passwordInput) {\n            passwordInput.value = '';\n            passwordInput.focus();\n        }\n    }\n}\n\n/**\n * 初始化密码验证系统\n */\nfunction initPasswordProtection() {\n    // 如果需要强制设置密码，显示警告弹窗\n    if (isPasswordRequired()) {\n        showPasswordModal();\n        return;\n    }\n    \n    // 如果设置了密码但用户未验证，显示密码输入框\n    if (isPasswordProtected() && !isPasswordVerified()) {\n        showPasswordModal();\n        return;\n    }\n}\n\n// 在页面加载完成后初始化密码保护\ndocument.addEventListener('DOMContentLoaded', function () {\n    initPasswordProtection();\n});"
  },
  {
    "path": "js/player.js",
    "content": "const selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[]');\nconst customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表\n\n// 改进返回功能\nfunction goBack(event) {\n    // 防止默认链接行为\n    if (event) event.preventDefault();\n    \n    // 1. 优先检查URL参数中的returnUrl\n    const urlParams = new URLSearchParams(window.location.search);\n    const returnUrl = urlParams.get('returnUrl');\n    \n    if (returnUrl) {\n        // 如果URL中有returnUrl参数，优先使用\n        window.location.href = decodeURIComponent(returnUrl);\n        return;\n    }\n    \n    // 2. 检查localStorage中保存的lastPageUrl\n    const lastPageUrl = localStorage.getItem('lastPageUrl');\n    if (lastPageUrl && lastPageUrl !== window.location.href) {\n        window.location.href = lastPageUrl;\n        return;\n    }\n    \n    // 3. 检查是否是从搜索页面进入的播放器\n    const referrer = document.referrer;\n    \n    // 检查 referrer 是否包含搜索参数\n    if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) {\n        // 如果是从搜索页面来的，返回到搜索页面\n        window.location.href = referrer;\n        return;\n    }\n    \n    // 4. 如果是在iframe中打开的，尝试关闭iframe\n    if (window.self !== window.top) {\n        try {\n            // 尝试调用父窗口的关闭播放器函数\n            window.parent.closeVideoPlayer && window.parent.closeVideoPlayer();\n            return;\n        } catch (e) {\n            console.error('调用父窗口closeVideoPlayer失败:', e);\n        }\n    }\n    \n    // 5. 无法确定上一页，则返回首页\n    if (!referrer || referrer === '') {\n        window.location.href = '/';\n        return;\n    }\n    \n    // 6. 以上都不满足，使用默认行为：返回上一页\n    window.history.back();\n}\n\n// 页面加载时保存当前URL到localStorage，作为返回目标\nwindow.addEventListener('load', function () {\n    // 保存前一页面URL\n    if (document.referrer && document.referrer !== window.location.href) {\n        localStorage.setItem('lastPageUrl', document.referrer);\n    }\n\n    // 提取当前URL中的重要参数，以便在需要时能够恢复当前页面\n    const urlParams = new URLSearchParams(window.location.search);\n    const videoId = urlParams.get('id');\n    const sourceCode = urlParams.get('source');\n\n    if (videoId && sourceCode) {\n        // 保存当前播放状态，以便其他页面可以返回\n        localStorage.setItem('currentPlayingId', videoId);\n        localStorage.setItem('currentPlayingSource', sourceCode);\n    }\n});\n\n\n// =================================\n// ============== PLAYER ==========\n// =================================\n// 全局变量\nlet currentVideoTitle = '';\nlet currentEpisodeIndex = 0;\nlet art = null; // 用于 ArtPlayer 实例\nlet currentHls = null; // 跟踪当前HLS实例\nlet currentEpisodes = [];\nlet episodesReversed = false;\nlet autoplayEnabled = true; // 默认开启自动连播\nlet videoHasEnded = false; // 跟踪视频是否已经自然结束\nlet userClickedPosition = null; // 记录用户点击的位置\nlet shortcutHintTimeout = null; // 用于控制快捷键提示显示时间\nlet adFilteringEnabled = true; // 默认开启广告过滤\nlet progressSaveInterval = null; // 定期保存进度的计时器\nlet currentVideoUrl = ''; // 记录当前实际的视频URL\nconst isWebkit = (typeof window.webkitConvertPointFromNodeToPage === 'function')\nArtplayer.FULLSCREEN_WEB_IN_BODY = true;\n\n// 页面加载\ndocument.addEventListener('DOMContentLoaded', function () {\n    // 先检查用户是否已通过密码验证\n    if (!isPasswordVerified()) {\n        // 隐藏加载提示\n        document.getElementById('player-loading').style.display = 'none';\n        return;\n    }\n\n    initializePageContent();\n});\n\n// 监听密码验证成功事件\ndocument.addEventListener('passwordVerified', () => {\n    document.getElementById('player-loading').style.display = 'block';\n\n    initializePageContent();\n});\n\n// 初始化页面内容\nfunction initializePageContent() {\n\n    // 解析URL参数\n    const urlParams = new URLSearchParams(window.location.search);\n    let videoUrl = urlParams.get('url');\n    const title = urlParams.get('title');\n    const sourceCode = urlParams.get('source');\n    let index = parseInt(urlParams.get('index') || '0');\n    const episodesList = urlParams.get('episodes'); // 从URL获取集数信息\n    const savedPosition = parseInt(urlParams.get('position') || '0'); // 获取保存的播放位置\n    // 解决历史记录问题：检查URL是否是player.html开头的链接\n    // 如果是，说明这是历史记录重定向，需要解析真实的视频URL\n    if (videoUrl && videoUrl.includes('player.html')) {\n        try {\n            // 尝试从嵌套URL中提取真实的视频链接\n            const nestedUrlParams = new URLSearchParams(videoUrl.split('?')[1]);\n            // 从嵌套参数中获取真实视频URL\n            const nestedVideoUrl = nestedUrlParams.get('url');\n            // 检查嵌套URL是否包含播放位置信息\n            const nestedPosition = nestedUrlParams.get('position');\n            const nestedIndex = nestedUrlParams.get('index');\n            const nestedTitle = nestedUrlParams.get('title');\n\n            if (nestedVideoUrl) {\n                videoUrl = nestedVideoUrl;\n\n                // 更新当前URL参数\n                const url = new URL(window.location.href);\n                if (!urlParams.has('position') && nestedPosition) {\n                    url.searchParams.set('position', nestedPosition);\n                }\n                if (!urlParams.has('index') && nestedIndex) {\n                    url.searchParams.set('index', nestedIndex);\n                }\n                if (!urlParams.has('title') && nestedTitle) {\n                    url.searchParams.set('title', nestedTitle);\n                }\n                // 替换当前URL\n                window.history.replaceState({}, '', url);\n            } else {\n                showError('历史记录链接无效，请返回首页重新访问');\n            }\n        } catch (e) {\n        }\n    }\n\n    // 保存当前视频URL\n    currentVideoUrl = videoUrl || '';\n\n    // 从localStorage获取数据\n    currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频';\n    currentEpisodeIndex = index;\n\n    // 设置自动连播开关状态\n    autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; // 默认为true\n    document.getElementById('autoplayToggle').checked = autoplayEnabled;\n\n    // 获取广告过滤设置\n    adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true\n\n    // 监听自动连播开关变化\n    document.getElementById('autoplayToggle').addEventListener('change', function (e) {\n        autoplayEnabled = e.target.checked;\n        localStorage.setItem('autoplayEnabled', autoplayEnabled);\n    });\n\n    // 优先使用URL传递的集数信息，否则从localStorage获取\n    try {\n        if (episodesList) {\n            // 如果URL中有集数数据，优先使用它\n            currentEpisodes = JSON.parse(decodeURIComponent(episodesList));\n\n        } else {\n            // 否则从localStorage获取\n            currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');\n\n        }\n\n        // 检查集数索引是否有效，如果无效则调整为0\n        if (index < 0 || (currentEpisodes.length > 0 && index >= currentEpisodes.length)) {\n            // 如果索引太大，则使用最大有效索引\n            if (index >= currentEpisodes.length && currentEpisodes.length > 0) {\n                index = currentEpisodes.length - 1;\n            } else {\n                index = 0;\n            }\n\n            // 更新URL以反映修正后的索引\n            const newUrl = new URL(window.location.href);\n            newUrl.searchParams.set('index', index);\n            window.history.replaceState({}, '', newUrl);\n        }\n\n        // 更新当前索引为验证过的值\n        currentEpisodeIndex = index;\n\n        episodesReversed = localStorage.getItem('episodesReversed') === 'true';\n    } catch (e) {\n        currentEpisodes = [];\n        currentEpisodeIndex = 0;\n        episodesReversed = false;\n    }\n\n    // 设置页面标题\n    document.title = currentVideoTitle + ' - LibreTV播放器';\n    document.getElementById('videoTitle').textContent = currentVideoTitle;\n\n    // 初始化播放器\n    if (videoUrl) {\n        initPlayer(videoUrl);\n    } else {\n        showError('无效的视频链接');\n    }\n\n    // 渲染源信息\n    renderResourceInfoBar();\n\n    // 更新集数信息\n    updateEpisodeInfo();\n\n    // 渲染集数列表\n    renderEpisodes();\n\n    // 更新按钮状态\n    updateButtonStates();\n\n    // 更新排序按钮状态\n    updateOrderButton();\n\n    // 添加对进度条的监听，确保点击准确跳转\n    setTimeout(() => {\n        setupProgressBarPreciseClicks();\n    }, 1000);\n\n    // 添加键盘快捷键事件监听\n    document.addEventListener('keydown', handleKeyboardShortcuts);\n\n    // 添加页面离开事件监听，保存播放位置\n    window.addEventListener('beforeunload', saveCurrentProgress);\n\n    // 新增：页面隐藏（切后台/切标签）时也保存\n    document.addEventListener('visibilitychange', function () {\n        if (document.visibilityState === 'hidden') {\n            saveCurrentProgress();\n        }\n    });\n\n    // 视频暂停时也保存\n    const waitForVideo = setInterval(() => {\n        if (art && art.video) {\n            art.video.addEventListener('pause', saveCurrentProgress);\n\n            // 新增：播放进度变化时节流保存\n            let lastSave = 0;\n            art.video.addEventListener('timeupdate', function() {\n                const now = Date.now();\n                if (now - lastSave > 5000) { // 每5秒最多保存一次\n                    saveCurrentProgress();\n                    lastSave = now;\n                }\n            });\n\n            clearInterval(waitForVideo);\n        }\n    }, 200);\n}\n\n// 处理键盘快捷键\nfunction handleKeyboardShortcuts(e) {\n    // 忽略输入框中的按键事件\n    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;\n\n    // Alt + 左箭头 = 上一集\n    if (e.altKey && e.key === 'ArrowLeft') {\n        if (currentEpisodeIndex > 0) {\n            playPreviousEpisode();\n            showShortcutHint('上一集', 'left');\n            e.preventDefault();\n        }\n    }\n\n    // Alt + 右箭头 = 下一集\n    if (e.altKey && e.key === 'ArrowRight') {\n        if (currentEpisodeIndex < currentEpisodes.length - 1) {\n            playNextEpisode();\n            showShortcutHint('下一集', 'right');\n            e.preventDefault();\n        }\n    }\n\n    // 左箭头 = 快退\n    if (!e.altKey && e.key === 'ArrowLeft') {\n        if (art && art.currentTime > 5) {\n            art.currentTime -= 5;\n            showShortcutHint('快退', 'left');\n            e.preventDefault();\n        }\n    }\n\n    // 右箭头 = 快进\n    if (!e.altKey && e.key === 'ArrowRight') {\n        if (art && art.currentTime < art.duration - 5) {\n            art.currentTime += 5;\n            showShortcutHint('快进', 'right');\n            e.preventDefault();\n        }\n    }\n\n    // 上箭头 = 音量+\n    if (e.key === 'ArrowUp') {\n        if (art && art.volume < 1) {\n            art.volume += 0.1;\n            showShortcutHint('音量+', 'up');\n            e.preventDefault();\n        }\n    }\n\n    // 下箭头 = 音量-\n    if (e.key === 'ArrowDown') {\n        if (art && art.volume > 0) {\n            art.volume -= 0.1;\n            showShortcutHint('音量-', 'down');\n            e.preventDefault();\n        }\n    }\n\n    // 空格 = 播放/暂停\n    if (e.key === ' ') {\n        if (art) {\n            art.toggle();\n            showShortcutHint('播放/暂停', 'play');\n            e.preventDefault();\n        }\n    }\n\n    // f 键 = 切换全屏\n    if (e.key === 'f' || e.key === 'F') {\n        if (art) {\n            art.fullscreen = !art.fullscreen;\n            showShortcutHint('切换全屏', 'fullscreen');\n            e.preventDefault();\n        }\n    }\n}\n\n// 显示快捷键提示\nfunction showShortcutHint(text, direction) {\n    const hintElement = document.getElementById('shortcutHint');\n    const textElement = document.getElementById('shortcutText');\n    const iconElement = document.getElementById('shortcutIcon');\n\n    // 清除之前的超时\n    if (shortcutHintTimeout) {\n        clearTimeout(shortcutHintTimeout);\n    }\n\n    // 设置文本和图标方向\n    textElement.textContent = text;\n\n    if (direction === 'left') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path>';\n    } else if (direction === 'right') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>';\n    }  else if (direction === 'up') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 15l7-7 7 7\"></path>';\n    } else if (direction === 'down') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M19 9l-7 7-7-7\"></path>';\n    } else if (direction === 'fullscreen') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5v-4m0 4h-4m4 0l-5-5\"></path>';\n    } else if (direction === 'play') {\n        iconElement.innerHTML = '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M5 3l14 9-14 9V3z\"></path>';\n    }\n\n    // 显示提示\n    hintElement.classList.add('show');\n\n    // 两秒后隐藏\n    shortcutHintTimeout = setTimeout(() => {\n        hintElement.classList.remove('show');\n    }, 2000);\n}\n\n// 初始化播放器\nfunction initPlayer(videoUrl) {\n    if (!videoUrl) {\n        return\n    }\n\n    // 销毁旧实例\n    if (art) {\n        art.destroy();\n        art = null;\n    }\n\n    // 配置HLS.js选项\n    const hlsConfig = {\n        debug: false,\n        loader: adFilteringEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader,\n        enableWorker: true,\n        lowLatencyMode: false,\n        backBufferLength: 90,\n        maxBufferLength: 30,\n        maxMaxBufferLength: 60,\n        maxBufferSize: 30 * 1000 * 1000,\n        maxBufferHole: 0.5,\n        fragLoadingMaxRetry: 6,\n        fragLoadingMaxRetryTimeout: 64000,\n        fragLoadingRetryDelay: 1000,\n        manifestLoadingMaxRetry: 3,\n        manifestLoadingRetryDelay: 1000,\n        levelLoadingMaxRetry: 4,\n        levelLoadingRetryDelay: 1000,\n        startLevel: -1,\n        abrEwmaDefaultEstimate: 500000,\n        abrBandWidthFactor: 0.95,\n        abrBandWidthUpFactor: 0.7,\n        abrMaxWithRealBitrate: true,\n        stretchShortVideoTrack: true,\n        appendErrorMaxRetry: 5,  // 增加尝试次数\n        liveSyncDurationCount: 3,\n        liveDurationInfinity: false\n    };\n\n    // Create new ArtPlayer instance\n    art = new Artplayer({\n        container: '#player',\n        url: videoUrl,\n        type: 'm3u8',\n        title: videoTitle,\n        volume: 0.8,\n        isLive: false,\n        muted: false,\n        autoplay: true,\n        pip: true,\n        autoSize: false,\n        autoMini: true,\n        screenshot: true,\n        setting: true,\n        loop: false,\n        flip: false,\n        playbackRate: true,\n        aspectRatio: false,\n        fullscreen: true,\n        fullscreenWeb: true,\n        subtitleOffset: false,\n        miniProgressBar: true,\n        mutex: true,\n        backdrop: true,\n        playsInline: true,\n        autoPlayback: false,\n        airplay: true,\n        hotkey: false,\n        theme: '#23ade5',\n        lang: navigator.language.toLowerCase(),\n        moreVideoAttr: {\n            crossOrigin: 'anonymous',\n        },\n        customType: {\n            m3u8: function (video, url) {\n                // 清理之前的HLS实例\n                if (currentHls && currentHls.destroy) {\n                    try {\n                        currentHls.destroy();\n                    } catch (e) {\n                    }\n                }\n\n                // 创建新的HLS实例\n                const hls = new Hls(hlsConfig);\n                currentHls = hls;\n\n                // 跟踪是否已经显示错误\n                let errorDisplayed = false;\n                // 跟踪是否有错误发生\n                let errorCount = 0;\n                // 跟踪视频是否开始播放\n                let playbackStarted = false;\n                // 跟踪视频是否出现bufferAppendError\n                let bufferAppendErrorCount = 0;\n\n                // 监听视频播放事件\n                video.addEventListener('playing', function () {\n                    playbackStarted = true;\n                    document.getElementById('player-loading').style.display = 'none';\n                    document.getElementById('error').style.display = 'none';\n                });\n\n                // 监听视频进度事件\n                video.addEventListener('timeupdate', function () {\n                    if (video.currentTime > 1) {\n                        // 视频进度超过1秒，隐藏错误（如果存在）\n                        document.getElementById('error').style.display = 'none';\n                    }\n                });\n\n                hls.loadSource(url);\n                hls.attachMedia(video);\n\n                // enable airplay, from https://github.com/video-dev/hls.js/issues/5989\n                // 检查是否已存在source元素，如果存在则更新，不存在则创建\n                let sourceElement = video.querySelector('source');\n                if (sourceElement) {\n                    // 更新现有source元素的URL\n                    sourceElement.src = videoUrl;\n                } else {\n                    // 创建新的source元素\n                    sourceElement = document.createElement('source');\n                    sourceElement.src = videoUrl;\n                    video.appendChild(sourceElement);\n                }\n                video.disableRemotePlayback = false;\n\n                hls.on(Hls.Events.MANIFEST_PARSED, function () {\n                    video.play().catch(e => {\n                    });\n                });\n\n                hls.on(Hls.Events.ERROR, function (event, data) {\n                    // 增加错误计数\n                    errorCount++;\n\n                    // 处理bufferAppendError\n                    if (data.details === 'bufferAppendError') {\n                        bufferAppendErrorCount++;\n                        // 如果视频已经开始播放，则忽略这个错误\n                        if (playbackStarted) {\n                            return;\n                        }\n\n                        // 如果出现多次bufferAppendError但视频未播放，尝试恢复\n                        if (bufferAppendErrorCount >= 3) {\n                            hls.recoverMediaError();\n                        }\n                    }\n\n                    // 如果是致命错误，且视频未播放\n                    if (data.fatal && !playbackStarted) {\n                        // 尝试恢复错误\n                        switch (data.type) {\n                            case Hls.ErrorTypes.NETWORK_ERROR:\n                                hls.startLoad();\n                                break;\n                            case Hls.ErrorTypes.MEDIA_ERROR:\n                                hls.recoverMediaError();\n                                break;\n                            default:\n                                // 仅在多次恢复尝试后显示错误\n                                if (errorCount > 3 && !errorDisplayed) {\n                                    errorDisplayed = true;\n                                    showError('视频加载失败，可能是格式不兼容或源不可用');\n                                }\n                                break;\n                        }\n                    }\n                });\n\n                // 监听分段加载事件\n                hls.on(Hls.Events.FRAG_LOADED, function () {\n                    document.getElementById('player-loading').style.display = 'none';\n                });\n\n                // 监听级别加载事件\n                hls.on(Hls.Events.LEVEL_LOADED, function () {\n                    document.getElementById('player-loading').style.display = 'none';\n                });\n            }\n        }\n    });\n\n    // artplayer 没有 'fullscreenWeb:enter', 'fullscreenWeb:exit' 等事件\n    // 所以原控制栏隐藏代码并没有起作用\n    // 实际起作用的是 artplayer 默认行为，它支持自动隐藏工具栏\n    // 但有一个 bug： 在副屏全屏时，鼠标移出副屏后不会自动隐藏工具栏\n    // 下面进一并重构和修复：\n    let hideTimer;\n\n    // 隐藏控制栏\n    function hideControls() {\n        if (art && art.controls) {\n            art.controls.show = false;\n        }\n    }\n\n    // 重置计时器，计时器超时时间与 artplayer 保持一致\n    function resetHideTimer() {\n        clearTimeout(hideTimer);\n        hideTimer = setTimeout(() => {\n            hideControls();\n        }, Artplayer.CONTROL_HIDE_TIME);\n    }\n\n    // 处理鼠标离开浏览器窗口\n    function handleMouseOut(e) {\n        if (e && !e.relatedTarget) {\n            resetHideTimer();\n        }\n    }\n\n    // 全屏状态切换时注册/移除 mouseout 事件，监听鼠标移出屏幕事件\n    // 从而对播放器状态栏进行隐藏倒计时\n    function handleFullScreen(isFullScreen, isWeb) {\n        if (isFullScreen) {\n            document.addEventListener('mouseout', handleMouseOut);\n        } else {\n            document.removeEventListener('mouseout', handleMouseOut);\n            // 退出全屏时清理计时器\n            clearTimeout(hideTimer);\n        }\n\n        if (!isWeb) {\n            if (window.screen.orientation && window.screen.orientation.lock) {\n                window.screen.orientation.lock('landscape')\n                    .then(() => {\n                    })\n                    .catch((error) => {\n                    });\n            }\n        }\n    }\n\n    // 播放器加载完成后初始隐藏工具栏\n    art.on('ready', () => {\n        hideControls();\n    });\n\n    // 全屏 Web 模式处理\n    art.on('fullscreenWeb', function (isFullScreen) {\n        handleFullScreen(isFullScreen, true);\n    });\n\n    // 全屏模式处理\n    art.on('fullscreen', function (isFullScreen) {\n        handleFullScreen(isFullScreen, false);\n    });\n\n    art.on('video:loadedmetadata', function() {\n        document.getElementById('player-loading').style.display = 'none';\n        videoHasEnded = false; // 视频加载时重置结束标志\n        // 优先使用URL传递的position参数\n        const urlParams = new URLSearchParams(window.location.search);\n        const savedPosition = parseInt(urlParams.get('position') || '0');\n\n        if (savedPosition > 10 && savedPosition < art.duration - 2) {\n            // 如果URL中有有效的播放位置参数，直接使用它\n            art.currentTime = savedPosition;\n            showPositionRestoreHint(savedPosition);\n        } else {\n            // 否则尝试从本地存储恢复播放进度\n            try {\n                const progressKey = 'videoProgress_' + getVideoId();\n                const progressStr = localStorage.getItem(progressKey);\n                if (progressStr && art.duration > 0) {\n                    const progress = JSON.parse(progressStr);\n                    if (\n                        progress &&\n                        typeof progress.position === 'number' &&\n                        progress.position > 10 &&\n                        progress.position < art.duration - 2\n                    ) {\n                        art.currentTime = progress.position;\n                        showPositionRestoreHint(progress.position);\n                    }\n                }\n            } catch (e) {\n            }\n        }\n\n        // 设置进度条点击监听\n        setupProgressBarPreciseClicks();\n\n        // 视频加载成功后，在稍微延迟后将其添加到观看历史\n        setTimeout(saveToHistory, 3000);\n\n        // 启动定期保存播放进度\n        startProgressSaveInterval();\n    })\n\n    // 错误处理\n    art.on('video:error', function (error) {\n        // 如果正在切换视频，忽略错误\n        if (window.isSwitchingVideo) {\n            return;\n        }\n\n        // 隐藏所有加载指示器\n        const loadingElements = document.querySelectorAll('#player-loading, .player-loading-container');\n        loadingElements.forEach(el => {\n            if (el) el.style.display = 'none';\n        });\n\n        showError('视频播放失败: ' + (error.message || '未知错误'));\n    });\n\n    // 添加移动端长按三倍速播放功能\n    setupLongPressSpeedControl();\n\n    // 视频播放结束事件\n    art.on('video:ended', function () {\n        videoHasEnded = true;\n\n        clearVideoProgress();\n\n        // 如果自动播放下一集开启，且确实有下一集\n        if (autoplayEnabled && currentEpisodeIndex < currentEpisodes.length - 1) {\n            // 稍长延迟以确保所有事件处理完成\n            setTimeout(() => {\n                // 确认不是因为用户拖拽导致的假结束事件\n                playNextEpisode();\n                videoHasEnded = false; // 重置标志\n            }, 1000);\n        } else {\n            art.fullscreen = false;\n        }\n    });\n\n    // 添加双击全屏支持\n    art.on('video:playing', () => {\n        // 绑定双击事件到视频容器\n        if (art.video) {\n            art.video.addEventListener('dblclick', () => {\n                art.fullscreen = !art.fullscreen;\n                art.play();\n            });\n        }\n    });\n\n    // 10秒后如果仍在加载，但不立即显示错误\n    setTimeout(function () {\n        // 如果视频已经播放开始，则不显示错误\n        if (art && art.video && art.video.currentTime > 0) {\n            return;\n        }\n\n        const loadingElement = document.getElementById('player-loading');\n        if (loadingElement && loadingElement.style.display !== 'none') {\n            loadingElement.innerHTML = `\n                <div class=\"loading-spinner\"></div>\n                <div>视频加载时间较长，请耐心等待...</div>\n                <div style=\"font-size: 12px; color: #aaa; margin-top: 10px;\">如长时间无响应，请尝试其他视频源</div>\n            `;\n        }\n    }, 10000);\n}\n\n// 自定义M3U8 Loader用于过滤广告\nclass CustomHlsJsLoader extends Hls.DefaultConfig.loader {\n    constructor(config) {\n        super(config);\n        const load = this.load.bind(this);\n        this.load = function (context, config, callbacks) {\n            // 拦截manifest和level请求\n            if (context.type === 'manifest' || context.type === 'level') {\n                const onSuccess = callbacks.onSuccess;\n                callbacks.onSuccess = function (response, stats, context) {\n                    // 如果是m3u8文件，处理内容以移除广告分段\n                    if (response.data && typeof response.data === 'string') {\n                        // 过滤掉广告段 - 实现更精确的广告过滤逻辑\n                        response.data = filterAdsFromM3U8(response.data, true);\n                    }\n                    return onSuccess(response, stats, context);\n                };\n            }\n            // 执行原始load方法\n            load(context, config, callbacks);\n        };\n    }\n}\n\n// 过滤可疑的广告内容\nfunction filterAdsFromM3U8(m3u8Content, strictMode = false) {\n    if (!m3u8Content) return '';\n\n    // 按行分割M3U8内容\n    const lines = m3u8Content.split('\\n');\n    const filteredLines = [];\n\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i];\n\n        // 只过滤#EXT-X-DISCONTINUITY标识\n        if (!line.includes('#EXT-X-DISCONTINUITY')) {\n            filteredLines.push(line);\n        }\n    }\n\n    return filteredLines.join('\\n');\n}\n\n\n// 显示错误\nfunction showError(message) {\n    // 在视频已经播放的情况下不显示错误\n    if (art && art.video && art.video.currentTime > 1) {\n        return;\n    }\n    const loadingEl = document.getElementById('player-loading');\n    if (loadingEl) loadingEl.style.display = 'none';\n    const errorEl = document.getElementById('error');\n    if (errorEl) errorEl.style.display = 'flex';\n    const errorMsgEl = document.getElementById('error-message');\n    if (errorMsgEl) errorMsgEl.textContent = message;\n}\n\n// 更新集数信息\nfunction updateEpisodeInfo() {\n    if (currentEpisodes.length > 0) {\n        document.getElementById('episodeInfo').textContent = `第 ${currentEpisodeIndex + 1}/${currentEpisodes.length} 集`;\n    } else {\n        document.getElementById('episodeInfo').textContent = '无集数信息';\n    }\n}\n\n// 更新按钮状态\nfunction updateButtonStates() {\n    const prevButton = document.getElementById('prevButton');\n    const nextButton = document.getElementById('nextButton');\n\n    // 处理上一集按钮\n    if (currentEpisodeIndex > 0) {\n        prevButton.classList.remove('bg-gray-700', 'cursor-not-allowed');\n        prevButton.classList.add('bg-[#222]', 'hover:bg-[#333]');\n        prevButton.removeAttribute('disabled');\n    } else {\n        prevButton.classList.add('bg-gray-700', 'cursor-not-allowed');\n        prevButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');\n        prevButton.setAttribute('disabled', '');\n    }\n\n    // 处理下一集按钮\n    if (currentEpisodeIndex < currentEpisodes.length - 1) {\n        nextButton.classList.remove('bg-gray-700', 'cursor-not-allowed');\n        nextButton.classList.add('bg-[#222]', 'hover:bg-[#333]');\n        nextButton.removeAttribute('disabled');\n    } else {\n        nextButton.classList.add('bg-gray-700', 'cursor-not-allowed');\n        nextButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');\n        nextButton.setAttribute('disabled', '');\n    }\n}\n\n// 渲染集数按钮\nfunction renderEpisodes() {\n    const episodesList = document.getElementById('episodesList');\n    if (!episodesList) return;\n\n    if (!currentEpisodes || currentEpisodes.length === 0) {\n        episodesList.innerHTML = '<div class=\"col-span-full text-center text-gray-400 py-8\">没有可用的集数</div>';\n        return;\n    }\n\n    const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;\n    let html = '';\n\n    episodes.forEach((episode, index) => {\n        // 根据倒序状态计算真实的剧集索引\n        const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;\n        const isActive = realIndex === currentEpisodeIndex;\n\n        html += `\n            <button id=\"episode-${realIndex}\" \n                    onclick=\"playEpisode(${realIndex})\" \n                    class=\"px-4 py-2 ${isActive ? 'episode-active' : '!bg-[#222] hover:!bg-[#333] hover:!shadow-none'} !border ${isActive ? '!border-blue-500' : '!border-[#333]'} rounded-lg transition-colors text-center episode-btn\">\n                ${realIndex + 1}\n            </button>\n        `;\n    });\n\n    episodesList.innerHTML = html;\n}\n\n// 播放指定集数\nfunction playEpisode(index) {\n    // 确保index在有效范围内\n    if (index < 0 || index >= currentEpisodes.length) {\n        return;\n    }\n\n    // 保存当前播放进度（如果正在播放）\n    if (art && art.video && !art.video.paused && !videoHasEnded) {\n        saveCurrentProgress();\n    }\n\n    // 清除进度保存计时器\n    if (progressSaveInterval) {\n        clearInterval(progressSaveInterval);\n        progressSaveInterval = null;\n    }\n\n    // 首先隐藏之前可能显示的错误\n    document.getElementById('error').style.display = 'none';\n    // 显示加载指示器\n    document.getElementById('player-loading').style.display = 'flex';\n    document.getElementById('player-loading').innerHTML = `\n        <div class=\"loading-spinner\"></div>\n        <div>正在加载视频...</div>\n    `;\n\n    // 获取 sourceCode\n    const urlParams2 = new URLSearchParams(window.location.search);\n    const sourceCode = urlParams2.get('source_code');\n\n    // 准备切换剧集的URL\n    const url = currentEpisodes[index];\n\n    // 更新当前剧集索引\n    currentEpisodeIndex = index;\n    currentVideoUrl = url;\n    videoHasEnded = false; // 重置视频结束标志\n\n    clearVideoProgress();\n\n    // 更新URL参数（不刷新页面）\n    const currentUrl = new URL(window.location.href);\n    currentUrl.searchParams.set('index', index);\n    currentUrl.searchParams.set('url', url);\n    currentUrl.searchParams.delete('position');\n    window.history.replaceState({}, '', currentUrl.toString());\n\n    if (isWebkit) {\n        initPlayer(url);\n    } else {\n        art.switch = url;\n    }\n\n    // 更新UI\n    updateEpisodeInfo();\n    updateButtonStates();\n    renderEpisodes();\n\n    // 重置用户点击位置记录\n    userClickedPosition = null;\n\n    // 三秒后保存到历史记录\n    setTimeout(() => saveToHistory(), 3000);\n}\n\n// 播放上一集\nfunction playPreviousEpisode() {\n    if (currentEpisodeIndex > 0) {\n        playEpisode(currentEpisodeIndex - 1);\n    }\n}\n\n// 播放下一集\nfunction playNextEpisode() {\n    if (currentEpisodeIndex < currentEpisodes.length - 1) {\n        playEpisode(currentEpisodeIndex + 1);\n    }\n}\n\n// 复制播放链接\nfunction copyLinks() {\n    // 尝试从URL中获取参数\n    const urlParams = new URLSearchParams(window.location.search);\n    const linkUrl = urlParams.get('url') || '';\n    if (linkUrl !== '') {\n        navigator.clipboard.writeText(linkUrl).then(() => {\n            showToast('播放链接已复制', 'success');\n        }).catch(err => {\n            showToast('复制失败，请检查浏览器权限', 'error');\n        });\n    }\n}\n\n// 切换集数排序\nfunction toggleEpisodeOrder() {\n    episodesReversed = !episodesReversed;\n\n    // 保存到localStorage\n    localStorage.setItem('episodesReversed', episodesReversed);\n\n    // 重新渲染集数列表\n    renderEpisodes();\n\n    // 更新排序按钮\n    updateOrderButton();\n}\n\n// 更新排序按钮状态\nfunction updateOrderButton() {\n    const orderText = document.getElementById('orderText');\n    const orderIcon = document.getElementById('orderIcon');\n\n    if (orderText && orderIcon) {\n        orderText.textContent = episodesReversed ? '正序排列' : '倒序排列';\n        orderIcon.style.transform = episodesReversed ? 'rotate(180deg)' : '';\n    }\n}\n\n// 设置进度条准确点击处理\nfunction setupProgressBarPreciseClicks() {\n    // 查找DPlayer的进度条元素\n    const progressBar = document.querySelector('.dplayer-bar-wrap');\n    if (!progressBar || !art || !art.video) return;\n\n    // 移除可能存在的旧事件监听器\n    progressBar.removeEventListener('mousedown', handleProgressBarClick);\n\n    // 添加新的事件监听器\n    progressBar.addEventListener('mousedown', handleProgressBarClick);\n\n    // 在移动端也添加触摸事件支持\n    progressBar.removeEventListener('touchstart', handleProgressBarTouch);\n    progressBar.addEventListener('touchstart', handleProgressBarTouch);\n\n    // 处理进度条点击\n    function handleProgressBarClick(e) {\n        if (!art || !art.video) return;\n\n        // 计算点击位置相对于进度条的比例\n        const rect = e.currentTarget.getBoundingClientRect();\n        const percentage = (e.clientX - rect.left) / rect.width;\n\n        // 计算点击位置对应的视频时间\n        const duration = art.video.duration;\n        let clickTime = percentage * duration;\n\n        // 处理视频接近结尾的情况\n        if (duration - clickTime < 1) {\n            // 如果点击位置非常接近结尾，稍微往前移一点\n            clickTime = Math.min(clickTime, duration - 1.5);\n\n        }\n\n        // 记录用户点击的位置\n        userClickedPosition = clickTime;\n\n        // 阻止默认事件传播，避免DPlayer内部逻辑将视频跳至末尾\n        e.stopPropagation();\n\n        // 直接设置视频时间\n        art.seek(clickTime);\n    }\n\n    // 处理移动端触摸事件\n    function handleProgressBarTouch(e) {\n        if (!art || !art.video || !e.touches[0]) return;\n\n        const touch = e.touches[0];\n        const rect = e.currentTarget.getBoundingClientRect();\n        const percentage = (touch.clientX - rect.left) / rect.width;\n\n        const duration = art.video.duration;\n        let clickTime = percentage * duration;\n\n        // 处理视频接近结尾的情况\n        if (duration - clickTime < 1) {\n            clickTime = Math.min(clickTime, duration - 1.5);\n        }\n\n        // 记录用户点击的位置\n        userClickedPosition = clickTime;\n\n        e.stopPropagation();\n        art.seek(clickTime);\n    }\n}\n\n// 在播放器初始化后添加视频到历史记录\nfunction saveToHistory() {\n    // 确保 currentEpisodes 非空且有当前视频URL\n    if (!currentEpisodes || currentEpisodes.length === 0 || !currentVideoUrl) {\n        return;\n    }\n\n    // 尝试从URL中获取参数\n    const urlParams = new URLSearchParams(window.location.search);\n    const sourceName = urlParams.get('source') || '';\n    const sourceCode = urlParams.get('source') || '';\n    const id_from_params = urlParams.get('id'); // Get video ID from player URL (passed as 'id')\n\n    // 获取当前播放进度\n    let currentPosition = 0;\n    let videoDuration = 0;\n\n    if (art && art.video) {\n        currentPosition = art.video.currentTime;\n        videoDuration = art.video.duration;\n    }\n\n    // Define a show identifier: Prioritize sourceName_id, fallback to first episode URL or current video URL\n    let show_identifier_for_video_info;\n    if (sourceName && id_from_params) {\n        show_identifier_for_video_info = `${sourceName}_${id_from_params}`;\n    } else {\n        show_identifier_for_video_info = (currentEpisodes && currentEpisodes.length > 0) ? currentEpisodes[0] : currentVideoUrl;\n    }\n\n    // 构建要保存的视频信息对象\n    const videoInfo = {\n        title: currentVideoTitle,\n        directVideoUrl: currentVideoUrl, // Current episode's direct URL\n        url: `player.html?url=${encodeURIComponent(currentVideoUrl)}&title=${encodeURIComponent(currentVideoTitle)}&source=${encodeURIComponent(sourceName)}&source_code=${encodeURIComponent(sourceCode)}&id=${encodeURIComponent(id_from_params || '')}&index=${currentEpisodeIndex}&position=${Math.floor(currentPosition || 0)}`,\n        episodeIndex: currentEpisodeIndex,\n        sourceName: sourceName,\n        vod_id: id_from_params || '', // Store the ID from params as vod_id in history item\n        sourceCode: sourceCode,\n        showIdentifier: show_identifier_for_video_info, // Identifier for the show/series\n        timestamp: Date.now(),\n        playbackPosition: currentPosition,\n        duration: videoDuration,\n        episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : []\n    };\n    \n    try {\n        const history = JSON.parse(localStorage.getItem('viewingHistory') || '[]');\n\n        // 检查是否已经存在相同的系列记录 (基于标题、来源和 showIdentifier)\n        const existingIndex = history.findIndex(item => \n            item.title === videoInfo.title && \n            item.sourceName === videoInfo.sourceName && \n            item.showIdentifier === videoInfo.showIdentifier\n        );\n\n        if (existingIndex !== -1) {\n            // 存在则更新现有记录的当前集数、时间戳、播放进度和URL等\n            const existingItem = history[existingIndex];\n            existingItem.episodeIndex = videoInfo.episodeIndex;\n            existingItem.timestamp = videoInfo.timestamp;\n            existingItem.sourceName = videoInfo.sourceName; // Should be consistent, but update just in case\n            existingItem.sourceCode = videoInfo.sourceCode;\n            existingItem.vod_id = videoInfo.vod_id;\n            \n            // Update URLs to reflect the current episode being watched\n            existingItem.directVideoUrl = videoInfo.directVideoUrl; // Current episode's direct URL\n            existingItem.url = videoInfo.url; // Player link for the current episode\n\n            // 更新播放进度信息\n            existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0);\n            existingItem.duration = videoInfo.duration || existingItem.duration;\n            \n            // 更新集数列表（如果新的集数列表与存储的不同，例如集数增加了）\n            if (videoInfo.episodes && videoInfo.episodes.length > 0) {\n                if (!existingItem.episodes || \n                    !Array.isArray(existingItem.episodes) || \n                    existingItem.episodes.length !== videoInfo.episodes.length || \n                    !videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) { // Basic check for content change\n                    existingItem.episodes = [...videoInfo.episodes]; // Deep copy\n                }\n            }\n            \n            // 移到最前面\n            const updatedItem = history.splice(existingIndex, 1)[0];\n            history.unshift(updatedItem);\n        } else {\n            // 添加新记录到最前面\n            history.unshift(videoInfo);\n        }\n\n        // 限制历史记录数量为50条\n        if (history.length > 50) history.splice(50);\n\n        localStorage.setItem('viewingHistory', JSON.stringify(history));\n    } catch (e) {\n    }\n}\n\n// 显示恢复位置提示\nfunction showPositionRestoreHint(position) {\n    if (!position || position < 10) return;\n\n    // 创建提示元素\n    const hint = document.createElement('div');\n    hint.className = 'position-restore-hint';\n    hint.innerHTML = `\n        <div class=\"hint-content\">\n            已从 ${formatTime(position)} 继续播放\n        </div>\n    `;\n\n    // 添加到播放器容器\n    const playerContainer = document.querySelector('.player-container'); // Ensure this selector is correct\n    if (playerContainer) { // Check if playerContainer exists\n        playerContainer.appendChild(hint);\n    } else {\n        return; // Exit if container not found\n    }\n\n    // 显示提示\n    setTimeout(() => {\n        hint.classList.add('show');\n\n        // 3秒后隐藏\n        setTimeout(() => {\n            hint.classList.remove('show');\n            setTimeout(() => hint.remove(), 300);\n        }, 3000);\n    }, 100);\n}\n\n// 格式化时间为 mm:ss 格式\nfunction formatTime(seconds) {\n    if (isNaN(seconds)) return '00:00';\n\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n\n    return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;\n}\n\n// 开始定期保存播放进度\nfunction startProgressSaveInterval() {\n    // 清除可能存在的旧计时器\n    if (progressSaveInterval) {\n        clearInterval(progressSaveInterval);\n    }\n\n    // 每30秒保存一次播放进度\n    progressSaveInterval = setInterval(saveCurrentProgress, 30000);\n}\n\n// 保存当前播放进度\nfunction saveCurrentProgress() {\n    if (!art || !art.video) return;\n    const currentTime = art.video.currentTime;\n    const duration = art.video.duration;\n    if (!duration || currentTime < 1) return;\n\n    // 在localStorage中保存进度\n    const progressKey = `videoProgress_${getVideoId()}`;\n    const progressData = {\n        position: currentTime,\n        duration: duration,\n        timestamp: Date.now()\n    };\n    try {\n        localStorage.setItem(progressKey, JSON.stringify(progressData));\n        // --- 新增：同步更新 viewingHistory 中的进度 ---\n        try {\n            const historyRaw = localStorage.getItem('viewingHistory');\n            if (historyRaw) {\n                const history = JSON.parse(historyRaw);\n                // 用 title + 集数索引唯一标识\n                const idx = history.findIndex(item =>\n                    item.title === currentVideoTitle &&\n                    (item.episodeIndex === undefined || item.episodeIndex === currentEpisodeIndex)\n                );\n                if (idx !== -1) {\n                    // 只在进度有明显变化时才更新，减少写入\n                    if (\n                        Math.abs((history[idx].playbackPosition || 0) - currentTime) > 2 ||\n                        Math.abs((history[idx].duration || 0) - duration) > 2\n                    ) {\n                        history[idx].playbackPosition = currentTime;\n                        history[idx].duration = duration;\n                        history[idx].timestamp = Date.now();\n                        localStorage.setItem('viewingHistory', JSON.stringify(history));\n                    }\n                }\n            }\n        } catch (e) {\n        }\n    } catch (e) {\n    }\n}\n\n// 设置移动端长按三倍速播放功能\nfunction setupLongPressSpeedControl() {\n    if (!art || !art.video) return;\n\n    const playerElement = document.getElementById('player');\n    let longPressTimer = null;\n    let originalPlaybackRate = 1.0;\n    let isLongPress = false;\n\n    // 显示快速提示\n    function showSpeedHint(speed) {\n        showShortcutHint(`${speed}倍速`, 'right');\n    }\n\n    // 禁用右键\n    playerElement.oncontextmenu = () => {\n        // 检测是否为移动设备\n        const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);\n\n        // 只在移动设备上禁用右键\n        if (isMobile) {\n            const dplayerMenu = document.querySelector(\".dplayer-menu\");\n            const dplayerMask = document.querySelector(\".dplayer-mask\");\n            if (dplayerMenu) dplayerMenu.style.display = \"none\";\n            if (dplayerMask) dplayerMask.style.display = \"none\";\n            return false;\n        }\n        return true; // 在桌面设备上允许右键菜单\n    };\n\n    // 触摸开始事件\n    playerElement.addEventListener('touchstart', function (e) {\n        // 检查视频是否正在播放，如果没有播放则不触发长按功能\n        if (art.video.paused) {\n            return; // 视频暂停时不触发长按功能\n        }\n\n        // 保存原始播放速度\n        originalPlaybackRate = art.video.playbackRate;\n\n        // 设置长按计时器\n        longPressTimer = setTimeout(() => {\n            // 再次检查视频是否仍在播放\n            if (art.video.paused) {\n                clearTimeout(longPressTimer);\n                longPressTimer = null;\n                return;\n            }\n\n            // 长按超过500ms，设置为3倍速\n            art.video.playbackRate = 3.0;\n            isLongPress = true;\n            showSpeedHint(3.0);\n\n            // 只在确认为长按时阻止默认行为\n            e.preventDefault();\n        }, 500);\n    }, { passive: false });\n\n    // 触摸结束事件\n    playerElement.addEventListener('touchend', function (e) {\n        // 清除长按计时器\n        if (longPressTimer) {\n            clearTimeout(longPressTimer);\n            longPressTimer = null;\n        }\n\n        // 如果是长按状态，恢复原始播放速度\n        if (isLongPress) {\n            art.video.playbackRate = originalPlaybackRate;\n            isLongPress = false;\n            showSpeedHint(originalPlaybackRate);\n\n            // 阻止长按后的点击事件\n            e.preventDefault();\n        }\n        // 如果不是长按，则允许正常的点击事件（暂停/播放）\n    });\n\n    // 触摸取消事件\n    playerElement.addEventListener('touchcancel', function () {\n        // 清除长按计时器\n        if (longPressTimer) {\n            clearTimeout(longPressTimer);\n            longPressTimer = null;\n        }\n\n        // 如果是长按状态，恢复原始播放速度\n        if (isLongPress) {\n            art.video.playbackRate = originalPlaybackRate;\n            isLongPress = false;\n        }\n    });\n\n    // 触摸移动事件 - 防止在长按时触发页面滚动\n    playerElement.addEventListener('touchmove', function (e) {\n        if (isLongPress) {\n            e.preventDefault();\n        }\n    }, { passive: false });\n\n    // 视频暂停时取消长按状态\n    art.video.addEventListener('pause', function () {\n        if (isLongPress) {\n            art.video.playbackRate = originalPlaybackRate;\n            isLongPress = false;\n        }\n\n        if (longPressTimer) {\n            clearTimeout(longPressTimer);\n            longPressTimer = null;\n        }\n    });\n}\n\n// 清除视频进度记录\nfunction clearVideoProgress() {\n    const progressKey = `videoProgress_${getVideoId()}`;\n    try {\n        localStorage.removeItem(progressKey);\n    } catch (e) {\n    }\n}\n\n// 获取视频唯一标识\nfunction getVideoId() {\n    // 使用视频标题和集数索引作为唯一标识\n    // If currentVideoUrl is available and more unique, prefer it. Otherwise, fallback.\n    if (currentVideoUrl) {\n        return `${encodeURIComponent(currentVideoUrl)}`;\n    }\n    return `${encodeURIComponent(currentVideoTitle)}_${currentEpisodeIndex}`;\n}\n\nlet controlsLocked = false;\nfunction toggleControlsLock() {\n    const container = document.getElementById('playerContainer');\n    controlsLocked = !controlsLocked;\n    container.classList.toggle('controls-locked', controlsLocked);\n    const icon = document.getElementById('lockIcon');\n    // 切换图标：锁 / 解锁\n    icon.innerHTML = controlsLocked\n        ? '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\\\"M12 15v2m0-8V7a4 4 0 00-8 0v2m8 0H4v8h16v-8H6v-6z\\\"/>'\n        : '<path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\\\"M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z\\\"/>';\n}\n\n// 支持在iframe中关闭播放器\nfunction closeEmbeddedPlayer() {\n    try {\n        if (window.self !== window.top) {\n            // 如果在iframe中，尝试调用父窗口的关闭方法\n            if (window.parent && typeof window.parent.closeVideoPlayer === 'function') {\n                window.parent.closeVideoPlayer();\n                return true;\n            }\n        }\n    } catch (e) {\n        console.error('尝试关闭嵌入式播放器失败:', e);\n    }\n    return false;\n}\n\nfunction renderResourceInfoBar() {\n    // 获取容器元素\n    const container = document.getElementById('resourceInfoBarContainer');\n    if (!container) {\n        console.error('找不到资源信息卡片容器');\n        return;\n    }\n    \n    // 获取当前视频 source_code\n    const urlParams = new URLSearchParams(window.location.search);\n    const currentSource = urlParams.get('source') || '';\n    \n    // 显示临时加载状态\n    container.innerHTML = `\n      <div class=\"resource-info-bar-left flex\">\n        <span>加载中...</span>\n        <span class=\"resource-info-bar-videos\">-</span>\n      </div>\n      <button class=\"resource-switch-btn flex\" id=\"switchResourceBtn\" onclick=\"showSwitchResourceModal()\">\n        <span class=\"resource-switch-icon\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12 4v16m0 0l-6-6m6 6l6-6\" stroke=\"#a67c2d\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n        </span>\n        切换资源\n      </button>\n    `;\n\n    // 查找当前源名称，从 API_SITES 和 custom_api 中查找即可\n    let resourceName = currentSource\n    if (currentSource && API_SITES[currentSource]) {\n        resourceName = API_SITES[currentSource].name;\n    }\n    if (resourceName === currentSource) {\n        const customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]');\n        const customIndex = parseInt(currentSource.replace('custom_', ''), 10);\n        if (customAPIs[customIndex]) {\n            resourceName = customAPIs[customIndex].name || '自定义资源';\n        }\n    }\n\n    container.innerHTML = `\n      <div class=\"resource-info-bar-left flex\">\n        <span>${resourceName}</span>\n        <span class=\"resource-info-bar-videos\">${currentEpisodes.length} 个视频</span>\n      </div>\n      <button class=\"resource-switch-btn flex\" id=\"switchResourceBtn\" onclick=\"showSwitchResourceModal()\">\n        <span class=\"resource-switch-icon\">\n          <svg viewBox=\"0 0 24 24\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M12 4v16m0 0l-6-6m6 6l6-6\" stroke=\"#a67c2d\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></svg>\n        </span>\n        切换资源\n      </button>\n    `;\n}\n\n// 测试视频源速率的函数\nasync function testVideoSourceSpeed(sourceKey, vodId) {\n    try {\n        const startTime = performance.now();\n        \n        // 构建API参数\n        let apiParams = '';\n        if (sourceKey.startsWith('custom_')) {\n            const customIndex = sourceKey.replace('custom_', '');\n            const customApi = getCustomApiInfo(customIndex);\n            if (!customApi) {\n                return { speed: -1, error: 'API配置无效' };\n            }\n            if (customApi.detail) {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';\n            } else {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';\n            }\n        } else {\n            apiParams = '&source=' + sourceKey;\n        }\n        \n        // 添加时间戳防止缓存\n        const timestamp = new Date().getTime();\n        const cacheBuster = `&_t=${timestamp}`;\n        \n        // 获取视频详情\n        const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`, {\n            method: 'GET',\n            cache: 'no-cache'\n        });\n        \n        if (!response.ok) {\n            return { speed: -1, error: '获取失败' };\n        }\n        \n        const data = await response.json();\n        \n        if (!data.episodes || data.episodes.length === 0) {\n            return { speed: -1, error: '无播放源' };\n        }\n        \n        // 测试第一个播放链接的响应速度\n        const firstEpisodeUrl = data.episodes[0];\n        if (!firstEpisodeUrl) {\n            return { speed: -1, error: '链接无效' };\n        }\n        \n        // 测试视频链接响应时间\n        const videoTestStart = performance.now();\n        try {\n            const videoResponse = await fetch(firstEpisodeUrl, {\n                method: 'HEAD',\n                mode: 'no-cors',\n                cache: 'no-cache',\n                signal: AbortSignal.timeout(5000) // 5秒超时\n            });\n            \n            const videoTestEnd = performance.now();\n            const totalTime = videoTestEnd - startTime;\n            \n            // 返回总响应时间（毫秒）\n            return { \n                speed: Math.round(totalTime),\n                episodes: data.episodes.length,\n                error: null \n            };\n        } catch (videoError) {\n            // 如果视频链接测试失败，只返回API响应时间\n            const apiTime = performance.now() - startTime;\n            return { \n                speed: Math.round(apiTime),\n                episodes: data.episodes.length,\n                error: null,\n                note: 'API响应' \n            };\n        }\n        \n    } catch (error) {\n        return { \n            speed: -1, \n            error: error.name === 'AbortError' ? '超时' : '测试失败' \n        };\n    }\n}\n\n// 格式化速度显示\nfunction formatSpeedDisplay(speedResult) {\n    if (speedResult.speed === -1) {\n        return `<span class=\"speed-indicator error\">❌ ${speedResult.error}</span>`;\n    }\n    \n    const speed = speedResult.speed;\n    let className = 'speed-indicator good';\n    let icon = '🟢';\n    \n    if (speed > 2000) {\n        className = 'speed-indicator poor';\n        icon = '🔴';\n    } else if (speed > 1000) {\n        className = 'speed-indicator medium';\n        icon = '🟡';\n    }\n    \n    const note = speedResult.note ? ` (${speedResult.note})` : '';\n    return `<span class=\"${className}\">${icon} ${speed}ms${note}</span>`;\n}\n\nasync function showSwitchResourceModal() {\n    const urlParams = new URLSearchParams(window.location.search);\n    const currentSourceCode = urlParams.get('source');\n    const currentVideoId = urlParams.get('id');\n\n    const modal = document.getElementById('modal');\n    const modalTitle = document.getElementById('modalTitle');\n    const modalContent = document.getElementById('modalContent');\n\n    modalTitle.innerHTML = `<span class=\"break-words\">${currentVideoTitle}</span>`;\n    modalContent.innerHTML = '<div style=\"text-align:center;padding:20px;color:#aaa;grid-column:1/-1;\">正在加载资源列表...</div>';\n    modal.classList.remove('hidden');\n\n    // 搜索\n    const resourceOptions = selectedAPIs.map((curr) => {\n        if (API_SITES[curr]) {\n            return { key: curr, name: API_SITES[curr].name };\n        }\n        const customIndex = parseInt(curr.replace('custom_', ''), 10);\n        if (customAPIs[customIndex]) {\n            return { key: curr, name: customAPIs[customIndex].name || '自定义资源' };\n        }\n        return { key: curr, name: '未知资源' };\n    });\n    let allResults = {};\n    await Promise.all(resourceOptions.map(async (opt) => {\n        let queryResult = await searchByAPIAndKeyWord(opt.key, currentVideoTitle);\n        if (queryResult.length == 0) {\n            return \n        }\n        // 优先取完全同名资源，否则默认取第一个\n        let result = queryResult[0]\n        queryResult.forEach((res) => {\n            if (res.vod_name == currentVideoTitle) {\n                result = res;\n            }\n        })\n        allResults[opt.key] = result;\n    }));\n\n    // 更新状态显示：开始速率测试\n    modalContent.innerHTML = '<div style=\"text-align:center;padding:20px;color:#aaa;grid-column:1/-1;\">正在测试各资源速率...</div>';\n\n    // 同时测试所有资源的速率\n    const speedResults = {};\n    await Promise.all(Object.entries(allResults).map(async ([sourceKey, result]) => {\n        if (result) {\n            speedResults[sourceKey] = await testVideoSourceSpeed(sourceKey, result.vod_id);\n        }\n    }));\n\n    // 对结果进行排序\n    const sortedResults = Object.entries(allResults).sort(([keyA, resultA], [keyB, resultB]) => {\n        // 当前播放的源放在最前面\n        const isCurrentA = String(keyA) === String(currentSourceCode) && String(resultA.vod_id) === String(currentVideoId);\n        const isCurrentB = String(keyB) === String(currentSourceCode) && String(resultB.vod_id) === String(currentVideoId);\n        \n        if (isCurrentA && !isCurrentB) return -1;\n        if (!isCurrentA && isCurrentB) return 1;\n        \n        // 其余按照速度排序，速度快的在前面（速度为-1表示失败，排到最后）\n        const speedA = speedResults[keyA]?.speed || 99999;\n        const speedB = speedResults[keyB]?.speed || 99999;\n        \n        if (speedA === -1 && speedB !== -1) return 1;\n        if (speedA !== -1 && speedB === -1) return -1;\n        if (speedA === -1 && speedB === -1) return 0;\n        \n        return speedA - speedB;\n    });\n\n    // 渲染资源列表\n    let html = '<div class=\"grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-4\">';\n    \n    for (const [sourceKey, result] of sortedResults) {\n        if (!result) continue;\n        \n        // 修复 isCurrentSource 判断，确保类型一致\n        const isCurrentSource = String(sourceKey) === String(currentSourceCode) && String(result.vod_id) === String(currentVideoId);\n        const sourceName = resourceOptions.find(opt => opt.key === sourceKey)?.name || '未知资源';\n        const speedResult = speedResults[sourceKey] || { speed: -1, error: '未测试' };\n        \n        html += `\n            <div class=\"relative group ${isCurrentSource ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:scale-105 transition-transform'}\" \n                 ${!isCurrentSource ? `onclick=\"switchToResource('${sourceKey}', '${result.vod_id}')\"` : ''}>\n                <div class=\"aspect-[2/3] rounded-lg overflow-hidden bg-gray-800 relative\">\n                    <img src=\"${result.vod_pic}\" \n                         alt=\"${result.vod_name}\"\n                         class=\"w-full h-full object-cover\"\n                         onerror=\"this.src='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNjY2IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+PHJlY3QgeD0iMyIgeT0iMyIgd2lkdGg9IjE4IiBoZWlnaHQ9IjE4IiByeD0iMiIgcnk9IjIiPjwvcmVjdD48cGF0aCBkPSJNMjEgMTV2NGEyIDIgMCAwIDEtMiAySDVhMiAyIDAgMCAxLTItMnYtNCI+PC9wYXRoPjxwb2x5bGluZSBwb2ludHM9IjE3IDggMTIgMyA3IDgiPjwvcG9seWxpbmU+PHBhdGggZD0iTTEyIDN2MTIiPjwvcGF0aD48L3N2Zz4='\">\n                    \n                    <!-- 速率显示在图片右上角 -->\n                    <div class=\"absolute top-1 right-1 speed-badge bg-black bg-opacity-75\">\n                        ${formatSpeedDisplay(speedResult)}\n                    </div>\n                </div>\n                <div class=\"mt-2\">\n                    <div class=\"text-xs font-medium text-gray-200 truncate\">${result.vod_name}</div>\n                    <div class=\"text-[10px] text-gray-400 truncate\">${sourceName}</div>\n                    <div class=\"text-[10px] text-gray-500 mt-1\">\n                        ${speedResult.episodes ? `${speedResult.episodes}集` : ''}\n                    </div>\n                </div>\n                ${isCurrentSource ? `\n                    <div class=\"absolute inset-0 flex items-center justify-center\">\n                        <div class=\"bg-blue-600 bg-opacity-75 rounded-lg px-2 py-0.5 text-xs text-white font-medium\">\n                            当前播放\n                        </div>\n                    </div>\n                ` : ''}\n            </div>\n        `;\n    }\n    \n    html += '</div>';\n    modalContent.innerHTML = html;\n}\n\n// 切换资源的函数\nasync function switchToResource(sourceKey, vodId) {\n    // 关闭模态框\n    document.getElementById('modal').classList.add('hidden');\n    \n    showLoading();\n    try {\n        // 构建API参数\n        let apiParams = '';\n        \n        // 处理自定义API源\n        if (sourceKey.startsWith('custom_')) {\n            const customIndex = sourceKey.replace('custom_', '');\n            const customApi = getCustomApiInfo(customIndex);\n            if (!customApi) {\n                showToast('自定义API配置无效', 'error');\n                hideLoading();\n                return;\n            }\n            // 传递 detail 字段\n            if (customApi.detail) {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom';\n            } else {\n                apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';\n            }\n        } else {\n            // 内置API\n            apiParams = '&source=' + sourceKey;\n        }\n        \n        // Add a timestamp to prevent caching\n        const timestamp = new Date().getTime();\n        const cacheBuster = `&_t=${timestamp}`;\n        const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`);\n        \n        const data = await response.json();\n        \n        if (!data.episodes || data.episodes.length === 0) {\n            showToast('未找到播放资源', 'error');\n            hideLoading();\n            return;\n        }\n\n        // 获取当前播放的集数索引\n        const currentIndex = currentEpisodeIndex;\n        \n        // 确定要播放的集数索引\n        let targetIndex = 0;\n        if (currentIndex < data.episodes.length) {\n            // 如果当前集数在新资源中存在，则使用相同集数\n            targetIndex = currentIndex;\n        }\n        \n        // 获取目标集数的URL\n        const targetUrl = data.episodes[targetIndex];\n        \n        // 构建播放页面URL\n        const watchUrl = `player.html?id=${vodId}&source=${sourceKey}&url=${encodeURIComponent(targetUrl)}&index=${targetIndex}&title=${encodeURIComponent(currentVideoTitle)}`;\n        \n        // 保存当前状态到localStorage\n        try {\n            localStorage.setItem('currentVideoTitle', data.vod_name || '未知视频');\n            localStorage.setItem('currentEpisodes', JSON.stringify(data.episodes));\n            localStorage.setItem('currentEpisodeIndex', targetIndex);\n            localStorage.setItem('currentSourceCode', sourceKey);\n            localStorage.setItem('lastPlayTime', Date.now());\n        } catch (e) {\n            console.error('保存播放状态失败:', e);\n        }\n\n        // 跳转到播放页面\n        window.location.href = watchUrl;\n        \n    } catch (error) {\n        console.error('切换资源失败:', error);\n        showToast('切换资源失败，请稍后重试', 'error');\n    } finally {\n        hideLoading();\n    }\n}"
  },
  {
    "path": "js/proxy-auth.js",
    "content": "/**\n * 代理请求鉴权模块\n * 为代理请求添加基于 PASSWORD 的鉴权机制\n */\n\n// 从全局配置获取密码哈希（如果存在）\nlet cachedPasswordHash = null;\n\n/**\n * 获取当前会话的密码哈希\n */\nasync function getPasswordHash() {\n    if (cachedPasswordHash) {\n        return cachedPasswordHash;\n    }\n    \n    // 1. 优先从已存储的代理鉴权哈希获取\n    const storedHash = localStorage.getItem('proxyAuthHash');\n    if (storedHash) {\n        cachedPasswordHash = storedHash;\n        return storedHash;\n    }\n    \n    // 2. 尝试从密码验证状态获取（password.js 验证后存储的哈希）\n    const passwordVerified = localStorage.getItem('passwordVerified');\n    const storedPasswordHash = localStorage.getItem('passwordHash');\n    if (passwordVerified === 'true' && storedPasswordHash) {\n        localStorage.setItem('proxyAuthHash', storedPasswordHash);\n        cachedPasswordHash = storedPasswordHash;\n        return storedPasswordHash;\n    }\n    \n    // 3. 尝试从用户输入的密码生成哈希\n    const userPassword = localStorage.getItem('userPassword');\n    if (userPassword) {\n        try {\n            // 动态导入 sha256 函数\n            const { sha256 } = await import('./sha256.js');\n            const hash = await sha256(userPassword);\n            localStorage.setItem('proxyAuthHash', hash);\n            cachedPasswordHash = hash;\n            return hash;\n        } catch (error) {\n            console.error('生成密码哈希失败:', error);\n        }\n    }\n    \n    // 4. 如果用户没有设置密码，尝试使用环境变量中的密码哈希\n    if (window.__ENV__ && window.__ENV__.PASSWORD) {\n        cachedPasswordHash = window.__ENV__.PASSWORD;\n        return window.__ENV__.PASSWORD;\n    }\n    \n    return null;\n}\n\n/**\n * 为代理请求URL添加鉴权参数\n */\nasync function addAuthToProxyUrl(url) {\n    try {\n        const hash = await getPasswordHash();\n        if (!hash) {\n            console.warn('无法获取密码哈希，代理请求可能失败');\n            return url;\n        }\n        \n        // 添加时间戳防止重放攻击\n        const timestamp = Date.now();\n        \n        // 检查URL是否已包含查询参数\n        const separator = url.includes('?') ? '&' : '?';\n        \n        return `${url}${separator}auth=${encodeURIComponent(hash)}&t=${timestamp}`;\n    } catch (error) {\n        console.error('添加代理鉴权失败:', error);\n        return url;\n    }\n}\n\n/**\n * 验证代理请求的鉴权\n */\nfunction validateProxyAuth(authHash, serverPasswordHash, timestamp) {\n    if (!authHash || !serverPasswordHash) {\n        return false;\n    }\n    \n    // 验证哈希是否匹配\n    if (authHash !== serverPasswordHash) {\n        return false;\n    }\n    \n    // 验证时间戳（10分钟有效期）\n    const now = Date.now();\n    const maxAge = 10 * 60 * 1000; // 10分钟\n    \n    if (timestamp && (now - parseInt(timestamp)) > maxAge) {\n        console.warn('代理请求时间戳过期');\n        return false;\n    }\n    \n    return true;\n}\n\n/**\n * 清除缓存的鉴权信息\n */\nfunction clearAuthCache() {\n    cachedPasswordHash = null;\n    localStorage.removeItem('proxyAuthHash');\n}\n\n// 监听密码变化，清除缓存\nwindow.addEventListener('storage', (e) => {\n    if (e.key === 'userPassword' || (window.PASSWORD_CONFIG && e.key === window.PASSWORD_CONFIG.localStorageKey)) {\n        clearAuthCache();\n    }\n});\n\n// 导出函数\nwindow.ProxyAuth = {\n    addAuthToProxyUrl,\n    validateProxyAuth,\n    clearAuthCache,\n    getPasswordHash\n};\n"
  },
  {
    "path": "js/pwa-register.js",
    "content": "// PWA 注册\nif ('serviceWorker' in navigator) {\n    window.addEventListener('load', () => {\n        navigator.serviceWorker.register('/service-worker.js');\n    });\n}\n"
  },
  {
    "path": "js/search.js",
    "content": "async function searchByAPIAndKeyWord(apiId, query) {\n    try {\n        let apiUrl, apiName, apiBaseUrl;\n        \n        // 处理自定义API\n        if (apiId.startsWith('custom_')) {\n            const customIndex = apiId.replace('custom_', '');\n            const customApi = getCustomApiInfo(customIndex);\n            if (!customApi) return [];\n            \n            apiBaseUrl = customApi.url;\n            apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);\n            apiName = customApi.name;\n        } else {\n            // 内置API\n            if (!API_SITES[apiId]) return [];\n            apiBaseUrl = API_SITES[apiId].api;\n            apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query);\n            apiName = API_SITES[apiId].name;\n        }\n        \n        // 添加超时处理\n        const controller = new AbortController();\n        const timeoutId = setTimeout(() => controller.abort(), 15000);\n        \n        // 添加鉴权参数到代理URL\n        const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n            await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) :\n            PROXY_URL + encodeURIComponent(apiUrl);\n        \n        const response = await fetch(proxiedUrl, {\n            headers: API_CONFIG.search.headers,\n            signal: controller.signal\n        });\n        \n        clearTimeout(timeoutId);\n        \n        if (!response.ok) {\n            return [];\n        }\n        \n        const data = await response.json();\n        \n        if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {\n            return [];\n        }\n        \n        // 处理第一页结果\n        const results = data.list.map(item => ({\n            ...item,\n            source_name: apiName,\n            source_code: apiId,\n            api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined\n        }));\n        \n        // 获取总页数\n        const pageCount = data.pagecount || 1;\n        // 确定需要获取的额外页数 (最多获取maxPages页)\n        const pagesToFetch = Math.min(pageCount - 1, API_CONFIG.search.maxPages - 1);\n        \n        // 如果有额外页数，获取更多页的结果\n        if (pagesToFetch > 0) {\n            const additionalPagePromises = [];\n            \n            for (let page = 2; page <= pagesToFetch + 1; page++) {\n                // 构建分页URL\n                const pageUrl = apiBaseUrl + API_CONFIG.search.pagePath\n                    .replace('{query}', encodeURIComponent(query))\n                    .replace('{page}', page);\n                \n                // 创建获取额外页的Promise\n                const pagePromise = (async () => {\n                    try {\n                        const pageController = new AbortController();\n                        const pageTimeoutId = setTimeout(() => pageController.abort(), 15000);\n                        \n                        // 添加鉴权参数到代理URL\n                        const proxiedPageUrl = await window.ProxyAuth?.addAuthToProxyUrl ? \n                            await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(pageUrl)) :\n                            PROXY_URL + encodeURIComponent(pageUrl);\n                        \n                        const pageResponse = await fetch(proxiedPageUrl, {\n                            headers: API_CONFIG.search.headers,\n                            signal: pageController.signal\n                        });\n                        \n                        clearTimeout(pageTimeoutId);\n                        \n                        if (!pageResponse.ok) return [];\n                        \n                        const pageData = await pageResponse.json();\n                        \n                        if (!pageData || !pageData.list || !Array.isArray(pageData.list)) return [];\n                        \n                        // 处理当前页结果\n                        return pageData.list.map(item => ({\n                            ...item,\n                            source_name: apiName,\n                            source_code: apiId,\n                            api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined\n                        }));\n                    } catch (error) {\n                        console.warn(`API ${apiId} 第${page}页搜索失败:`, error);\n                        return [];\n                    }\n                })();\n                \n                additionalPagePromises.push(pagePromise);\n            }\n            \n            // 等待所有额外页的结果\n            const additionalResults = await Promise.all(additionalPagePromises);\n            \n            // 合并所有页的结果\n            additionalResults.forEach(pageResults => {\n                if (pageResults.length > 0) {\n                    results.push(...pageResults);\n                }\n            });\n        }\n        \n        return results;\n    } catch (error) {\n        console.warn(`API ${apiId} 搜索失败:`, error);\n        return [];\n    }\n}"
  },
  {
    "path": "js/sha256.js",
    "content": "export async function sha256(message) {\n    const msgBuffer = new TextEncoder().encode(message);\n    const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);\n    const hashArray = Array.from(new Uint8Array(hashBuffer));\n    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n}\n"
  },
  {
    "path": "js/ui.js",
    "content": "// UI相关函数\nfunction toggleSettings(e) {\n    // 强化的密码保护校验 - 防止绕过\n    try {\n        if (window.ensurePasswordProtection) {\n            window.ensurePasswordProtection();\n        } else {\n            // 兼容性检查\n            if (window.isPasswordProtected && window.isPasswordVerified) {\n                if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n                    showPasswordModal && showPasswordModal();\n                    return;\n                }\n            }\n        }\n    } catch (error) {\n        console.warn('Password protection check failed:', error.message);\n        return;\n    }\n    // 阻止事件冒泡，防止触发document的点击事件\n    e && e.stopPropagation();\n    const panel = document.getElementById('settingsPanel');\n    panel.classList.toggle('show');\n}\n\n// 改进的Toast显示函数 - 支持队列显示多个Toast\nconst toastQueue = [];\nlet isShowingToast = false;\n\nfunction showToast(message, type = 'error') {\n    // 首先确保toast元素存在\n    let toast = document.getElementById('toast');\n    let toastMessage = document.getElementById('toastMessage');\n\n    // 如果toast元素不存在，创建它\n    if (!toast) {\n        toast = document.createElement('div');\n        toast.id = 'toast';\n        toast.className = 'fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 z-50 opacity-0';\n        toast.style = 'z-index: 2147483647'\n        toastMessage = document.createElement('p');\n        toastMessage.id = 'toastMessage';\n        toast.appendChild(toastMessage);\n\n        document.body.appendChild(toast);\n    }\n\n    // 将新的toast添加到队列\n    toastQueue.push({ message, type });\n\n    // 如果当前没有显示中的toast，则开始显示\n    if (!isShowingToast) {\n        showNextToast();\n    }\n}\n\nfunction showNextToast() {\n    if (toastQueue.length === 0) {\n        isShowingToast = false;\n        return;\n    }\n\n    isShowingToast = true;\n    const { message, type } = toastQueue.shift();\n\n    const toast = document.getElementById('toast');\n    const toastMessage = document.getElementById('toastMessage');\n\n    const bgColors = {\n        'error': 'bg-red-500',\n        'success': 'bg-green-500',\n        'info': 'bg-blue-500',\n        'warning': 'bg-yellow-500'\n    };\n\n    const bgColor = bgColors[type] || bgColors.error;\n    toast.className = `fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 ${bgColor} text-white z-50`;\n    toastMessage.textContent = message;\n\n    // 显示提示\n    toast.style.opacity = '1';\n    toast.style.transform = 'translateX(-50%) translateY(0)';\n\n    // 3秒后自动隐藏\n    setTimeout(() => {\n        toast.style.opacity = '0';\n        toast.style.transform = 'translateX(-50%) translateY(-100%)';\n\n        // 等待动画完成后显示下一个toast\n        setTimeout(() => {\n            showNextToast();\n        }, 300);\n    }, 3000);\n}\n\n// 添加显示/隐藏 loading 的函数\nlet loadingTimeoutId = null;\n\nfunction showLoading(message = '加载中...') {\n    // 清除任何现有的超时\n    if (loadingTimeoutId) {\n        clearTimeout(loadingTimeoutId);\n    }\n\n    const loading = document.getElementById('loading');\n    const messageEl = loading.querySelector('p');\n    messageEl.textContent = message;\n    loading.style.display = 'flex';\n\n    // 设置30秒后自动关闭loading，防止无限loading\n    loadingTimeoutId = setTimeout(() => {\n        hideLoading();\n        showToast('操作超时，请稍后重试', 'warning');\n    }, 30000);\n}\n\nfunction hideLoading() {\n    // 清除超时\n    if (loadingTimeoutId) {\n        clearTimeout(loadingTimeoutId);\n        loadingTimeoutId = null;\n    }\n\n    const loading = document.getElementById('loading');\n    loading.style.display = 'none';\n}\n\nfunction updateSiteStatus(isAvailable) {\n    const statusEl = document.getElementById('siteStatus');\n    if (isAvailable) {\n        statusEl.innerHTML = '<span class=\"text-green-500\">●</span> 可用';\n    } else {\n        statusEl.innerHTML = '<span class=\"text-red-500\">●</span> 不可用';\n    }\n}\n\nfunction closeModal() {\n    document.getElementById('modal').classList.add('hidden');\n    // 清除 iframe 内容\n    document.getElementById('modalContent').innerHTML = '';\n}\n\n// 获取搜索历史的增强版本 - 支持新旧格式\nfunction getSearchHistory() {\n    try {\n        const data = localStorage.getItem(SEARCH_HISTORY_KEY);\n        if (!data) return [];\n\n        const parsed = JSON.parse(data);\n\n        // 检查是否是数组\n        if (!Array.isArray(parsed)) return [];\n\n        // 支持旧格式（字符串数组）和新格式（对象数组）\n        return parsed.map(item => {\n            if (typeof item === 'string') {\n                return { text: item, timestamp: 0 };\n            }\n            return item;\n        }).filter(item => item && item.text);\n    } catch (e) {\n        console.error('获取搜索历史出错:', e);\n        return [];\n    }\n}\n\n// 保存搜索历史的增强版本 - 添加时间戳和最大数量限制，现在缓存2个月\nfunction saveSearchHistory(query) {\n    if (!query || !query.trim()) return;\n\n    // 清理输入，防止XSS\n    query = query.trim().substring(0, 50).replace(/</g, '&lt;').replace(/>/g, '&gt;');\n\n    let history = getSearchHistory();\n\n    // 获取当前时间\n    const now = Date.now();\n\n    // 过滤掉超过2个月的记录（约60天，60*24*60*60*1000 = 5184000000毫秒）\n    history = history.filter(item =>\n        typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000)\n    );\n\n    // 删除已存在的相同项\n    history = history.filter(item =>\n        typeof item === 'object' ? item.text !== query : item !== query\n    );\n\n    // 新项添加到开头，包含时间戳\n    history.unshift({\n        text: query,\n        timestamp: now\n    });\n\n    // 限制历史记录数量\n    if (history.length > MAX_HISTORY_ITEMS) {\n        history = history.slice(0, MAX_HISTORY_ITEMS);\n    }\n\n    try {\n        localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));\n    } catch (e) {\n        console.error('保存搜索历史失败:', e);\n        // 如果存储失败（可能是localStorage已满），尝试清理旧数据\n        try {\n            localStorage.removeItem(SEARCH_HISTORY_KEY);\n            localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3)));\n        } catch (e2) {\n            console.error('再次保存搜索历史失败:', e2);\n        }\n    }\n\n    renderSearchHistory();\n}\n\n// 渲染最近搜索历史的增强版本\nfunction renderSearchHistory() {\n    const historyContainer = document.getElementById('recentSearches');\n    if (!historyContainer) return;\n\n    const history = getSearchHistory();\n\n    if (history.length === 0) {\n        historyContainer.innerHTML = '';\n        return;\n    }\n\n    // 创建一个包含标题和清除按钮的行\n    historyContainer.innerHTML = `\n        <div class=\"flex justify-between items-center w-full mb-2\">\n            <div class=\"text-gray-500\">最近搜索:</div>\n            <button id=\"clearHistoryBtn\" class=\"text-gray-500 hover:text-white transition-colors\"\n                    onclick=\"clearSearchHistory()\" aria-label=\"清除搜索历史\">\n                清除搜索历史\n            </button>\n        </div>\n    `;\n\n    history.forEach(item => {\n        const tag = document.createElement('button');\n        tag.className = 'search-tag flex items-center gap-1';\n        const textSpan = document.createElement('span');\n        textSpan.textContent = item.text;\n        tag.appendChild(textSpan);\n\n        // 添加删除按钮\n        const deleteButton = document.createElement('span');\n        deleteButton.className = 'pl-1 text-gray-500 hover:text-red-500 transition-colors';\n        deleteButton.innerHTML = '<svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path></svg>';\n        deleteButton.onclick = function(e) {\n            // 阻止事件冒泡，避免触发搜索\n            e.stopPropagation();\n            // 删除对应历史记录\n            deleteSingleSearchHistory(item.text);\n            // 重新渲染搜索历史\n            renderSearchHistory();\n        };\n        tag.appendChild(deleteButton);\n\n        // 添加时间提示（如果有时间戳）\n        if (item.timestamp) {\n            const date = new Date(item.timestamp);\n            tag.title = `搜索于: ${date.toLocaleString()}`;\n        }\n\n        tag.onclick = function() {\n            document.getElementById('searchInput').value = item.text;\n            search();\n        };\n        historyContainer.appendChild(tag);\n    });\n}\n\n// 删除单条搜索历史记录\nfunction deleteSingleSearchHistory(query) {\n    // 当url中包含删除的关键词时，页面刷新后会自动加入历史记录，导致误认为删除功能有bug。此问题无需修复，功能无实际影响。\n    try {\n        let history = getSearchHistory();\n        // 过滤掉要删除的记录\n        history = history.filter(item => item.text !== query);\n        console.log('更新后的搜索历史:', history);\n        localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));\n    } catch (e) {\n        console.error('删除单条搜索历史失败:', e);\n        showToast('删除单条搜索历史失败', 'error');\n    }\n}\n\n// 增加清除搜索历史功能\nfunction clearSearchHistory() {\n    // 密码保护校验\n    if (window.isPasswordProtected && window.isPasswordVerified) {\n        if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n            showPasswordModal && showPasswordModal();\n            return;\n        }\n    }\n    try {\n        localStorage.removeItem(SEARCH_HISTORY_KEY);\n        renderSearchHistory();\n        showToast('搜索历史已清除', 'success');\n    } catch (e) {\n        console.error('清除搜索历史失败:', e);\n        showToast('清除搜索历史失败:', 'error');\n    }\n}\n\n// 历史面板相关函数\nfunction toggleHistory(e) {\n    // 密码保护校验\n    if (window.isPasswordProtected && window.isPasswordVerified) {\n        if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n            showPasswordModal && showPasswordModal();\n            return;\n        }\n    }\n    if (e) e.stopPropagation();\n\n    const panel = document.getElementById('historyPanel');\n    if (panel) {\n        panel.classList.toggle('show');\n\n        // 如果打开了历史记录面板，则加载历史数据\n        if (panel.classList.contains('show')) {\n            loadViewingHistory();\n        }\n\n        // 如果设置面板是打开的，则关闭它\n        const settingsPanel = document.getElementById('settingsPanel');\n        if (settingsPanel && settingsPanel.classList.contains('show')) {\n            settingsPanel.classList.remove('show');\n        }\n    }\n}\n\n// 格式化时间戳为友好的日期时间格式\nfunction formatTimestamp(timestamp) {\n    const date = new Date(timestamp);\n    const now = new Date();\n    const diff = now - date;\n\n    // 小于1小时，显示\"X分钟前\"\n    if (diff < 3600000) {\n        const minutes = Math.floor(diff / 60000);\n        return minutes <= 0 ? '刚刚' : `${minutes}分钟前`;\n    }\n\n    // 小于24小时，显示\"X小时前\"\n    if (diff < 86400000) {\n        const hours = Math.floor(diff / 3600000);\n        return `${hours}小时前`;\n    }\n\n    // 小于7天，显示\"X天前\"\n    if (diff < 604800000) {\n        const days = Math.floor(diff / 86400000);\n        return `${days}天前`;\n    }\n\n    // 其他情况，显示完整日期\n    const year = date.getFullYear();\n    const month = (date.getMonth() + 1).toString().padStart(2, '0');\n    const day = date.getDate().toString().padStart(2, '0');\n    const hour = date.getHours().toString().padStart(2, '0');\n    const minute = date.getMinutes().toString().padStart(2, '0');\n\n    return `${year}-${month}-${day} ${hour}:${minute}`;\n}\n\n// 获取观看历史记录\nfunction getViewingHistory() {\n    try {\n        const data = localStorage.getItem('viewingHistory');\n        return data ? JSON.parse(data) : [];\n    } catch (e) {\n        console.error('获取观看历史失败:', e);\n        return [];\n    }\n}\n\n// 加载观看历史并渲染\nfunction loadViewingHistory() {\n    const historyList = document.getElementById('historyList');\n    if (!historyList) return;\n\n    const history = getViewingHistory();\n\n    if (history.length === 0) {\n        historyList.innerHTML = `<div class=\"text-center text-gray-500 py-8\">暂无观看记录</div>`;\n        return;\n    }\n\n    // 渲染历史记录\n    historyList.innerHTML = history.map(item => {\n        // 防止XSS\n        const safeTitle = item.title\n            .replace(/</g, '&lt;')\n            .replace(/>/g, '&gt;')\n            .replace(/\"/g, '&quot;');\n\n        const safeSource = item.sourceName ?\n            item.sourceName.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;') :\n            '未知来源';\n\n        const episodeText = item.episodeIndex !== undefined ?\n            `第${item.episodeIndex + 1}集` : '';\n\n        // 格式化剧集信息\n        let episodeInfoHtml = '';\n        if (item.episodes && Array.isArray(item.episodes) && item.episodes.length > 0) {\n            const totalEpisodes = item.episodes.length;\n            const syncStatus = item.lastSyncTime ?\n                `<span class=\"text-green-400 text-xs\" title=\"剧集列表已同步\">✓</span>` :\n                `<span class=\"text-yellow-400 text-xs\" title=\"使用缓存数据\">⚠</span>`;\n            episodeInfoHtml = `<span class=\"text-xs text-gray-400\">共${totalEpisodes}集 ${syncStatus}</span>`;\n        }\n\n        // 格式化进度信息\n        let progressHtml = '';\n        if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) {\n            const percent = Math.round((item.playbackPosition / item.duration) * 100);\n            const formattedTime = formatPlaybackTime(item.playbackPosition);\n            const formattedDuration = formatPlaybackTime(item.duration);\n\n            progressHtml = `\n                <div class=\"history-progress\">\n                    <div class=\"progress-bar\">\n                        <div class=\"progress-filled\" style=\"width:${percent}%\"></div>\n                    </div>\n                    <div class=\"progress-text\">${formattedTime} / ${formattedDuration}</div>\n                </div>\n            `;\n        }\n\n        // 为防止XSS，使用encodeURIComponent编码URL\n        const safeURL = encodeURIComponent(item.url);\n\n        // 构建历史记录项HTML，添加删除按钮，需要放在position:relative的容器中\n        return `\n            <div class=\"history-item cursor-pointer relative group\" onclick=\"playFromHistory('${item.url}', '${safeTitle}', ${item.episodeIndex || 0}, ${item.playbackPosition || 0})\">\n                <button onclick=\"event.stopPropagation(); deleteHistoryItem('${safeURL}')\"\n                        class=\"absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-gray-400 hover:text-red-400 p-1 rounded-full hover:bg-gray-800 z-10\"\n                        title=\"删除记录\">\n                    <svg class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"></path>\n                    </svg>\n                </button>\n                <div class=\"history-info\">\n                    <div class=\"history-title\">${safeTitle}</div>\n                    <div class=\"history-meta\">\n                        <span class=\"history-episode\">${episodeText}</span>\n                        ${episodeText ? '<span class=\"history-separator mx-1\">·</span>' : ''}\n                        <span class=\"history-source\">${safeSource}</span>\n                        ${episodeInfoHtml ? '<span class=\"history-separator mx-1\">·</span>' : ''}\n                        ${episodeInfoHtml}\n                    </div>\n                    ${progressHtml}\n                    <div class=\"history-time\">${formatTimestamp(item.timestamp)}</div>\n                </div>\n            </div>\n        `;\n    }).join('');\n\n    // 检查是否存在较多历史记录，添加底部边距确保底部按钮不会挡住内容\n    if (history.length > 5) {\n        historyList.classList.add('pb-4');\n    }\n}\n\n// 格式化播放时间为 mm:ss 格式\nfunction formatPlaybackTime(seconds) {\n    if (!seconds || isNaN(seconds)) return '00:00';\n\n    const minutes = Math.floor(seconds / 60);\n    const remainingSeconds = Math.floor(seconds % 60);\n\n    return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;\n}\n\n// 删除单个历史记录项\nfunction deleteHistoryItem(encodedUrl) {\n    try {\n        // 解码URL\n        const url = decodeURIComponent(encodedUrl);\n\n        // 获取当前历史记录\n        const history = getViewingHistory();\n\n        // 过滤掉要删除的项\n        const newHistory = history.filter(item => item.url !== url);\n\n        // 保存回localStorage\n        localStorage.setItem('viewingHistory', JSON.stringify(newHistory));\n\n        // 重新加载历史记录显示\n        loadViewingHistory();\n\n        // 显示成功提示\n        showToast('已删除该记录', 'success');\n    } catch (e) {\n        console.error('删除历史记录项失败:', e);\n        showToast('删除记录失败', 'error');\n    }\n}\n\n// 从历史记录播放\nasync function playFromHistory(url, title, episodeIndex, playbackPosition = 0) {\n    // console.log('[playFromHistory in ui.js] Called with:', { url, title, episodeIndex, playbackPosition }); // Log 1\n    try {\n        let episodesList = [];\n        let historyItem = null; // To store the full history item\n        let syncSuccessful = false;\n\n        // 检查viewingHistory，查找匹配的项\n        const historyRaw = localStorage.getItem('viewingHistory');\n        if (historyRaw) {\n            const history = JSON.parse(historyRaw);\n            historyItem = history.find(item => item.url === url);\n            // console.log('[playFromHistory in ui.js] Found historyItem:', historyItem ? JSON.parse(JSON.stringify(historyItem)) : null); // Log 2 (stringify/parse for deep copy)\n            if (historyItem) {\n                // console.log('[playFromHistory in ui.js] historyItem.vod_id:', historyItem.vod_id, 'historyItem.sourceName:', historyItem.sourceName); // Log 3\n            }\n\n            if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) {\n                episodesList = historyItem.episodes; // Default to stored episodes\n                // console.log(`从历史记录找到视频 \"${title}\" 的集数数据 (默认):`, episodesList.length);\n            }\n        }\n\n        // Always attempt to fetch fresh episode list if we have the necessary info\n        if (historyItem && historyItem.vod_id && historyItem.sourceName) {\n            // Show loading toast to indicate syncing\n            showToast('正在同步最新剧集列表...', 'info');\n\n            // console.log(`[playFromHistory in ui.js] Attempting to fetch details for vod_id: ${historyItem.vod_id}, sourceName: ${historyItem.sourceName}`); // Log 4\n            try {\n                // Construct the API URL for detail fetching\n                // historyItem.sourceName is used as the sourceCode here\n                // Add a cache buster timestamp\n                const timestamp = new Date().getTime();\n                const apiUrl = `/api/detail?id=${encodeURIComponent(historyItem.vod_id)}&source=${encodeURIComponent(historyItem.sourceName)}&_t=${timestamp}`;\n\n                // Add timeout to the fetch request\n                const controller = new AbortController();\n                const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout\n\n                const response = await fetch(apiUrl, {\n                    signal: controller.signal\n                });\n                clearTimeout(timeoutId);\n\n                if (!response.ok) {\n                    throw new Error(`API request failed with status ${response.status}`);\n                }\n                const videoDetails = await response.json();\n\n                if (videoDetails && videoDetails.episodes && videoDetails.episodes.length > 0) {\n                    const oldEpisodeCount = episodesList.length;\n                    episodesList = videoDetails.episodes;\n                    syncSuccessful = true;\n\n                    // Show success message with episode count info\n                    const newEpisodeCount = episodesList.length;\n                    if (newEpisodeCount > oldEpisodeCount) {\n                        showToast(`已同步最新剧集列表 (${newEpisodeCount}集，新增${newEpisodeCount - oldEpisodeCount}集)`, 'success');\n                    } else if (newEpisodeCount === oldEpisodeCount) {\n                        showToast(`剧集列表已是最新 (${newEpisodeCount}集)`, 'success');\n                    } else {\n                        showToast(`已同步最新剧集列表 (${newEpisodeCount}集)`, 'success');\n                    }\n\n                    // console.log(`成功获取 \"${title}\" 最新剧集列表:`, episodesList.length, \"集\");\n                    // Update the history item in localStorage with the fresh episodes\n                    if (historyItem) {\n                        historyItem.episodes = [...episodesList]; // Deep copy\n                        historyItem.lastSyncTime = Date.now(); // Add sync timestamp\n                        const history = JSON.parse(historyRaw); // Re-parse to ensure we have the latest version\n                        const idx = history.findIndex(item => item.url === url);\n                        if (idx !== -1) {\n                            history[idx] = { ...history[idx], ...historyItem }; // Merge, ensuring other properties are kept\n                            localStorage.setItem('viewingHistory', JSON.stringify(history));\n                            // console.log(\"观看历史中的剧集列表已更新。\");\n                        }\n                    }\n                } else {\n                    // console.log(`未能获取 \"${title}\" 的最新剧集列表，或列表为空。将使用已存储的剧集。`);\n                    showToast('未获取到最新剧集信息，使用缓存数据', 'warning');\n                }\n            } catch (fetchError) {\n                // console.error(`获取 \"${title}\" 最新剧集列表失败:`, fetchError, \"将使用已存储的剧集。\");\n                if (fetchError.name === 'AbortError') {\n                    showToast('同步剧集列表超时，使用缓存数据', 'warning');\n                } else {\n                    showToast('同步剧集列表失败，使用缓存数据', 'warning');\n                }\n            }\n        } else if (historyItem) {\n            // console.log(`历史记录项 \"${title}\" 缺少 vod_id 或 sourceName，无法刷新剧集列表。将使用已存储的剧集。`);\n            showToast('无法同步剧集列表，使用缓存数据', 'info');\n        }\n\n\n        // 如果在历史记录中没找到，尝试使用上一个会话的集数数据\n        if (episodesList.length === 0) {\n            try {\n                const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');\n                if (storedEpisodes.length > 0) {\n                    episodesList = storedEpisodes;\n                    // console.log(`使用localStorage中的集数数据:`, episodesList.length);\n                }\n            } catch (e) {\n                // console.error('解析currentEpisodes失败:', e);\n            }\n        }\n\n        // 将剧集列表保存到localStorage，播放器页面会读取它\n        if (episodesList.length > 0) {\n            localStorage.setItem('currentEpisodes', JSON.stringify(episodesList));\n            // console.log(`已将剧集列表保存到localStorage，共 ${episodesList.length} 集`);\n        }\n\n        // 保存当前页面URL作为返回地址\n        let currentPath;\n        if (window.location.pathname.startsWith('/player.html') || window.location.pathname.startsWith('/watch.html')) {\n            currentPath = localStorage.getItem('lastPageUrl') || '/';\n        } else {\n            currentPath = window.location.origin + window.location.pathname + window.location.search;\n        }\n        localStorage.setItem('lastPageUrl', currentPath);\n\n        // 构造播放器URL\n        let playerUrl;\n        const sourceNameForUrl = historyItem ? historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source');\n        const sourceCodeForUrl = historyItem ? historyItem.sourceCode || historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source_code');\n        const idForUrl = historyItem ? historyItem.vod_id : '';\n\n\n        if (url.includes('player.html') || url.includes('watch.html')) {\n            // console.log('检测到嵌套播放链接，解析真实URL');\n            try {\n                const nestedUrl = new URL(url, window.location.origin);\n                const nestedParams = nestedUrl.searchParams;\n                const realVideoUrl = nestedParams.get('url') || url;\n\n                playerUrl = `player.html?url=${encodeURIComponent(realVideoUrl)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`;\n                if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`;\n                if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`;\n                if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`;\n\n\n            } catch (e) {\n                // console.error('解析嵌套URL出错:', e);\n                playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`;\n                if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`;\n                if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`;\n                if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`;\n            }\n        } else {\n             // This case should ideally not happen if 'url' is always a player.html link from history\n            // console.warn(\"Playing from history with a non-player.html URL structure. This might be an issue.\");\n            const playUrl = new URL(url, window.location.origin);\n            if (!playUrl.searchParams.has('index') && episodeIndex > 0) {\n                playUrl.searchParams.set('index', episodeIndex);\n            }\n            playUrl.searchParams.set('position', Math.floor(playbackPosition || 0).toString());\n            playUrl.searchParams.set('returnUrl', encodeURIComponent(currentPath));\n            if (sourceNameForUrl) playUrl.searchParams.set('source', sourceNameForUrl);\n            if (sourceCodeForUrl) playUrl.searchParams.set('source_code', sourceCodeForUrl);\n            if (idForUrl) playUrl.searchParams.set('id', idForUrl);\n            playerUrl = playUrl.toString();\n        }\n\n        showVideoPlayer(playerUrl);\n    } catch (e) {\n        // console.error('从历史记录播放失败:', e);\n        const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`;\n        showVideoPlayer(simpleUrl);\n    }\n}\n\n// 添加观看历史 - 确保每个视频标题只有一条记录\n// IMPORTANT: videoInfo passed to this function should include a 'showIdentifier' property\n// (ideally `${sourceName}_${vod_id}`), 'sourceName', and 'vod_id'.\nfunction addToViewingHistory(videoInfo) {\n    // 密码保护校验\n    if (window.isPasswordProtected && window.isPasswordVerified) {\n        if (window.isPasswordProtected() && !window.isPasswordVerified()) {\n            showPasswordModal && showPasswordModal();\n            return;\n        }\n    }\n    try {\n        const history = getViewingHistory();\n\n        // Ensure videoInfo has a showIdentifier\n        if (!videoInfo.showIdentifier) {\n            if (videoInfo.sourceName && videoInfo.vod_id) {\n                videoInfo.showIdentifier = `${videoInfo.sourceName}_${videoInfo.vod_id}`;\n            } else {\n                // Fallback if critical IDs are missing for the preferred identifier\n                videoInfo.showIdentifier = (videoInfo.episodes && videoInfo.episodes.length > 0) ? videoInfo.episodes[0] : videoInfo.directVideoUrl;\n                // console.warn(`addToViewingHistory: videoInfo for \"${videoInfo.title}\" was missing sourceName or vod_id for preferred showIdentifier. Generated fallback: ${videoInfo.showIdentifier}`);\n            }\n        }\n\n        const existingIndex = history.findIndex(item =>\n            item.title === videoInfo.title &&\n            item.sourceName === videoInfo.sourceName &&\n            item.showIdentifier === videoInfo.showIdentifier // Strict check using the determined showIdentifier\n        );\n\n        if (existingIndex !== -1) {\n            // Exact match with showIdentifier: Update existing series entry\n            const existingItem = history[existingIndex];\n            existingItem.episodeIndex = videoInfo.episodeIndex;\n            existingItem.timestamp = Date.now();\n            existingItem.sourceName = videoInfo.sourceName || existingItem.sourceName;\n            existingItem.sourceCode = videoInfo.sourceCode || existingItem.sourceCode;\n            existingItem.vod_id = videoInfo.vod_id || existingItem.vod_id;\n            existingItem.directVideoUrl = videoInfo.directVideoUrl || existingItem.directVideoUrl;\n            existingItem.url = videoInfo.url || existingItem.url;\n            existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0);\n            existingItem.duration = videoInfo.duration || existingItem.duration;\n\n            if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) {\n                if (!existingItem.episodes ||\n                    !Array.isArray(existingItem.episodes) ||\n                    existingItem.episodes.length !== videoInfo.episodes.length ||\n                    !videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) {\n                    existingItem.episodes = [...videoInfo.episodes];\n                    // console.log(`更新 (addToViewingHistory) \"${videoInfo.title}\" 的剧集数据: ${videoInfo.episodes.length}集`);\n                }\n            }\n\n            history.splice(existingIndex, 1);\n            history.unshift(existingItem);\n            // console.log(`更新历史记录 (addToViewingHistory): \"${videoInfo.title}\", 第 ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'} 集`);\n        } else {\n            // No exact match: Add as a new entry\n            const newItem = {\n                ...videoInfo, // Includes the showIdentifier we ensured is present\n                timestamp: Date.now()\n            };\n\n            if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) {\n                newItem.episodes = [...videoInfo.episodes];\n            } else {\n                newItem.episodes = [];\n            }\n\n            history.unshift(newItem);\n            // console.log(`创建新的历史记录 (addToViewingHistory): \"${videoInfo.title}\", Episode: ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'}`);\n        }\n\n        // 限制历史记录数量为50条\n        const maxHistoryItems = 50;\n        if (history.length > maxHistoryItems) {\n            history.splice(maxHistoryItems);\n        }\n\n        // 保存到本地存储\n        localStorage.setItem('viewingHistory', JSON.stringify(history));\n    } catch (e) {\n        // console.error('保存观看历史失败:', e);\n    }\n}\n\n// 清空观看历史\nfunction clearViewingHistory() {\n    try {\n        localStorage.removeItem('viewingHistory');\n        loadViewingHistory(); // 重新加载空的历史记录\n        showToast('观看历史已清空', 'success');\n    } catch (e) {\n        // console.error('清除观看历史失败:', e);\n        showToast('清除观看历史失败', 'error');\n    }\n}\n\n// 更新toggleSettings函数以处理历史面板互动\nconst originalToggleSettings = toggleSettings;\ntoggleSettings = function(e) {\n    if (e) e.stopPropagation();\n\n    // 原始设置面板切换逻辑\n    originalToggleSettings(e);\n\n    // 如果历史记录面板是打开的，则关闭它\n    const historyPanel = document.getElementById('historyPanel');\n    if (historyPanel && historyPanel.classList.contains('show')) {\n        historyPanel.classList.remove('show');\n    }\n};\n\n// 点击外部关闭历史面板\ndocument.addEventListener('DOMContentLoaded', function() {\n    document.addEventListener('click', function(e) {\n        const historyPanel = document.getElementById('historyPanel');\n        const historyButton = document.querySelector('button[onclick=\"toggleHistory(event)\"]');\n\n        if (historyPanel && historyButton &&\n            !historyPanel.contains(e.target) &&\n            !historyButton.contains(e.target) &&\n            historyPanel.classList.contains('show')) {\n            historyPanel.classList.remove('show');\n        }\n    });\n});\n\n// 清除本地存储缓存并刷新页面\nfunction clearLocalStorage() {\n    // 确保模态框在页面上只有一个实例\n    let modal = document.getElementById('messageBoxModal');\n    if (modal) {\n        document.body.removeChild(modal);\n    }\n\n    // 创建模态框元素\n    modal = document.createElement('div');\n    modal.id = 'messageBoxModal';\n    modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';\n\n    modal.innerHTML = `\n        <div class=\"bg-[#191919] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto relative\">\n            <button id=\"closeBoxModal\" class=\"absolute top-4 right-4 text-gray-400 hover:text-white text-xl\">&times;</button>\n\n            <h3 class=\"text-xl font-bold text-red-500 mb-4\">警告</h3>\n\n            <div class=\"mb-0\">\n                <div class=\"text-sm font-medium text-gray-300\">确定要清除页面缓存吗？</div>\n                <div class=\"text-sm font-medium text-gray-300 mb-4\">此功能会删除你的观看记录、自定义 API 接口和 Cookie，<scan class=\"text-red-500 font-bold\">此操作不可恢复！</scan></div>\n                <div class=\"flex justify-end space-x-2\">\n                    <button id=\"confirmBoxModal\" class=\"ml-2 bg-gray-600 hover:bg-gray-700 text-white px-4 py-1 rounded\">确定</button>\n                    <button id=\"cancelBoxModal\" class=\"ml-2 bg-pink-600 hover:bg-pink-700 text-white px-4 py-1 rounded\">取消</button>\n                </div>\n            </div>\n        </div>`;\n\n    // 添加模态框到页面\n    document.body.appendChild(modal);\n\n    // 添加事件监听器 - 关闭按钮\n    document.getElementById('closeBoxModal').addEventListener('click', function () {\n        document.body.removeChild(modal);\n    });\n\n    // 添加事件监听器 - 确定按钮\n    document.getElementById('confirmBoxModal').addEventListener('click', function () {\n        // 清除所有localStorage数据\n        localStorage.clear();\n\n        // 清除所有cookie\n        const cookies = document.cookie.split(\";\");\n        for (let i = 0; i < cookies.length; i++) {\n            const cookie = cookies[i];\n            const eqPos = cookie.indexOf(\"=\");\n            const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();\n            document.cookie = name + \"=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/\";\n        }\n\n        modal.innerHTML = `\n            <div class=\"bg-[#191919] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto relative\">\n                <button id=\"closeBoxModal\" class=\"absolute top-4 right-4 text-gray-400 hover:text-white text-xl\">&times;</button>\n\n                <h3 class=\"text-xl font-bold text-white mb-4\">提示</h3>\n\n                <div class=\"mb-4\">\n                    <div class=\"text-sm font-medium text-gray-300 mb-4\">页面缓存和Cookie已清除，<span id=\"countdown\">3</span> 秒后自动刷新本页面。</div>\n                </div>\n            </div>`;\n\n        let countdown = 3;\n        const countdownElement = document.getElementById('countdown');\n\n        const countdownInterval = setInterval(() => {\n            countdown--;\n            if (countdown >= 0) {\n                countdownElement.textContent = countdown;\n            } else {\n                clearInterval(countdownInterval);\n                window.location.reload();\n            }\n        }, 1000);\n    });\n\n    // 添加事件监听器 - 取消按钮\n    document.getElementById('cancelBoxModal').addEventListener('click', function () {\n        document.body.removeChild(modal);\n    });\n\n    // 添加事件监听器 - 点击模态框外部关闭\n    modal.addEventListener('click', function (e) {\n        if (e.target === modal) {\n            document.body.removeChild(modal);\n        }\n    });\n}\n\n// 显示配置文件导入页面\nfunction showImportBox(fun) {\n    // 确保模态框在页面上只有一个实例\n    let modal = document.getElementById('showImportBoxModal');\n    if (modal) {\n        document.body.removeChild(modal);\n    }\n\n    // 创建模态框元素\n    modal = document.createElement('div');\n    modal.id = 'showImportBoxModal';\n    modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40';\n\n    modal.innerHTML = `\n        <div class=\"bg-[#191919] rounded-lg p-6 max-w-md w-full max-h-[90vh] overflow-y-auto relative\">\n            <button id=\"closeBoxModal\" class=\"absolute top-4 right-4 text-gray-400 hover:text-white text-xl\">&times;</button>\n\n            <div class=\"m-4\">\n                <div id=\"dropZone\" class=\"w-full py-9 bg-[#111] rounded-2xl border border-gray-300 gap-3 grid border-dashed\">\n                    <div class=\"grid gap-1\">\n                        <svg class=\"mx-auto\" width=\"40\" height=\"40\" viewBox=\"0 0 40 40\" fill=\"none\" xmlns=\"http://www.w3.org/2000/svg\">\n                            <g id=\"File\">\n                                <path id=\"icon\" d=\"M31.6497 10.6056L32.2476 10.0741L31.6497 10.6056ZM28.6559 7.23757L28.058 7.76907L28.058 7.76907L28.6559 7.23757ZM26.5356 5.29253L26.2079 6.02233L26.2079 6.02233L26.5356 5.29253ZM33.1161 12.5827L32.3683 12.867V12.867L33.1161 12.5827ZM31.8692 33.5355L32.4349 34.1012L31.8692 33.5355ZM24.231 11.4836L25.0157 11.3276L24.231 11.4836ZM26.85 14.1026L26.694 14.8872L26.85 14.1026ZM11.667 20.8667C11.2252 20.8667 10.867 21.2248 10.867 21.6667C10.867 22.1085 11.2252 22.4667 11.667 22.4667V20.8667ZM25.0003 22.4667C25.4422 22.4667 25.8003 22.1085 25.8003 21.6667C25.8003 21.2248 25.4422 20.8667 25.0003 20.8667V22.4667ZM11.667 25.8667C11.2252 25.8667 10.867 26.2248 10.867 26.6667C10.867 27.1085 11.2252 27.4667 11.667 27.4667V25.8667ZM20.0003 27.4667C20.4422 27.4667 20.8003 27.1085 20.8003 26.6667C20.8003 26.2248 20.4422 25.8667 20.0003 25.8667V27.4667ZM23.3337 34.2H16.667V35.8H23.3337V34.2ZM7.46699 25V15H5.86699V25H7.46699ZM32.5337 15.0347V25H34.1337V15.0347H32.5337ZM16.667 5.8H23.6732V4.2H16.667V5.8ZM23.6732 5.8C25.2185 5.8 25.7493 5.81639 26.2079 6.02233L26.8633 4.56274C26.0191 4.18361 25.0759 4.2 23.6732 4.2V5.8ZM29.2539 6.70608C28.322 5.65771 27.7076 4.94187 26.8633 4.56274L26.2079 6.02233C26.6665 6.22826 27.0314 6.6141 28.058 7.76907L29.2539 6.70608ZM34.1337 15.0347C34.1337 13.8411 34.1458 13.0399 33.8638 12.2984L32.3683 12.867C32.5216 13.2702 32.5337 13.7221 32.5337 15.0347H34.1337ZM31.0518 11.1371C31.9238 12.1181 32.215 12.4639 32.3683 12.867L33.8638 12.2984C33.5819 11.5569 33.0406 10.9662 32.2476 10.0741L31.0518 11.1371ZM16.667 34.2C14.2874 34.2 12.5831 34.1983 11.2872 34.0241C10.0144 33.8529 9.25596 33.5287 8.69714 32.9698L7.56577 34.1012C8.47142 35.0069 9.62375 35.4148 11.074 35.6098C12.5013 35.8017 14.3326 35.8 16.667 35.8V34.2ZM5.86699 25C5.86699 27.3344 5.86529 29.1657 6.05718 30.593C6.25217 32.0432 6.66012 33.1956 7.56577 34.1012L8.69714 32.9698C8.13833 32.411 7.81405 31.6526 7.64292 30.3798C7.46869 29.0839 7.46699 27.3796 7.46699 25H5.86699ZM23.3337 35.8C25.6681 35.8 27.4993 35.8017 28.9266 35.6098C30.3769 35.4148 31.5292 35.0069 32.4349 34.1012L31.3035 32.9698C30.7447 33.5287 29.9863 33.8529 28.7134 34.0241C27.4175 34.1983 25.7133 34.2 23.3337 34.2V35.8ZM32.5337 25C32.5337 27.3796 32.532 29.0839 32.3577 30.3798C32.1866 31.6526 31.8623 32.411 31.3035 32.9698L32.4349 34.1012C33.3405 33.1956 33.7485 32.0432 33.9435 30.593C34.1354 29.1657 34.1337 27.3344 34.1337 25H32.5337ZM7.46699 15C7.46699 12.6204 7.46869 10.9161 7.64292 9.62024C7.81405 8.34738 8.13833 7.58897 8.69714 7.03015L7.56577 5.89878C6.66012 6.80443 6.25217 7.95676 6.05718 9.40704C5.86529 10.8343 5.86699 12.6656 5.86699 15H7.46699ZM16.667 4.2C14.3326 4.2 12.5013 4.1983 11.074 4.39019C9.62375 4.58518 8.47142 4.99313 7.56577 5.89878L8.69714 7.03015C9.25596 6.47133 10.0144 6.14706 11.2872 5.97592C12.5831 5.8017 14.2874 5.8 16.667 5.8V4.2ZM23.367 5V10H24.967V5H23.367ZM28.3337 14.9667H33.3337V13.3667H28.3337V14.9667ZM23.367 10C23.367 10.7361 23.3631 11.221 23.4464 11.6397L25.0157 11.3276C24.9709 11.1023 24.967 10.8128 24.967 10H23.367ZM28.3337 13.3667C27.5209 13.3667 27.2313 13.3628 27.0061 13.318L26.694 14.8872C27.1127 14.9705 27.5976 14.9667 28.3337 14.9667V13.3667ZM23.4464 11.6397C23.7726 13.2794 25.0543 14.5611 26.694 14.8872L27.0061 13.318C26.0011 13.1181 25.2156 12.3325 25.0157 11.3276L23.4464 11.6397ZM11.667 22.4667H25.0003V20.8667H11.667V22.4667ZM11.667 27.4667H20.0003V25.8667H11.667V27.4667ZM32.2476 10.0741L29.2539 6.70608L28.058 7.76907L31.0518 11.1371L32.2476 10.0741Z\" fill=\"#DB2777\" />\n                            </g>\n                        </svg>\n                    </div>\n                    <div class=\"grid gap-2\">\n                        <h4 class=\"text-center text-white-900 text-sm font-medium leading-snug\">将配置文件拖到此处，或手动选择文件</h4>\n                    <div class=\"flex items-center justify-center gap-2\">\n                        <label>\n                            <input type=\"file\" id=\"ChooseFile\" hidden />\n                            <div class=\"flex w-28 h-9 px-2 flex-col bg-pink-600 rounded-full shadow text-white text-xs font-semibold leading-4 items-center justify-center cursor-pointer focus:outline-none\">选择文件</div>\n                        </label>\n                        <button onclick=\"importConfigFromUrl()\" class=\"flex w-28 h-9 px-2 flex-col bg-blue-600 rounded-full shadow text-white text-xs font-semibold leading-4 items-center justify-center cursor-pointer focus:outline-none\">从URL导入</button>\n                    </div>\n                    </div>\n                </div>\n            </div>\n        </div>`;\n\n    // 添加模态框到页面\n    document.body.appendChild(modal);\n\n    // 添加事件监听器 - 关闭按钮\n    document.getElementById('closeBoxModal').addEventListener('click', function () {\n        document.body.removeChild(modal);\n    });\n\n    // 添加事件监听器 - 点击模态框外部关闭\n    modal.addEventListener('click', function (e) {\n        if (e.target === modal) {\n            document.body.removeChild(modal);\n        }\n    });\n\n    // 添加事件监听器 - 拖拽文件\n    const dropZone = document.getElementById('dropZone');\n    const fileInput = document.getElementById('ChooseFile');\n\n    dropZone.addEventListener('dragover', (e) => {\n        e.preventDefault();\n        dropZone.classList.add('border-blue-500');\n    });\n\n    dropZone.addEventListener('dragleave', () => {\n        dropZone.classList.remove('border-blue-500');\n    });\n\n    dropZone.addEventListener('drop', (e) => {\n        e.preventDefault();\n        fun(e.dataTransfer.files[0]);\n    });\n\n    fileInput.addEventListener('change', (e) => {\n        fun(fileInput.files[0]);\n    });\n}\n"
  },
  {
    "path": "js/version-check.js",
    "content": "// 添加动画样式\n(function() {\n    const style = document.createElement('style');\n    style.textContent = `\n        @keyframes pulse {\n            0%, 100% {\n                opacity: 1;\n            }\n            50% {\n                opacity: 0.6;\n            }\n        }\n        .animate-pulse {\n            animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;\n        }\n    `;\n    document.head.appendChild(style);\n})();\n\n// 获取版本信息\nasync function fetchVersion(url, errorMessage, options = {}) {\n    const response = await fetch(url, options);\n    if (!response.ok) {\n        throw new Error(errorMessage);\n    }\n    return await response.text();\n}\n\n// 版本检查函数\nasync function checkForUpdates() {\n    try {\n        // 获取当前版本\n        const currentVersion = await fetchVersion('/VERSION.txt', '获取当前版本失败', {\n            cache: 'no-store'\n        });\n        \n        // 获取最新版本\n        let latestVersion;\n        const VERSION_URL = {\n            PROXY: 'https://ghfast.top/raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt',\n            DIRECT: 'https://raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt'\n        };\n        const FETCH_TIMEOUT = 1500;\n        \n        try {\n            // 尝试使用代理URL获取最新版本\n            const proxyPromise = fetchVersion(VERSION_URL.PROXY, '代理请求失败');\n            const timeoutPromise = new Promise((_, reject) => \n                setTimeout(() => reject(new Error('代理请求超时')), FETCH_TIMEOUT)\n            );\n            \n            latestVersion = await Promise.race([proxyPromise, timeoutPromise]);\n            console.log('通过代理服务器获取版本成功');\n        } catch (error) {\n            console.log('代理请求失败，尝试直接请求:', error.message);\n            try {\n                // 代理失败后尝试直接获取\n                latestVersion = await fetchVersion(VERSION_URL.DIRECT, '获取最新版本失败');\n                console.log('直接请求获取版本成功');\n            } catch (directError) {\n                console.error('所有版本检查请求均失败:', directError);\n                throw new Error('无法获取最新版本信息');\n            }\n        }\n        \n        console.log('当前版本:', currentVersion);\n        console.log('最新版本:', latestVersion);\n        \n        // 清理版本字符串（移除可能的空格或换行符）\n        const cleanCurrentVersion = currentVersion.trim();\n        const cleanLatestVersion = latestVersion.trim();\n        \n        // 返回版本信息\n        return {\n            current: cleanCurrentVersion,\n            latest: cleanLatestVersion,\n            hasUpdate: parseInt(cleanLatestVersion) > parseInt(cleanCurrentVersion),\n            currentFormatted: formatVersion(cleanCurrentVersion),\n            latestFormatted: formatVersion(cleanLatestVersion)\n        };\n    } catch (error) {\n        console.error('版本检测出错:', error);\n        throw error;\n    }\n}\n\n// 格式化版本号为可读形式 (yyyyMMddhhmm -> yyyy-MM-dd hh:mm)\nfunction formatVersion(versionString) {\n    // 检测版本字符串是否有效\n    if (!versionString) {\n        return '未知版本';\n    }\n    \n    // 清理版本字符串（移除可能的空格或换行符）\n    const cleanedString = versionString.trim();\n    \n    // 格式化标准12位版本号\n    if (cleanedString.length === 12) {\n        const year = cleanedString.substring(0, 4);\n        const month = cleanedString.substring(4, 6);\n        const day = cleanedString.substring(6, 8);\n        const hour = cleanedString.substring(8, 10);\n        const minute = cleanedString.substring(10, 12);\n        \n        return `${year}-${month}-${day} ${hour}:${minute}`;\n    }\n    \n    return cleanedString;\n}\n\n// 创建错误版本信息元素\nfunction createErrorVersionElement(errorMessage) {\n    const errorElement = document.createElement('p');\n    errorElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left';\n    errorElement.innerHTML = `版本: <span class=\"text-amber-500\">检测失败</span>`;\n    errorElement.title = errorMessage;\n    return errorElement;\n}\n\n// 添加版本信息到页脚\nfunction addVersionInfoToFooter() {\n    checkForUpdates().then(result => {\n        if (!result) {\n            // 如果版本检测失败，显示错误信息\n            const versionElement = createErrorVersionElement();\n            // 在页脚显示错误元素\n            displayVersionElement(versionElement);\n            return;\n        }\n        \n        // 创建版本信息元素\n        const versionElement = document.createElement('p');\n        versionElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left';\n        \n        // 添加当前版本信息\n        versionElement.innerHTML = `版本: ${result.currentFormatted}`;\n        \n        // 如果有更新，添加更新提示\n        if (result.hasUpdate) {\n            versionElement.innerHTML += ` <span class=\"inline-flex items-center bg-red-600 text-white text-xs px-2 py-0.5 rounded-md ml-1 cursor-pointer animate-pulse font-medium\">\n                <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3 w-3 mr-1\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M13 10V3L4 14h7v7l9-11h-7z\" />\n                </svg>\n                发现新版\n            </span>`;\n            \n            setTimeout(() => {\n                const updateBtn = versionElement.querySelector('span');\n                if (updateBtn) {\n                    updateBtn.addEventListener('click', () => {\n                        window.open('https://github.com/LibreSpark/LibreTV', '_blank');\n                    });\n                }\n            }, 100);\n        } else {\n            // 如果没有更新，显示当前版本为最新版本\n            versionElement.innerHTML = `版本: ${result.currentFormatted} <span class=\"text-green-500\">(最新版本)</span>`;\n        }\n        \n        // 显示版本元素\n        displayVersionElement(versionElement);\n    }).catch(error => {\n        console.error('版本检测出错:', error);\n        // 创建错误版本信息元素并显示\n        const errorElement = createErrorVersionElement(`错误信息: ${error.message}`);\n        displayVersionElement(errorElement);\n    });\n}\n\n// 在页脚显示版本元素的辅助函数\nfunction displayVersionElement(element) {\n    // 获取页脚元素\n    const footerElement = document.querySelector('.footer p.text-gray-500.text-sm');\n    if (footerElement) {\n        // 在原版权信息后插入版本信息\n        footerElement.insertAdjacentElement('afterend', element);\n    } else {\n        // 如果找不到页脚元素，尝试在页脚区域最后添加\n        const footer = document.querySelector('.footer .container');\n        if (footer) {\n            footer.querySelector('div').appendChild(element);\n        }\n    }\n}\n\n// 页面加载完成后添加版本信息\ndocument.addEventListener('DOMContentLoaded', addVersionInfoToFooter);\n"
  },
  {
    "path": "js/watch.js",
    "content": "// 获取当前URL的参数，并将它们传递给player.html\nwindow.onload = function() {\n    // 获取当前URL的查询参数\n    const currentParams = new URLSearchParams(window.location.search);\n    \n    // 创建player.html的URL对象\n    const playerUrlObj = new URL(\"player.html\", window.location.origin);\n    \n    // 更新状态文本\n    const statusElement = document.getElementById('redirect-status');\n    const manualRedirect = document.getElementById('manual-redirect');\n    let statusMessages = [\n        \"准备视频数据中...\",\n        \"正在加载视频信息...\",\n        \"即将开始播放...\",\n    ];\n    let currentStatus = 0;\n    \n    // 状态文本动画\n    let statusInterval = setInterval(() => {\n        if (currentStatus >= statusMessages.length) {\n            currentStatus = 0;\n        }\n        if (statusElement) {\n            statusElement.textContent = statusMessages[currentStatus];\n            statusElement.style.opacity = 0.7;\n            setTimeout(() => {\n                if (statusElement) statusElement.style.opacity = 1;\n            }, 300);\n        }\n        currentStatus++;\n    }, 1000);\n    \n    // 确保保留所有原始参数\n    currentParams.forEach((value, key) => {\n        playerUrlObj.searchParams.set(key, value);\n    });\n    \n    // 获取来源URL (如果存在)\n    const referrer = document.referrer;\n    \n    // 获取当前URL中的返回URL参数（如果有）\n    const backUrl = currentParams.get('back');\n    \n    // 确定返回URL的优先级：1. 指定的back参数 2. referrer 3. 搜索页面\n    let returnUrl = '';\n    if (backUrl) {\n        // 有显式指定的返回URL\n        returnUrl = decodeURIComponent(backUrl);\n    } else if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) {\n        // 来源是搜索页面\n        returnUrl = referrer;\n    } else if (referrer && referrer.trim() !== '') {\n        // 如果有referrer但不是搜索页，也使用它\n        returnUrl = referrer;\n    } else {\n        // 默认回到首页\n        returnUrl = '/';\n    }\n    \n    // 将返回URL添加到player.html的参数中\n    if (!playerUrlObj.searchParams.has('returnUrl')) {\n        playerUrlObj.searchParams.set('returnUrl', encodeURIComponent(returnUrl));\n    }\n    \n    // 同时保存在localStorage中，作为备用\n    localStorage.setItem('lastPageUrl', returnUrl);\n    \n    // 标记来自搜索页面\n    if (returnUrl.includes('/s=') || returnUrl.includes('?s=')) {\n        localStorage.setItem('cameFromSearch', 'true');\n        localStorage.setItem('searchPageUrl', returnUrl);\n    }\n    \n    // 获取最终的URL字符串\n    const finalPlayerUrl = playerUrlObj.toString();\n    \n    // 更新手动重定向链接\n    if (manualRedirect) {\n        manualRedirect.href = finalPlayerUrl;\n    }\n\n    // 更新meta refresh标签\n    const metaRefresh = document.querySelector('meta[http-equiv=\"refresh\"]');\n    if (metaRefresh) {\n        metaRefresh.content = `3; url=${finalPlayerUrl}`;\n    }\n    \n    // 重定向到播放器页面\n    setTimeout(() => {\n        clearInterval(statusInterval);\n        window.location.href = finalPlayerUrl;\n    }, 2800); // 稍微早于meta refresh的时间，确保我们的JS控制重定向\n};"
  },
  {
    "path": "manifest.json",
    "content": "{\n    \"name\": \"LibreTV\",\n    \"short_name\": \"LibreTV\",\n    \"description\": \"免费在线视频搜索与观看平台\",\n    \"start_url\": \".\",\n    \"display\": \"standalone\",\n    \"background_color\": \"#0f1622\",\n    \"theme_color\": \"#000000\",\n    \"apple-mobile-web-app-capable\": \"yes\",\n    \"apple-mobile-web-app-status-bar-style\": \"black\",\n    \"icons\": [\n      {\n        \"src\": \"image/logo-black.png\",\n        \"sizes\": \"512x512\",\n        \"type\": \"image/png\"\n      }\n    ]\n  }\n"
  },
  {
    "path": "middleware.js",
    "content": "import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现\n\n// Vercel Middleware to inject environment variables\nexport default async function middleware(request) {\n  // Get the URL from the request\n  const url = new URL(request.url);\n  \n  // Only process HTML pages\n  const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/');\n  if (!isHtmlPage) {\n    return; // Let the request pass through unchanged\n  }\n\n  // Fetch the original response\n  const response = await fetch(request);\n  \n  // Check if it's an HTML response\n  const contentType = response.headers.get('content-type') || '';\n  if (!contentType.includes('text/html')) {\n    return response; // Return the original response if not HTML\n  }\n\n  // Get the HTML content\n  const originalHtml = await response.text();\n  \n  // Replace the placeholder with actual environment variable\n  // If PASSWORD is not set, replace with empty string\n  const password = process.env.PASSWORD || '';\n  let passwordHash = '';\n  if (password) {\n    passwordHash = await sha256(password);\n  }\n  \n  // 替换密码占位符\n  let modifiedHtml = originalHtml.replace(\n    'window.__ENV__.PASSWORD = \"{{PASSWORD}}\";',\n    `window.__ENV__.PASSWORD = \"${passwordHash}\"; // SHA-256 hash`\n  );\n\n  // 修复Response构造\n  return new Response(modifiedHtml, {\n    status: response.status,\n    statusText: response.statusText,\n    headers: response.headers\n  });\n}\n\nexport const config = {\n  matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'],\n};"
  },
  {
    "path": "netlify/edge-functions/inject-env.js",
    "content": "// Netlify Edge Function to inject environment variables into HTML\nexport default async (request, context) => {\n  const url = new URL(request.url);\n  \n  // Only process HTML pages\n  const isHtmlPage = url.pathname.endsWith('.html') || url.pathname === '/';\n  if (!isHtmlPage) {\n    return; // Let the request pass through unchanged\n  }\n\n  // Get the original response\n  const response = await context.next();\n  \n  // Check if it's an HTML response\n  const contentType = response.headers.get('content-type') || '';\n  if (!contentType.includes('text/html')) {\n    return response; // Return the original response if not HTML\n  }\n\n  // Get the HTML content\n  const originalHtml = await response.text();\n  \n  // Simple SHA-256 implementation for Netlify Edge Functions\n  async function sha256(message) {\n    const msgUint8 = new TextEncoder().encode(message);\n    const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8);\n    const hashArray = Array.from(new Uint8Array(hashBuffer));\n    return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');\n  }\n  \n  // Replace the placeholder with actual environment variable\n  const password = Netlify.env.get('PASSWORD') || '';\n  let passwordHash = '';\n  if (password) {\n    passwordHash = await sha256(password);\n  }\n  \n  const modifiedHtml = originalHtml.replace(\n    'window.__ENV__.PASSWORD = \"{{PASSWORD}}\";',\n    `window.__ENV__.PASSWORD = \"${passwordHash}\"; // SHA-256 hash`\n  );\n  \n  // Create a new response with the modified HTML\n  return new Response(modifiedHtml, {\n    status: response.status,\n    statusText: response.statusText,\n    headers: response.headers\n  });\n};\n\nexport const config = {\n  path: [\"/*\"]\n};\n"
  },
  {
    "path": "netlify/functions/proxy.mjs",
    "content": "// /netlify/functions/proxy.mjs - Netlify Function (ES Module)\n\nimport fetch from 'node-fetch';\nimport { URL } from 'url'; // Use Node.js built-in URL\nimport crypto from 'crypto'; // 导入 crypto 模块用于密码哈希\n\n// --- Configuration (Read from Environment Variables) ---\nconst DEBUG_ENABLED = process.env.DEBUG === 'true';\nconst CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours\nconst MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels\n\n// --- User Agent Handling ---\nlet USER_AGENTS = [\n    'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',\n    'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15'\n];\ntry {\n    const agentsJsonString = process.env.USER_AGENTS_JSON;\n    if (agentsJsonString) {\n        const parsedAgents = JSON.parse(agentsJsonString);\n        if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {\n            USER_AGENTS = parsedAgents;\n            console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`);\n        } else {\n            console.warn(\"[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default.\");\n        }\n    } else {\n        console.log(\"[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents.\");\n    }\n} catch (e) {\n    console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`);\n}\nconst FILTER_DISCONTINUITY = false; // Ad filtering disabled\n\n// --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) ---\n\nfunction logDebug(message) {\n    if (DEBUG_ENABLED) {\n        console.log(`[Proxy Log Netlify] ${message}`);\n    }\n}\n\nfunction getTargetUrlFromPath(encodedPath) {\n    if (!encodedPath) { logDebug(\"getTargetUrlFromPath received empty path.\"); return null; }\n    try {\n        const decodedUrl = decodeURIComponent(encodedPath);\n        if (decodedUrl.match(/^https?:\\/\\/.+/i)) { return decodedUrl; }\n        else {\n            logDebug(`Invalid decoded URL format: ${decodedUrl}`);\n            if (encodedPath.match(/^https?:\\/\\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; }\n            return null;\n        }\n    } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; }\n}\n\nfunction getBaseUrl(urlStr) {\n    if (!urlStr) return '';\n    try {\n        const parsedUrl = new URL(urlStr);\n        const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);\n        if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; }\n        pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`;\n    } catch (e) {\n        logDebug(`Getting BaseUrl failed for \"${urlStr}\": ${e.message}`);\n        const lastSlashIndex = urlStr.lastIndexOf('/');\n        if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); }\n        return urlStr + '/';\n    }\n}\n\nfunction resolveUrl(baseUrl, relativeUrl) {\n    if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\\/\\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl;\n    try { return new URL(relativeUrl, baseUrl).toString(); }\n    catch (e) {\n        logDebug(`URL resolution failed: base=\"${baseUrl}\", relative=\"${relativeUrl}\". Error: ${e.message}`);\n        if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } }\n        else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; }\n    }\n}\n\n// ** MODIFIED for Netlify redirect **\nfunction rewriteUrlToProxy(targetUrl) {\n    if (!targetUrl || typeof targetUrl !== 'string') return '';\n    // Use the path defined in netlify.toml 'from' field\n    return `/proxy/${encodeURIComponent(targetUrl)}`;\n}\n\nfunction getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; }\n\n/**\n * 验证代理请求的鉴权\n */\nfunction validateAuth(event) {\n    const params = new URLSearchParams(event.queryStringParameters || {});\n    const authHash = params.get('auth');\n    const timestamp = params.get('t');\n    \n    // 获取服务器端密码哈希\n    const serverPassword = process.env.PASSWORD;\n    if (!serverPassword) {\n        console.error('服务器未设置 PASSWORD 环境变量，代理访问被拒绝');\n        return false;\n    }\n    \n    // 使用 crypto 模块计算 SHA-256 哈希\n    const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');\n    \n    if (!authHash || authHash !== serverPasswordHash) {\n        console.warn('代理请求鉴权失败：密码哈希不匹配');\n        return false;\n    }\n    \n    // 验证时间戳（10分钟有效期）\n    if (timestamp) {\n        const now = Date.now();\n        const maxAge = 10 * 60 * 1000; // 10分钟\n        if (now - parseInt(timestamp) > maxAge) {\n            console.warn('代理请求鉴权失败：时间戳过期');\n            return false;\n        }\n    }\n    \n    return true;\n}\n\nasync function fetchContentWithType(targetUrl, requestHeaders) {\n    const headers = {\n        'User-Agent': getRandomUserAgent(),\n        'Accept': requestHeaders['accept'] || '*/*',\n        'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',\n        'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,\n    };\n    Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});\n    logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`);\n    try {\n        const response = await fetch(targetUrl, { headers, redirect: 'follow' });\n        if (!response.ok) {\n            const errorBody = await response.text().catch(() => '');\n            logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`);\n            const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);\n            err.status = response.status; throw err;\n        }\n        const content = await response.text();\n        const contentType = response.headers.get('content-type') || '';\n        logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`);\n        return { content, contentType, responseHeaders: response.headers };\n    } catch (error) {\n        logDebug(`Fetch exception for ${targetUrl}: ${error.message}`);\n        throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`);\n    }\n}\n\nfunction isM3u8Content(content, contentType) {\n    if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; }\n    return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');\n}\n\nfunction processKeyLine(line, baseUrl) { return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`; }); }\nfunction processMapLine(line, baseUrl) { return line.replace(/URI=\"([^\"]+)\"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI=\"${rewriteUrlToProxy(absoluteUri)}\"`; }); }\nfunction processMediaPlaylist(url, content) {\n    const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); }\n    const lines = content.split('\\n'); const output = [];\n    for (let i = 0; i < lines.length; i++) {\n        const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue;\n        if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }\n        if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }\n        if (line.startsWith('#EXTINF')) { output.push(line); continue; }\n        if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; }\n        output.push(line);\n    } return output.join('\\n');\n}\nasync function processM3u8Content(targetUrl, content, recursionDepth = 0) {\n    if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); }\n    logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content);\n}\nasync function processMasterPlaylist(url, content, recursionDepth) {\n    if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); }\n    const baseUrl = getBaseUrl(url); const lines = content.split('\\n'); let highestBandwidth = -1; let bestVariantUrl = '';\n    for (let i = 0; i < lines.length; i++) { if (lines[i].startsWith('#EXT-X-STREAM-INF')) { const bandwidthMatch = lines[i].match(/BANDWIDTH=(\\d+)/); const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0; let variantUriLine = ''; for (let j = i + 1; j < lines.length; j++) { const line = lines[j].trim(); if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; } } if (variantUriLine && currentBandwidth >= highestBandwidth) { highestBandwidth = currentBandwidth; bestVariantUrl = resolveUrl(baseUrl, variantUriLine); } } }\n    if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\\.m3u8($|\\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } }\n    if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); }\n    logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`);\n    const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});\n    if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); }\n    return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);\n}\n\n\n// --- Netlify Handler ---\nexport const handler = async (event, context) => {\n    console.log('--- Netlify Proxy Request ---');\n    console.log('Time:', new Date().toISOString());\n    console.log('Method:', event.httpMethod);\n    console.log('Path:', event.path);\n    // Note: event.queryStringParameters contains query params if any\n    // Note: event.headers contains incoming headers\n\n    // --- CORS Headers (for all responses) ---\n    const corsHeaders = {\n        'Access-Control-Allow-Origin': '*',\n        'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',\n        'Access-Control-Allow-Headers': '*', // Allow all headers client might send\n    };\n\n    // --- Handle OPTIONS Preflight Request ---\n    if (event.httpMethod === 'OPTIONS') {\n        logDebug(\"Handling OPTIONS request\");\n        return {\n            statusCode: 204,\n            headers: {\n                ...corsHeaders,\n                'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours\n            },\n            body: '',\n        };\n    }\n\n    // --- 验证鉴权 ---\n    if (!validateAuth(event)) {\n        console.warn('Netlify 代理请求鉴权失败');\n        return {\n            statusCode: 401,\n            headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n                success: false,\n                error: '代理访问未授权：请检查密码配置或鉴权参数'\n            }),\n        };\n    }\n\n    // --- Extract Target URL ---\n    // Based on netlify.toml rewrite: from = \"/proxy/*\" to = \"/.netlify/functions/proxy/:splat\"\n    // The :splat part should be available in event.path after the base path\n    let encodedUrlPath = '';\n    const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml\n    if (event.path && event.path.startsWith(proxyPrefix)) {\n        encodedUrlPath = event.path.substring(proxyPrefix.length);\n        logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`);\n    } else {\n        logDebug(`Could not extract encoded path from event.path: ${event.path}`);\n        // Potentially handle direct calls too? Less likely needed.\n        // const functionPath = '/.netlify/functions/proxy/';\n        // if (event.path && event.path.startsWith(functionPath)) {\n        //     encodedUrlPath = event.path.substring(functionPath.length);\n        // }\n    }\n\n    const targetUrl = getTargetUrlFromPath(encodedUrlPath);\n    logDebug(`Resolved target URL: ${targetUrl || 'null'}`);\n\n    if (!targetUrl) {\n        logDebug('Error: Invalid proxy request path.');\n        return {\n            statusCode: 400,\n            headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n            body: JSON.stringify({ success: false, error: \"Invalid proxy request path. Could not extract target URL.\" }),\n        };\n    }\n\n    logDebug(`Processing proxy request for target: ${targetUrl}`);\n\n    try {\n        // 验证鉴权\n        const isValidAuth = validateAuth(event);\n        if (!isValidAuth) {\n            return {\n                statusCode: 403,\n                headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n                body: JSON.stringify({ success: false, error: \"Forbidden: Invalid auth credentials.\" }),\n            };\n        }\n\n        // Fetch Original Content (Pass Netlify event headers)\n        const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers);\n\n        // --- Process if M3U8 ---\n        if (isM3u8Content(content, contentType)) {\n            logDebug(`Processing M3U8 content: ${targetUrl}`);\n            const processedM3u8 = await processM3u8Content(targetUrl, content);\n\n            logDebug(`Successfully processed M3U8 for ${targetUrl}`);\n            return {\n                statusCode: 200,\n                headers: {\n                    ...corsHeaders, // Include CORS headers\n                    'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8',\n                    'Cache-Control': `public, max-age=${CACHE_TTL}`,\n                    // Note: Do NOT include content-encoding or content-length from original response\n                    // as node-fetch likely decompressed it and length changed.\n                },\n                body: processedM3u8, // Netlify expects body as string\n            };\n        } else {\n            // --- Return Original Content (Non-M3U8) ---\n            logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`);\n\n            // Prepare headers for Netlify response object\n            const netlifyHeaders = { ...corsHeaders };\n            responseHeaders.forEach((value, key) => {\n                 const lowerKey = key.toLowerCase();\n                 // Exclude problematic headers and CORS headers (already added)\n                 if (!lowerKey.startsWith('access-control-') &&\n                     lowerKey !== 'content-encoding' &&\n                     lowerKey !== 'content-length') {\n                     netlifyHeaders[key] = value; // Add other original headers\n                 }\n             });\n            netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy\n\n            return {\n                statusCode: 200,\n                headers: netlifyHeaders,\n                body: content, // Body as string\n                // isBase64Encoded: false, // Set true only if returning binary data as base64\n            };\n        }\n\n    } catch (error) {\n        logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`);\n        console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack\n\n        const statusCode = error.status || 500; // Get status from error if available\n\n        return {\n            statusCode: statusCode,\n            headers: { ...corsHeaders, 'Content-Type': 'application/json' },\n            body: JSON.stringify({\n                success: false,\n                error: `Proxy processing error: ${error.message}`,\n                targetUrl: targetUrl\n            }),\n        };\n    }\n};\n"
  },
  {
    "path": "netlify.toml",
    "content": "# netlify.toml\n\n[build]\n  # 如果你的项目不需要构建步骤 (纯静态 + functions)，可以省略 publish\n  # publish = \".\" # 假设你的 HTML/CSS/JS 文件在根目录\n  functions = \"netlify/functions\" # 指定 Netlify 函数目录\n\n# 配置 Edge Functions\n[[edge_functions]]\n  function = \"inject-env\"\n  path = \"/*\"\n\n# 配置重写规则，将 /proxy/* 的请求路由到 proxy 函数\n# 这样前端的 PROXY_URL 仍然可以是 '/proxy/'\n[[redirects]]\n  from = \"/proxy/*\"\n  to = \"/.netlify/functions/proxy/:splat\" # 将路径参数传递给函数\n  status = 200 # 重要：这是代理，不是重定向\n\n# 处理搜索路径格式 /s=*\n[[redirects]]\n  from = \"/s=*\"\n  to = \"/index.html\"\n  status = 200\n\n# （可选）为其他静态文件设置缓存头等\n# [[headers]]\n#   for = \"/*\"\n#   [headers.values]\n#     # Add any global headers here\n"
  },
  {
    "path": "nodemon.json",
    "content": "{\n  \"watch\": [\n    \"server.mjs\",\n    \"*.html\",\n    \".env\"\n  ],\n  \"ext\": \"js,mjs,json,html,css\",\n  \"ignore\": [\n    \"node_modules/**/*\",\n    \".git/**/*\"\n  ],\n  \"delay\": \"500\",\n  \"env\": {\n    \"NODE_ENV\": \"development\"\n  },\n  \"execMap\": {\n    \"mjs\": \"node\"\n  },\n  \"verbose\": true,\n  \"restartable\": \"rs\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\r\n  \"name\": \"libretv\",\r\n  \"type\": \"module\",\r\n  \"version\": \"1.1.0\",\r\n  \"private\": true,\r\n  \"description\": \"免费在线视频搜索与观看平台\",\r\n  \"author\": \"bestZwei\",\r\n  \"license\": \"Apache-2.0\",\r\n  \"scripts\": {\r\n    \"dev\": \"nodemon server.mjs\",\r\n    \"start\": \"node server.mjs\"\r\n  },\r\n  \"dependencies\": {\r\n    \"axios\": \"^1.9.0\",\r\n    \"cors\": \"^2.8.5\",\r\n    \"dotenv\": \"^16.5.0\",\r\n    \"express\": \"^5.1.0\",\r\n    \"node-fetch\": \"^3.3.2\"\r\n  },\r\n  \"devDependencies\": {\r\n    \"nodemon\": \"^3.1.10\"\r\n  }\r\n}\r\n"
  },
  {
    "path": "player.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\">\n    <meta name=\"referrer\" content=\"same-origin\">\n    <title>LibreTV 播放器</title>\n\n    <!-- Favicon -->\n    <link rel=\"icon\" href=\"image/logo.png\">\n    <link rel=\"apple-touch-icon\" href=\"image/logo-black.png\">\n    <link rel=\"manifest\" href=\"manifest.json\">\n\n    <script src=\"libs/tailwindcss.min.js\"></script>\n    <script src=\"js/version-check.js\"></script>\n    <link rel=\"stylesheet\" href=\"css/styles.css\">\n    <link rel=\"stylesheet\" href=\"css/player.css\">\n    \n    <meta http-equiv=\"Cache-Control\" content=\"no-store, must-revalidate\">\n    <meta http-equiv=\"Pragma\" content=\"no-cache\">\n    <meta http-equiv=\"Expires\" content=\"0\">\n</head>\n<body>\n    <header class=\"player-header-fixed p-4 flex items-center border-b border-[#333] gap-2\">\n        <div class=\"flex items-center min-w-0\">\n            <button id=\"homeButton\" type=\"button\" class=\"flex items-center min-w-0 cursor-pointer home-button\">\n                <svg class=\"w-8 h-8 mr-2 text-[#00ccff] logo-icon\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                    <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                </svg>\n                <h1 class=\"text-xl font-bold gradient-text logo-text\">LibreTV</h1>\n            </button>\n        </div>\n        <h2 id=\"videoTitle\" class=\"text-xl font-semibold flex-1 text-center overflow-x-auto whitespace-nowrap truncate custom-title-scroll\"></h2>\n        <a href=\"#\" id=\"goBack\" onclick=\"goBack(event)\" class=\"px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors flex items-center min-w-0 home-btn\">\n            <svg class=\"w-5 h-5 mr-1\" viewBox=\"0 0 24 24\" fill=\"#ffffff\" xmlns=\"http://www.w3.org/2000/svg\">\n                <path d=\"M5.25 11h15a1.5 1.5 0 1 1 0 3H5.25a1.5 1.5 0 0 1 0-3z\"/>\n                <path d=\"M5.55 12 11.3 17.3a1.5 1.5 0 1 1-2.12 2.12L3 12l6.18-6.18a1.5 1.5 0 1 1 2.12 2.12L5.55 12z\"/>\n            </svg>\n            <span class=\"home-btn-text\">上一页</span>\n        </a>\n    </header>\n\n    <!-- 密码验证弹窗 -->\n    <div id=\"passwordModal\" class=\"fixed inset-0 bg-black/95 hidden items-center justify-center z-[65] transition-opacity duration-300\">\n        <div class=\"bg-[#111] p-8 rounded-lg w-11/12 max-w-md border border-[#333] max-h-[90vh] flex flex-col\">\n            <div class=\"flex justify-between items-center mb-6 flex-none\">\n                <h2 class=\"text-2xl font-bold gradient-text\">访问验证</h2>\n            </div>\n            <div class=\"mb-6\">\n                <p class=\"text-gray-300 mb-4\">请输入密码继续访问</p>\n                <form id=\"passwordForm\" onsubmit=\"handlePasswordSubmit(); return false;\">\n                    <input type=\"text\" name=\"username\" id=\"username\" autocomplete=\"username\" style=\"display:none\" tabindex=\"-1\" aria-hidden=\"true\">\n                    <input type=\"password\" id=\"passwordInput\" class=\"w-full bg-[#111] border border-[#333] text-white px-4 py-3 rounded-lg focus:outline-none focus:border-white transition-colors\" placeholder=\"密码...\" autocomplete=\"new-password\">\n                    <button id=\"passwordSubmitBtn\" type=\"submit\" class=\"mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded\">提交</button>\n                </form>\n                <p id=\"passwordError\" class=\"text-red-500 mt-2 hidden\">密码错误，请重试</p>\n            </div>\n        </div>\n    </div>\n\n    <main class=\"container mx-auto px-4 py-4\">        <!-- 视频播放区 -->\n        <div id=\"playerContainer\" class=\"player-container mb-4\">\n            <div class=\"relative\">\n                <div id=\"player\" class=\"player-placeholder\">\n                    <!-- Initial loading state before JavaScript loads -->\n                    <div class=\"player-loading-overlay\">\n                        <div class=\"player-loading-spinner\"></div>\n                        <div class=\"player-loading-text\" id=\"loading-title\">正在加载视频...</div>\n                    </div>\n                </div>\n                <div class=\"loading-container\" id=\"player-loading\" style=\"display: none;\">\n                    <div class=\"loading-spinner\"></div>\n                    <div>正在加载视频...</div>\n                </div>\n                <div class=\"error-container\" id=\"error\">\n                    <div class=\"error-icon\">⚠️</div>\n                    <div id=\"error-message\">视频加载失败</div>\n                    <div class=\"error-message-sub\">请尝试其他视频源或稍后重试</div>\n                </div>\n            </div>\n        </div>\n\n        <!-- 源信息 -->\n        <div class=\"player-container flex justify-between py-2 px-4 mb-2 bg-gray-700\" id=\"resourceInfoBarContainer\">\n            <!-- 资源信息卡片、切换按钮、视频数将由JS动态渲染 -->\n        </div>\n\n        <!-- 集数导航 -->\n        <div class=\"player-container\">\n            <div class=\"flex justify-between items-center my-4\">\n                <button onclick=\"playPreviousEpisode()\" id=\"prevButton\" class=\"px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors\">\n                    <svg class=\"w-5 h-5 inline-block\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path>\n                    </svg>\n                    上一集\n                </button>\n                <span class=\"text-gray-400\" id=\"episodeInfo\">加载中...</span>\n                <button onclick=\"playNextEpisode()\" id=\"nextButton\" class=\"px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors\">\n                    下一集\n                    <svg class=\"w-5 h-5 inline-block\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                        <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M9 5l7 7-7 7\"></path>\n                    </svg>\n                </button>\n            </div>\n        </div>\n\n        <!-- 添加自动播放开关和排序按钮 -->\n        <div class=\"player-container mb-2\">\n            <div class=\"flex flex-wrap justify-end items-center gap-2\">\n                <!-- 自动连播开关 - 分组到左边 -->\n                <div class=\"flex items-center gap-1 flex-shrink-0 mr-auto\">\n                    <span class=\"text-gray-400 text-sm whitespace-nowrap\">自动连播</span>\n                    <label class=\"switch\">\n                        <input type=\"checkbox\" id=\"autoplayToggle\">\n                        <span class=\"slider\"></span>\n                    </label>\n                </div>\n                \n                <!-- 把各种功能按钮放在右侧 - 在小屏幕上各自占一行 -->\n                <div class=\"flex flex-wrap justify-end gap-2\">\n                    <!-- 倒序排列按钮 -->\n                    <button onclick=\"toggleEpisodeOrder()\" class=\"px-3 py-1 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors flex items-center space-x-1 flex-shrink-0\">\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" id=\"orderIcon\" viewBox=\"0 0 20 20\" fill=\"currentColor\">\n                            <path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z\" clip-rule=\"evenodd\" />\n                        </svg>\n                        <span id=\"orderText\">倒序排列</span>\n                    </button>\n                    \n                    <!-- 复制链接按钮 -->\n                    <button title=\"复制播放链接\" onclick=\"copyLinks()\" class=\"px-2 py-1 bg-[#222] hover:bg-[#333] border border-[#333] text-white rounded-lg transition\">\n                        <svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" fill=\"none\" viewBox=\"0 0 24 24\" stroke=\"currentColor\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 012-2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3\" />\n                        </svg>\n                    </button>\n                    \n                    <!-- 锁定控制按钮 - 始终显示在最右侧 -->\n                    <button id=\"lockToggle\" onclick=\"toggleControlsLock()\" title=\"锁定控制\" \n                            class=\"px-2 py-1 bg-[#222] hover:bg-[#333] border border-[#333] text-white rounded-lg transition flex-shrink-0\">\n                        <svg id=\"lockIcon\" class=\"w-4 h-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <!-- 默认状态：未锁图标 -->\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\"\n                                d=\"M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z\" />\n                        </svg>\n                    </button>\n                </div>\n            </div>\n        </div>\n\n        <!-- 集数网格 -->\n        <div class=\"player-container\">\n            <div class=\"episode-grid\" id=\"episodesGrid\">\n                <div class=\"grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2\" id=\"episodesList\">\n                    <!-- 集数将在这里动态加载 -->\n                    <div class=\"col-span-full text-center text-gray-400 py-8\">加载中...</div>\n                </div>\n            </div>\n        </div>\n    </main>\n\n    <!-- 添加快捷键提示元素 -->\n    <div class=\"shortcut-hint\" id=\"shortcutHint\">\n        <svg class=\"w-6 h-6\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\" id=\"shortcutIcon\">\n            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 19l-7-7 7-7\"></path>\n        </svg>\n        <span id=\"shortcutText\"></span>\n    </div>\n\n    <!-- 页脚区域 -->\n    <footer class=\"footer mt-2 py-6 border-t border-[#333] bg-[#0a0a0a]\">\n        <div class=\"container mx-auto px-4\">\n            <div class=\"flex flex-col md:flex-row justify-between items-center\">\n                <div class=\"mb-4 md:mb-0\">\n                    <div class=\"flex items-center justify-center md:justify-start\">\n                        <svg class=\"w-6 h-6 mr-2\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                            <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n                        </svg>\n                        <span class=\"gradient-text font-bold\">LibreTV</span>\n                    </div>\n                    <p class=\"text-gray-500 text-sm mt-2 text-center md:text-left\">© 2025 LibreTV - 自由观影，畅享精彩</p>\n                </div>\n                \n                <div class=\"text-center md:text-right\">\n                    <p class=\"text-gray-500 text-sm max-w-md\">\n                        免责声明：本站仅为视频搜索工具，不存储、上传或分发任何视频内容。\n                        所有视频均来自第三方API接口。如有侵权，请联系相关内容提供方。\n                    </p>\n                    <div class=\"mt-2 flex justify-center md:justify-end space-x-4\">\n                        <a href=\"about.html\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">关于我们</a>\n                        <a href=\"about.html\" class=\"text-gray-400 hover:text-white text-sm transition-colors\">隐私政策</a>\n                        <a href=\"https://www.msf.hk/zh-hant/donate/general?type=one-off\" target=\"_blank\" rel=\"noopener\" class=\"text-blue-400 hover:text-blue-300 text-sm transition-colors\">捐赠</a>\n                    </div>\n                </div>\n            </div>\n        </div>\n    </footer>\n\n    <!-- 换源模态框 -->\n    <div id=\"modal\" class=\"fixed inset-0 bg-black/60 hidden flex items-center justify-center transition-opacity duration-300 z-[10000]\">\n        <div class=\"bg-[#111] p-8 rounded-lg w-11/12 max-w-4xl border border-[#333] max-h-[90vh] flex flex-col\">\n            <div class=\"flex justify-between items-center mb-6 flex-none\">\n                <h2 id=\"modalTitle\" class=\"text-2xl font-bold gradient-text break-words pr-4 max-w-[80%]\"></h2>\n                <button onclick=\"closeModal()\" class=\"text-gray-400 hover:text-white text-2xl transition-colors flex-shrink-0\">&times;</button>\n            </div>\n            <div id=\"modalContent\" class=\"overflow-auto flex-1 min-h-0\">\n                <div class=\"grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2\">\n                </div>\n            </div>\n        </div>\n    </div>\n\n    <!-- 添加 loading 提示框 -->\n    <div id=\"loading\" class=\"fixed inset-0 bg-black/80 hidden items-center justify-center z-[10001]\">\n        <div class=\"bg-[#111] p-8 rounded-lg border border-[#333] flex items-center space-x-4\">\n            <div class=\"w-8 h-8 border-4 border-white border-t-transparent rounded-full animate-spin\"></div>\n            <p class=\"text-white text-lg\">加载中...</p>\n        </div>\n    </div>\n\n    <!-- 引入纯 JS sha256（HTTP 下依然可用） -->\n    <script src=\"libs/sha256.min.js\"></script>\n    <script>\n        // 保存原始 js‑sha256 实现，避免被 password.js 覆盖\n        window._jsSha256 = window.sha256;\n    </script>\n    \n    <script src=\"libs/hls.min.js\" crossorigin=\"anonymous\"></script>\n    <script src=\"libs/artplayer.min.js\" crossorigin=\"anonymous\"></script>\n\n    <script src=\"js/config.js\"></script>\n    <script src=\"js/proxy-auth.js\"></script>\n    <script src=\"js/customer_site.js\"></script>\n    <script src=\"js/password.js\"></script>\n    <script src=\"js/ui.js\"></script>\n    <script src=\"js/api.js\"></script>\n    <script src=\"js/search.js\"></script>\n    <script src=\"js/player.js\"></script>\n\n    <script>\n        // 创建全局环境变量对象\n        window.__ENV__ = window.__ENV__ || {};\n        \n        // 注入服务器端环境变量 (将由服务器端替换)\n        // PASSWORD 变量将在这里被服务器端注入\n        window.__ENV__.PASSWORD = \"{{PASSWORD}}\";\n\n        // 修复 home 跳转\n        document.addEventListener('DOMContentLoaded', function() {\n            // 使用事件委托处理首页按钮点击\n            document.body.addEventListener('click', function(event) {\n                const homeButton = event.target.closest('#homeButton');\n                if (homeButton) {\n                    event.preventDefault();\n                    event.stopPropagation();\n\n                    // 如果是在iframe中打开的，尝试关闭iframe\n                    if (window.self !== window.top) {\n                        try {\n                            // 尝试调用父窗口的关闭播放器函数\n                            window.parent.closeVideoPlayer && window.parent.closeVideoPlayer(true);\n                            return;\n                        } catch (e) {\n                            console.error('调用父窗口closeVideoPlayer失败:', e);\n                        }\n                    }\n\n                    // 多重兜底跳转\n                    try {\n                            window.location.href = '/';\n                    } catch (e) {\n                        try {\n                            window.location.replace('/');\n                        } catch (e2) {\n                            window.location.assign('/');\n                    }\n                    }\n                    return false;\n                }\n            });\n        });\n    </script>\n</body>\n</html>\n"
  },
  {
    "path": "render.yaml",
    "content": "services:\n  - type: web\n    name: libretv\n    runtime: node\n    plan: free\n    buildCommand: 'npm install'\n    startCommand: 'node server.mjs'\n    autoDeploy: true "
  },
  {
    "path": "robots.txt",
    "content": "User-agent: *\nDisallow: /"
  },
  {
    "path": "server.mjs",
    "content": "import path from 'path';\nimport express from 'express';\nimport axios from 'axios';\nimport cors from 'cors';\nimport { fileURLToPath } from 'url';\nimport fs from 'fs';\nimport crypto from 'crypto';\nimport dotenv from 'dotenv';\n\ndotenv.config();\n\nconst __filename = fileURLToPath(import.meta.url);\nconst __dirname = path.dirname(__filename);\n\nconst config = {\n  port: process.env.PORT || 8080,\n  password: process.env.PASSWORD || '',\n  corsOrigin: process.env.CORS_ORIGIN || '*',\n  timeout: parseInt(process.env.REQUEST_TIMEOUT || '5000'),\n  maxRetries: parseInt(process.env.MAX_RETRIES || '2'),\n  cacheMaxAge: process.env.CACHE_MAX_AGE || '1d',\n  userAgent: process.env.USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',\n  debug: process.env.DEBUG === 'true'\n};\n\nconst log = (...args) => {\n  if (config.debug) {\n    console.log('[DEBUG]', ...args);\n  }\n};\n\nconst app = express();\n\napp.use(cors({\n  origin: config.corsOrigin,\n  methods: ['GET', 'POST'],\n  allowedHeaders: ['Content-Type', 'Authorization']\n}));\n\napp.use((req, res, next) => {\n  res.setHeader('X-Content-Type-Options', 'nosniff');\n  res.setHeader('X-Frame-Options', 'SAMEORIGIN');\n  res.setHeader('X-XSS-Protection', '1; mode=block');\n  next();\n});\n\nfunction sha256Hash(input) {\n  return new Promise((resolve) => {\n    const hash = crypto.createHash('sha256');\n    hash.update(input);\n    resolve(hash.digest('hex'));\n  });\n}\n\nasync function renderPage(filePath, password) {\n  let content = fs.readFileSync(filePath, 'utf8');\n  if (password !== '') {\n    const sha256 = await sha256Hash(password);\n    content = content.replace('{{PASSWORD}}', sha256);\n  } else {\n    content = content.replace('{{PASSWORD}}', '');\n  }\n  return content;\n}\n\napp.get(['/', '/index.html', '/player.html'], async (req, res) => {\n  try {\n    let filePath;\n    switch (req.path) {\n      case '/player.html':\n        filePath = path.join(__dirname, 'player.html');\n        break;\n      default: // '/' 和 '/index.html'\n        filePath = path.join(__dirname, 'index.html');\n        break;\n    }\n    \n    const content = await renderPage(filePath, config.password);\n    res.send(content);\n  } catch (error) {\n    console.error('页面渲染错误:', error);\n    res.status(500).send('读取静态页面失败');\n  }\n});\n\napp.get('/s=:keyword', async (req, res) => {\n  try {\n    const filePath = path.join(__dirname, 'index.html');\n    const content = await renderPage(filePath, config.password);\n    res.send(content);\n  } catch (error) {\n    console.error('搜索页面渲染错误:', error);\n    res.status(500).send('读取静态页面失败');\n  }\n});\n\nfunction isValidUrl(urlString) {\n  try {\n    const parsed = new URL(urlString);\n    const allowedProtocols = ['http:', 'https:'];\n    \n    // 从环境变量获取阻止的主机名列表\n    const blockedHostnames = (process.env.BLOCKED_HOSTS || 'localhost,127.0.0.1,0.0.0.0,::1').split(',');\n    \n    // 从环境变量获取阻止的 IP 前缀\n    const blockedPrefixes = (process.env.BLOCKED_IP_PREFIXES || '192.168.,10.,172.').split(',');\n    \n    if (!allowedProtocols.includes(parsed.protocol)) return false;\n    if (blockedHostnames.includes(parsed.hostname)) return false;\n    \n    for (const prefix of blockedPrefixes) {\n      if (parsed.hostname.startsWith(prefix)) return false;\n    }\n    \n    return true;\n  } catch {\n    return false;\n  }\n}\n\n// 验证代理请求的鉴权\nfunction validateProxyAuth(req) {\n  const authHash = req.query.auth;\n  const timestamp = req.query.t;\n  \n  // 获取服务器端密码哈希\n  const serverPassword = config.password;\n  if (!serverPassword) {\n    console.error('服务器未设置 PASSWORD 环境变量，代理访问被拒绝');\n    return false;\n  }\n  \n  // 使用 crypto 模块计算 SHA-256 哈希\n  const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex');\n  \n  if (!authHash || authHash !== serverPasswordHash) {\n    console.warn('代理请求鉴权失败：密码哈希不匹配');\n    console.warn(`期望: ${serverPasswordHash}, 收到: ${authHash}`);\n    return false;\n  }\n  \n  // 验证时间戳（10分钟有效期）\n  if (timestamp) {\n    const now = Date.now();\n    const maxAge = 10 * 60 * 1000; // 10分钟\n    if (now - parseInt(timestamp) > maxAge) {\n      console.warn('代理请求鉴权失败：时间戳过期');\n      return false;\n    }\n  }\n  \n  return true;\n}\n\napp.get('/proxy/:encodedUrl', async (req, res) => {\n  try {\n    // 验证鉴权\n    if (!validateProxyAuth(req)) {\n      return res.status(401).json({\n        success: false,\n        error: '代理访问未授权：请检查密码配置或鉴权参数'\n      });\n    }\n\n    const encodedUrl = req.params.encodedUrl;\n    const targetUrl = decodeURIComponent(encodedUrl);\n\n    // 安全验证\n    if (!isValidUrl(targetUrl)) {\n      return res.status(400).send('无效的 URL');\n    }\n\n    log(`代理请求: ${targetUrl}`);\n\n    // 添加请求超时和重试逻辑\n    const maxRetries = config.maxRetries;\n    let retries = 0;\n    \n    const makeRequest = async () => {\n      try {\n        return await axios({\n          method: 'get',\n          url: targetUrl,\n          responseType: 'stream',\n          timeout: config.timeout,\n          headers: {\n            'User-Agent': config.userAgent\n          }\n        });\n      } catch (error) {\n        if (retries < maxRetries) {\n          retries++;\n          log(`重试请求 (${retries}/${maxRetries}): ${targetUrl}`);\n          return makeRequest();\n        }\n        throw error;\n      }\n    };\n\n    const response = await makeRequest();\n\n    // 转发响应头（过滤敏感头）\n    const headers = { ...response.headers };\n    const sensitiveHeaders = (\n      process.env.FILTERED_HEADERS || \n      'content-security-policy,cookie,set-cookie,x-frame-options,access-control-allow-origin'\n    ).split(',');\n    \n    sensitiveHeaders.forEach(header => delete headers[header]);\n    res.set(headers);\n\n    // 管道传输响应流\n    response.data.pipe(res);\n  } catch (error) {\n    console.error('代理请求错误:', error.message);\n    if (error.response) {\n      res.status(error.response.status || 500);\n      error.response.data.pipe(res);\n    } else {\n      res.status(500).send(`请求失败: ${error.message}`);\n    }\n  }\n});\n\napp.use(express.static(path.join(__dirname), {\n  maxAge: config.cacheMaxAge\n}));\n\napp.use((err, req, res, next) => {\n  console.error('服务器错误:', err);\n  res.status(500).send('服务器内部错误');\n});\n\napp.use((req, res) => {\n  res.status(404).send('页面未找到');\n});\n\n// 启动服务器\napp.listen(config.port, () => {\n  console.log(`服务器运行在 http://localhost:${config.port}`);\n  if (config.password !== '') {\n    console.log('用户登录密码已设置');\n  } else {\n    console.log('警告: 未设置 PASSWORD 环境变量，用户将被要求设置密码');\n  }\n  if (config.debug) {\n    console.log('调试模式已启用');\n    console.log('配置:', { ...config, password: config.password ? '******' : '' });\n  }\n});\n"
  },
  {
    "path": "service-worker.js",
    "content": "// 不使用缓存，直接通过网络获取资源\nself.addEventListener('install', event => {\n  self.skipWaiting();\n});\n\nself.addEventListener('activate', event => {\n  event.waitUntil(self.clients.claim());\n});\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"rewrites\": [\n    {\n      \"source\": \"/proxy/:path*\",\n      \"destination\": \"/api/proxy/:path*\"\n    },\n    {\n      \"source\": \"/s=:query\",\n      \"destination\": \"/index.html\"\n    },\n    {\n      \"source\": \"/player.html\",\n      \"destination\": \"/player.html\"\n    },\n    {\n      \"source\": \"/player.html/:path*\",\n      \"destination\": \"/player.html\"\n    },\n    {\n      \"source\": \"/:path*\",\n      \"destination\": \"/:path*\"\n    }\n  ]\n}\n"
  },
  {
    "path": "watch.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\">\n    <meta http-equiv=\"refresh\" content=\"3; url=player.html\">\n    <title>正在跳转到播放器...</title>\n    <link rel=\"manifest\" href=\"manifest.json\">\n    <link rel=\"stylesheet\" href=\"css/watch.css\">\n    <script src=\"js/watch.js\"></script>\n</head>\n<body>\n    <div class=\"redirect-container\">\n        <div class=\"logo-container\">\n            <svg class=\"logo-icon\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\">\n                <path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z\"></path>\n            </svg>\n            <h1 class=\"logo-text\">LibreTV</h1>\n        </div>\n        <div class=\"loading-animation\"></div>\n        <div class=\"redirect-message\">正在加载播放器...</div>\n        <div id=\"redirect-status\">准备视频数据中，请稍候...</div>\n        <p class=\"redirect-hint\">如果页面没有自动跳转，请<a href=\"player.html\" id=\"manual-redirect\">点击这里</a></p>\n    </div>\n</body>\n</html>\n"
  }
]