Repository: LibreSpark/LibreTV Branch: main Commit: 78ee8bbacc64 Files: 55 Total size: 412.3 KB Directory structure: gitextract_t49wjz4n/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── docker-build.yml │ ├── nomore-spam.yml │ ├── sync.yml │ └── version.yml ├── .gitignore ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── README.md ├── VERSION.txt ├── about.html ├── api/ │ └── proxy/ │ └── [...path].mjs ├── css/ │ ├── index.css │ ├── modals.css │ ├── player.css │ ├── styles.css │ └── watch.css ├── docker-compose.yml ├── functions/ │ ├── _middleware.js │ └── proxy/ │ └── [[path]].js ├── image/ │ └── nomedia.psd ├── index.html ├── js/ │ ├── api.js │ ├── app.js │ ├── config.js │ ├── customer_site.js │ ├── douban.js │ ├── index-page.js │ ├── password.js │ ├── player.js │ ├── proxy-auth.js │ ├── pwa-register.js │ ├── search.js │ ├── sha256.js │ ├── ui.js │ ├── version-check.js │ └── watch.js ├── manifest.json ├── middleware.js ├── netlify/ │ ├── edge-functions/ │ │ └── inject-env.js │ └── functions/ │ └── proxy.mjs ├── netlify.toml ├── nodemon.json ├── package.json ├── player.html ├── render.yaml ├── robots.txt ├── server.mjs ├── service-worker.js ├── vercel.json └── watch.html ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .github/ node_modules/ netlify* .gitignore .dockerignore .env* nodemon.json vercel.json Dockerfile* docker-compose*.yml .docker/ ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: BUG Report / BUG 报告 description: "Create a report to help us improve" title: "[BUG]" labels: ["bug"] body: - type: checkboxes id: self_check attributes: label: Self-check / 自查 description: Self-Check before submitting the Issue / 在提交Issue之前的自查 options: - label: I have already cleard my browser cache / 我已经清除了浏览器缓存 required: false - type: checkboxes id: confirm attributes: label: Confirm / 确认 description: Please confirm / 请你确认 options: - label: I have searched the Issue and found no related issues / 我已经搜索过Issue,没有找到相关问题 required: true - label: I am using the latest source from this repository / 我使用的是来自此仓库的最新版代码 required: true - label: I provided information which does not include sensitive information / 我提供的信息里不包含敏感信息 required: true - type: textarea id: description attributes: label: BUG Description / BUG 描述 description: Describe your BUG here / 在此描述你的BUG validations: required: true - type: textarea id: expected_behavior attributes: label: Expected Behavior / 预期行为 description: What you expect to happen / 你认为的预期行为 validations: required: true - type: textarea id: reproduce attributes: label: Reproduction Steps / 复现步骤 description: How to reproduce / 如何复现 validations: required: true - type: textarea id: debug attributes: label: Console Log / 控制台日志 description: F12 -> Console / 从 F12 -> 控制台 复制你觉得可能有帮助的日志 validations: required: false - type: textarea id: additional_context attributes: label: Additional Information / 附加信息 description: Any other information you think might be helpful to solve this BUG / 你觉得对解决此BUG有帮助的其它信息 validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: How To Ask Questions The Smart Way / 提问的智慧 about: Read it before start a new issue url: http://www.catb.org/~esr/faqs/smart-questions.html ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request / 功能请求 description: "Suggest an idea for this project" title: "[Feature Request]" labels: ["enhancement"] body: - type: checkboxes id: confirm attributes: label: Confirm / 确认 description: Please confirm / 请你确认 options: - label: I have searched the Issue and found no related feature requests / 我已经搜索过Issue,没有找到相关的功能请求 required: true - type: textarea id: description attributes: label: Feature Description / 功能描述 description: Describe the feature you want / 描述你想要的功能 validations: required: true - type: textarea id: how_to attributes: label: How to Implement / 如何实现 description: How to implement this feature / 应该如何实现这个功能 validations: required: false - type: textarea id: additional_context attributes: label: Additional Information / 附加信息 description: Any other information you think might be helpful to implement this feature / 你觉得对实现此功能有帮助的其它信息 validations: required: false ================================================ FILE: .github/workflows/docker-build.yml ================================================ name: Build LibreTV image on: workflow_run: workflows: ["Bump version"] types: - completed workflow_dispatch: jobs: build: name: Build LibreTV image runs-on: ubuntu-latest if: (github.event.workflow_run.conclusion == 'success' && github.repository == 'LibreSpark/LibreTV') || (github.repository == 'bestZwei/libretv') steps: - name: Check out repository uses: actions/checkout@v4 - name: Read version from VERSION.txt id: version run: | VERSION=$(cat VERSION.txt) echo "VERSION=$VERSION" >> $GITHUB_OUTPUT - name: Set Docker image tag based on repository id: set-tag run: | if [ "${{ github.repository }}" = "LibreSpark/LibreTV" ]; then echo "IMAGE_NAME=libretv" >> $GITHUB_OUTPUT echo "TAGS=${{ secrets.DOCKER_USERNAME }}/libretv:latest,${{ secrets.DOCKER_USERNAME }}/libretv:${{ steps.version.outputs.VERSION }}" >> $GITHUB_OUTPUT else echo "IMAGE_NAME=libretv-beta" >> $GITHUB_OUTPUT echo "TAGS=${{ secrets.DOCKER_USERNAME }}/libretv-beta:latest" >> $GITHUB_OUTPUT fi - name: Set up Docker QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to DockerHub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Build and push LibreTV image uses: docker/build-push-action@v6.14.0 with: context: . file: Dockerfile push: true tags: ${{ steps.set-tag.outputs.TAGS }} platforms: linux/amd64,linux/arm64/v8,linux/arm/v7 ================================================ FILE: .github/workflows/nomore-spam.yml ================================================ name: NoMore Spam on: issues: types: [opened] pull_request_target: types: [opened] permissions: contents: read issues: write pull-requests: write models: read actions: write jobs: spam-detection: runs-on: ubuntu-latest steps: - name: Detect and close spam uses: JohnsonRan/nomore-spam@main with: github-token: ${{ secrets.GITHUB_TOKEN }} ai-base-url: ${{ secrets.AI_BASE_URL }} ai-api-key: ${{ secrets.AI_API_KEY }} ai-model: 'qwen3-235b-a22b' labels: 'bug,enhancement,question' analyze-file-changes: 'true' max-analysis-depth: 'normal' blacklist: ${{ secrets.BLACKLIST }} # 可选:黑名单用户列表 ================================================ FILE: .github/workflows/sync.yml ================================================ name: Upstream Sync permissions: contents: write on: schedule: - cron: "0 4 * * *" # At 12PM UTC+8 workflow_dispatch: jobs: sync_latest_from_upstream: name: Sync latest commits from upstream repo runs-on: ubuntu-latest if: ${{ github.event.repository.fork }} steps: # Step 1: run a standard checkout action - name: Checkout target repo uses: actions/checkout@v4 # Step 2: run the sync action - name: Sync upstream changes id: sync uses: aormsby/Fork-Sync-With-Upstream-action@v3.4.1 with: upstream_sync_repo: LibreSpark/LibreTV upstream_sync_branch: main target_sync_branch: main target_repo_token: ${{ secrets.GITHUB_TOKEN }} - name: Sync check if: failure() run: | 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." exit 1 ================================================ FILE: .github/workflows/version.yml ================================================ name: Bump version on: push: branches: - main workflow_dispatch: jobs: bump-version: if: github.repository == 'LibreSpark/LibreTV' runs-on: ubuntu-latest env: TZ: 'Asia/Shanghai' steps: - name: Checkout repository uses: actions/checkout@v4 - name: Bump version and commit changes run: | CURRENT_TIME=$(date +"%Y-%m-%d %H:%M") git config user.name 'github-actions[bot]' git config user.email 'github-actions[bot]@users.noreply.github.com' echo $(date +"%Y%m%d%H%M") > VERSION.txt git add VERSION.txt git commit -m "Auto Update $CURRENT_TIME" git push origin main - name: Delete workflow runs uses: Mattraks/delete-workflow-runs@main with: token: ${{ secrets.GITHUB_TOKEN }} repository: ${{ github.repository }} retain_days: 0 keep_minimum_runs: 2 ================================================ FILE: .gitignore ================================================ # Logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules .DS_Store dist dist-ssr coverage *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea *.suo *.ntvs* *.njsproj *.sln *.sw? .env ================================================ FILE: CONTRIBUTING.md ================================================ # 贡献指南 感谢您对 LibreTV 项目的关注!我们欢迎所有形式的贡献,包括但不限于代码提交、问题报告、功能建议、文档改进等。 ## 🚀 快速开始 ### 开发环境要求 - Node.js 16.0 或更高版本 - Git - 支持 ES6 的现代浏览器 ### 本地开发设置 1. **Fork 项目** ```bash # 通过 GitHub 网页 Fork 本项目到您的账户 ``` 2. **克隆仓库** ```bash git clone https://github.com/YOUR_USERNAME/LibreTV.git cd LibreTV ``` 3. **安装依赖** ```bash npm install ``` 4. **配置环境变量** ```bash cp .env.example .env # 根据需要修改 .env 文件中的配置 ``` 5. **启动开发服务器** ```bash npm run dev ``` 6. **访问应用** ``` 打开浏览器访问 http://localhost:8080 ``` ## 🤝 如何贡献 ### 报告问题 如果您发现了 bug 或希望建议新功能: 1. 首先查看 [Issues](https://github.com/LibreSpark/LibreTV/issues) 确保问题尚未被报告 2. 创建新的 Issue,请包含: - 清晰的标题和描述 - 重现步骤(如果是 bug) - 预期行为和实际行为 - 环境信息(浏览器、操作系统等) - 截图或错误日志(如果适用) ### 提交代码 1. **创建分支** ```bash git checkout -b feature/your-feature-name # 或 git checkout -b fix/your-bug-fix ``` 2. **进行开发** - 保持代码风格一致 - 添加必要的注释 - 确保功能正常工作 3. **测试更改** ```bash # 确保应用正常启动 npm run dev # 测试各项功能 # - 视频搜索 # - 视频播放 # - 响应式设计 # - 各种部署方式 ``` 4. **提交更改** ```bash git add . git commit -m "类型: 简洁的提交信息" ``` 5. **推送分支** ```bash git push origin feature/your-feature-name ``` 6. **创建 Pull Request** - 在 GitHub 上创建 Pull Request - 填写详细的 PR 描述 - 等待代码审查 ### 提交信息格式 请使用以下格式提交代码: ``` 类型: 简洁的描述 详细描述(可选) 相关 Issue: #123 ``` **提交类型:** - `feat`: 新功能 - `fix`: 修复 bug - `docs`: 文档更新 - `style`: 代码格式调整 - `refactor`: 代码重构 - `test`: 测试相关 - `chore`: 构建过程或辅助工具的变动 **示例:** ``` feat: 添加自定义播放器控制栏 - 增加播放速度调节功能 - 优化进度条拖拽体验 - 添加音量记忆功能 相关 Issue: #45 ``` ## 📋 代码规范 ### JavaScript 规范 - 使用 ES6+ 语法 - 优先使用 `const`,需要重新赋值时使用 `let` - 使用有意义的变量和函数名 - 函数名使用驼峰命名 - 常量使用大写字母和下划线 ```javascript // ✅ 推荐 const API_BASE_URL = 'https://api.example.com'; const searchVideos = async (keyword) => { // 函数实现 }; // ❌ 不推荐 var url = 'https://api.example.com'; function search(k) { // 函数实现 } ``` ### CSS 规范 - 使用 BEM 命名方式或语义化类名 - 优先使用 CSS 变量 - 移动端优先的响应式设计 - 避免使用 `!important` ```css /* ✅ 推荐 */ .video-player { --primary-color: #00ccff; background-color: var(--primary-color); } .video-player__controls { display: flex; gap: 1rem; } /* ❌ 不推荐 */ .player { background-color: #00ccff !important; } ``` ### HTML 规范 - 使用语义化标签 - 确保可访问性(添加适当的 aria 属性) - 保持良好的缩进格式 ```html
``` ## 🎯 贡献重点领域 我们特别欢迎以下方面的贡献: ### 核心功能 - **搜索优化**: 改进搜索算法和用户体验 - **播放器增强**: 新的播放器功能和控制选项 - **API 集成**: 添加新的视频源 API 支持 - **性能优化**: 加载速度和播放性能改进 ### 用户体验 - **界面设计**: UI/UX 改进和现代化 - **响应式设计**: 移动端体验优化 - **无障碍功能**: 提高可访问性 - **国际化**: 多语言支持 ### 技术架构 - **代码重构**: 提高代码质量和可维护性 - **安全性**: 安全漏洞修复和防护 - **部署优化**: 改进各平台部署流程 - **监控日志**: 添加错误监控和日志系统 ### 文档和社区 - **文档完善**: API 文档、部署指南等 - **示例项目**: 集成示例和最佳实践 - **社区建设**: 问题回答和新手指导 ## 🔍 代码审查流程 1. **自动检查**: PR 会触发自动化测试 2. **代码审查**: 维护者会审查代码质量和功能 3. **反馈修改**: 根据审查意见修改代码 4. **合并**: 审查通过后合并到主分支 ### 审查标准 - **功能完整**: 功能按预期工作 - **代码质量**: 遵循项目编码规范 - **性能影响**: 不显著影响应用性能 - **兼容性**: 与现有功能兼容 - **文档更新**: 必要时更新相关文档 ## 🚫 注意事项 ### 不接受的贡献 - **侵权内容**: 包含版权争议的代码或资源 - **恶意代码**: 包含病毒、后门或其他恶意功能 - **商业推广**: 纯粹的商业宣传或广告 - **不相关功能**: 与项目核心功能无关的特性 ### 法律要求 - 确保您的贡献不侵犯他人版权 - 提交的代码必须是您原创或有合法使用权 - 同意以项目相同的 MIT 许可证分发您的贡献 ## 📞 联系方式 如果您有任何问题或需要帮助: - **GitHub Issues**: [报告问题或建议](https://github.com/LibreSpark/LibreTV/issues) - **GitHub Discussions**: [参与社区讨论](https://github.com/LibreSpark/LibreTV/discussions) - **Email**: 通过 GitHub 联系项目维护者 ## 🙏 致谢 感谢所有为 LibreTV 项目做出贡献的开发者!您的每一份贡献都让这个项目变得更好。 ### 贡献者列表 我们会在项目 README 中展示所有贡献者。您的贡献被合并后,您的 GitHub 头像将出现在贡献者列表中。 --- **再次感谢您的贡献!** 🎉 让我们一起构建一个更好的 LibreTV! ================================================ FILE: Dockerfile ================================================ FROM node:lts-alpine LABEL maintainer="LibreTV Team" LABEL description="LibreTV - 免费在线视频搜索与观看平台" # 设置环境变量 ENV PORT=8080 ENV CORS_ORIGIN=* ENV DEBUG=false ENV REQUEST_TIMEOUT=5000 ENV MAX_RETRIES=2 ENV CACHE_MAX_AGE=1d # 设置工作目录 WORKDIR /app # 复制 package.json 和 package-lock.json(如果存在) COPY package*.json ./ # 安装依赖 RUN npm ci --only=production && npm cache clean --force # 复制应用文件 COPY . . # 暴露端口 EXPOSE 8080 # 健康检查 HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ CMD node -e "require('http').get('http://localhost:8080', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))" # 启动应用 CMD ["npm", "start"] ================================================ FILE: LICENSE ================================================ Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright 2025 LibreTV Team Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ================================================ FILE: README.md ================================================ # LibreTV - 免费在线视频搜索与观看平台
LibreTV Logo

自由观影,畅享精彩

## 📺 项目简介 LibreTV 是一个轻量级、免费的在线视频搜索与观看平台,提供来自多个视频源的内容搜索与播放服务。无需注册,即开即用,支持多种设备访问。项目结合了前端技术和后端代理功能,可部署在支持服务端功能的各类网站托管服务上。**项目门户**: [libretv.is-an.org](https://libretv.is-an.org) 本项目基于 [bestK/tv](https://github.com/bestK/tv) 进行重构与增强。
点击查看项目截图 项目截图
## 🚀 快速部署 选择以下任一平台,点击一键部署按钮,即可快速创建自己的 LibreTV 实例: [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FLibreSpark%2FLibreTV) [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/LibreSpark/LibreTV) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/LibreSpark/LibreTV) ## 🚨 重要声明 - 本项目仅供学习和个人使用,为避免版权纠纷,必须设置PASSWORD环境变量 - 请勿将部署的实例用于商业用途或公开服务 - 如因公开分享导致的任何法律问题,用户需自行承担责任 - 项目开发者不对用户的使用行为承担任何法律责任 ## ⚠️ 同步与升级 Pull Bot 会反复触发无效的 PR 和垃圾邮件,严重干扰项目维护。作者可能会直接拉黑所有 Pull Bot 自动发起的同步请求的仓库所有者。 **推荐做法:** 建议在 fork 的仓库中启用本仓库自带的 GitHub Actions 自动同步功能(见 `.github/workflows/sync.yml`)。 如需手动同步主仓库更新,也可以使用 GitHub 官方的 [Sync fork](https://docs.github.com/cn/github/collaborating-with-issues-and-pull-requests/syncing-a-fork) 功能。 对于更新后可能会出现的错误和异常,在设置中备份配置后,首先清除页面Cookie,然后 Ctrl + F5 刷新页面。再次访问网页检查是否解决问题。 ## 📋 详细部署指南 ### Cloudflare Pages 1. Fork 或克隆本仓库到您的 GitHub 账户 2. 登录 [Cloudflare Dashboard](https://dash.cloudflare.com/),进入 Pages 服务 3. 点击"创建项目",连接您的 GitHub 仓库 4. 使用以下设置: - 构建命令:留空(无需构建) - 输出目录:留空(默认为根目录) 5. **⚠️ 重要:在"设置" > "环境变量"中添加 `PASSWORD` 变量(必须设置)** 6. 点击"保存并部署" ### Vercel 1. Fork 或克隆本仓库到您的 GitHub/GitLab 账户 2. 登录 [Vercel](https://vercel.com/),点击"New Project" 3. 导入您的仓库,使用默认设置 4. **⚠️ 重要:在"Settings" > "Environment Variables"中添加 `PASSWORD` 变量(必须设置)** 5. 点击"Deploy" ### Docker ``` docker run -d \ --name libretv \ --restart unless-stopped \ -p 8899:8080 \ -e PASSWORD=your_password \ bestzwei/libretv:latest ``` ### Docker Compose `docker-compose.yml` 文件: ```yaml services: libretv: image: bestzwei/libretv:latest container_name: libretv ports: - "8899:8080" # 将内部 8080 端口映射到主机的 8899 端口 environment: - PASSWORD=${PASSWORD:-111111} # 可将 111111 修改为你想要的密码,默认为 your_password restart: unless-stopped ``` 启动 LibreTV: ```bash docker compose up -d ``` 访问 `http://localhost:8899` 即可使用。 ### 本地开发环境 项目包含后端代理功能,需要支持服务器端功能的环境: ```bash # 首先,通过复制示例来设置 .env 文件(可选) cp .env.example .env # 安装依赖 npm install # 启动开发服务器 npm run dev ``` 访问 `http://localhost:8080` 即可使用(端口可在.env文件中通过PORT变量修改)。 > ⚠️ 注意:使用简单静态服务器(如 `python -m http.server` 或 `npx http-server`)时,视频代理功能将不可用,视频无法正常播放。完整功能测试请使用 Node.js 开发服务器。 ## 🔧 自定义配置 ### 密码保护 **重要提示**: 为确保安全,所有部署都必须设置 PASSWORD 环境变量,否则用户将看到设置密码的提示。 ### API兼容性 LibreTV 支持标准的苹果 CMS V10 API 格式。添加自定义 API 时需遵循以下格式: - 搜索接口: `https://example.com/api.php/provide/vod/?ac=videolist&wd=关键词` - 详情接口: `https://example.com/api.php/provide/vod/?ac=detail&ids=视频ID` **添加 CMS 源**: 1. 在设置面板中选择"自定义接口" 2. 接口地址: `https://example.com/api.php/provide/vod` ## ⌨️ 键盘快捷键 播放器支持以下键盘快捷键: - **空格键**: 播放/暂停 - **左右箭头**: 快退/快进 - **上下箭头**: 音量增加/减小 - **M 键**: 静音/取消静音 - **F 键**: 全屏/退出全屏 - **Esc 键**: 退出全屏 ## 🛠️ 技术栈 - HTML5 + CSS3 + JavaScript (ES6+) - Tailwind CSS - HLS.js 用于 HLS 流处理 - DPlayer 视频播放器核心 - Cloudflare/Vercel/Netlify Serverless Functions - 服务端 HLS 代理和处理技术 - localStorage 本地存储 ## ⚠️ 免责声明 LibreTV 仅作为视频搜索工具,不存储、上传或分发任何视频内容。所有视频均来自第三方 API 接口提供的搜索结果。如有侵权内容,请联系相应的内容提供方。 本项目开发者不对使用本项目产生的任何后果负责。使用本项目时,您必须遵守当地的法律法规。 ## 🤝 衍生项目 它们提供了更多丰富的自定义功能,欢迎体验~ - **[MoonTV](https://github.com/senshinya/MoonTV)** - **[OrionTV](https://github.com/zimplexing/OrionTV)** ## 🥇 感谢支持 - **[Sharon](https://sharon.io)** - **[ZMTO](https://zmto.com)** - **[YXVM](https://yxvm.com)** ================================================ FILE: VERSION.txt ================================================ 202508060117 ================================================ FILE: about.html ================================================ 关于我们 - LibreTV

关于LibreTV

LibreTV Logo

LibreTV 是一个免费的在线视频搜索平台,提供视频搜索和播放服务,致力于为用户带来最佳体验。

本项目代码托管在 GitHub 上,欢迎访问我们的仓库:

访问 GitHub 仓库

隐私政策

数据保护

我们尊重并保护您的隐私。LibreTV 不收集任何个人数据,且不会限制访问或使用本网站。

服务说明

本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供,我们不会存储或追踪用户信息。

版权声明与投诉机制

免责声明

LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。

投诉反馈

若您是版权方或相关权利人,发现本站搜索结果中存在侵犯您合法权益的内容,请通过以下渠道向我们反馈:

投诉邮箱: troll@pissmail.com

请在投诉邮件中提供:您的身份证明、权利证明、侵权内容的具体链接及相关说明。我们将在收到投诉后尽快处理,对于确认侵权的内容,将立即断开相关链接,停止展示侵权内容,并将处理结果反馈给您。

================================================ FILE: api/proxy/[...path].mjs ================================================ // /api/proxy/[...path].mjs - Vercel Serverless Function (ES Module) import fetch from 'node-fetch'; import { URL } from 'url'; // 使用 Node.js 内置 URL 处理 import crypto from 'crypto'; // 导入 crypto 模块用于密码哈希 // --- 配置 (从环境变量读取) --- const DEBUG_ENABLED = process.env.DEBUG === 'true'; const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // 默认 24 小时 const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // 默认 5 层 // --- User Agent 处理 --- // 默认 User Agent 列表 let USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', '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' ]; // 尝试从环境变量读取并解析 USER_AGENTS_JSON try { const agentsJsonString = process.env.USER_AGENTS_JSON; if (agentsJsonString) { const parsedAgents = JSON.parse(agentsJsonString); // 检查解析结果是否为非空数组 if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { USER_AGENTS = parsedAgents; // 使用环境变量中的数组 console.log(`[代理日志] 已从环境变量加载 ${USER_AGENTS.length} 个 User Agent。`); } else { console.warn("[代理日志] 环境变量 USER_AGENTS_JSON 不是有效的非空数组,使用默认值。"); } } else { console.log("[代理日志] 未设置环境变量 USER_AGENTS_JSON,使用默认 User Agent。"); } } catch (e) { // 如果 JSON 解析失败,记录错误并使用默认值 console.error(`[代理日志] 解析环境变量 USER_AGENTS_JSON 出错: ${e.message}。使用默认 User Agent。`); } // 广告过滤在代理中禁用,由播放器处理 const FILTER_DISCONTINUITY = false; // --- 辅助函数 --- function logDebug(message) { if (DEBUG_ENABLED) { console.log(`[代理日志] ${message}`); } } /** * 从代理请求路径中提取编码后的目标 URL。 * @param {string} encodedPath - URL 编码后的路径部分 (例如 "https%3A%2F%2F...") * @returns {string|null} 解码后的目标 URL,如果无效则返回 null。 */ function getTargetUrlFromPath(encodedPath) { if (!encodedPath) { logDebug("getTargetUrlFromPath 收到空路径。"); return null; } try { const decodedUrl = decodeURIComponent(encodedPath); // 基础检查,看是否像一个 HTTP/HTTPS URL if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; } else { logDebug(`无效的解码 URL 格式: ${decodedUrl}`); // 备选检查:原始路径是否未编码但看起来像 URL? if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`警告: 路径未编码但看起来像 URL: ${encodedPath}`); return encodedPath; } return null; } } catch (e) { // 捕获解码错误 (例如格式错误的 URI) logDebug(`解码目标 URL 出错: ${encodedPath} - ${e.message}`); return null; } } function getBaseUrl(urlStr) { if (!urlStr) return ''; try { const parsedUrl = new URL(urlStr); // 处理根目录或只有文件名的情况 const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); // 移除空字符串 if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; } pathSegments.pop(); // 移除最后一段 return `${parsedUrl.origin}/${pathSegments.join('/')}/`; } catch (e) { logDebug(`获取 BaseUrl 失败: "${urlStr}": ${e.message}`); // 备用方法:查找最后一个斜杠 const lastSlashIndex = urlStr.lastIndexOf('/'); if (lastSlashIndex > urlStr.indexOf('://') + 2) { // 确保不是协议部分的斜杠 return urlStr.substring(0, lastSlashIndex + 1); } return urlStr + '/'; // 如果没有路径,添加斜杠 } } function resolveUrl(baseUrl, relativeUrl) { if (!relativeUrl) return ''; // 处理空的 relativeUrl if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; // 已经是绝对 URL } if (!baseUrl) return relativeUrl; // 没有基础 URL 无法解析 try { // 使用 Node.js 的 URL 构造函数处理相对路径 return new URL(relativeUrl, baseUrl).toString(); } catch (e) { logDebug(`URL 解析失败: base="${baseUrl}", relative="${relativeUrl}". 错误: ${e.message}`); // 简单的备用逻辑 if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } // 如果 baseUrl 也无效,返回原始相对路径 } else { // 假设相对于包含基础 URL 资源的目录 return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; } } } // ** 已修正:确保生成 /proxy/ 前缀的链接 ** function rewriteUrlToProxy(targetUrl) { if (!targetUrl || typeof targetUrl !== 'string') return ''; // 返回与 vercel.json 的 "source" 和前端 PROXY_URL 一致的路径 return `/proxy/${encodeURIComponent(targetUrl)}`; } function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } async function fetchContentWithType(targetUrl, requestHeaders) { // 准备请求头 const headers = { 'User-Agent': getRandomUserAgent(), 'Accept': requestHeaders['accept'] || '*/*', // 传递原始 Accept 头(如果有) 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8', // 尝试设置一个合理的 Referer 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin, }; // 清理空值的头 Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {}); logDebug(`准备请求目标: ${targetUrl},请求头: ${JSON.stringify(headers)}`); try { // 发起 fetch 请求 const response = await fetch(targetUrl, { headers, redirect: 'follow' }); // 检查响应是否成功 if (!response.ok) { const errorBody = await response.text().catch(() => ''); // 尝试获取错误响应体 logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`); // 创建一个包含状态码的错误对象 const err = new Error(`HTTP 错误 ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`); err.status = response.status; // 将状态码附加到错误对象 throw err; // 抛出错误 } // 读取响应内容 const content = await response.text(); const contentType = response.headers.get('content-type') || ''; logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`); // 返回结果 return { content, contentType, responseHeaders: response.headers }; } catch (error) { // 捕获 fetch 本身的错误(网络、超时等)或上面抛出的 HTTP 错误 logDebug(`请求异常 ${targetUrl}: ${error.message}`); // 重新抛出,确保包含原始错误信息 throw new Error(`请求目标 URL 失败 ${targetUrl}: ${error.message}`); } } function isM3u8Content(content, contentType) { if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; } return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); } function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); } function processMediaPlaylist(url, content) { const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`无法确定媒体列表的 Base URL: ${url},相对路径可能无法处理。`); } const lines = content.split('\n'); const output = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // 保留最后一个空行 if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue; // 跳过中间空行 // 广告过滤已禁用 if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; } if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; } if (line.startsWith('#EXTINF')) { output.push(line); continue; } // 处理 URL 行 if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`重写媒体片段: 原始='${line}', 解析后='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; } // 保留其他 M3U8 标签 output.push(line); } return output.join('\n'); } async function processM3u8Content(targetUrl, content, recursionDepth = 0) { // 判断是主列表还是媒体列表 if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`检测到主播放列表: ${targetUrl} (深度: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); } logDebug(`检测到媒体播放列表: ${targetUrl} (深度: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content); } async function processMasterPlaylist(url, content, recursionDepth) { // 检查递归深度 if (recursionDepth > MAX_RECURSION) { throw new Error(`处理主播放列表时,递归深度超过最大限制 (${MAX_RECURSION}): ${url}`); } const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = ''; // 查找最高带宽的流 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 = ''; // 找到下一行的 URI 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); } } } // 如果没有找到带宽信息,尝试查找第一个 .m3u8 链接 if (!bestVariantUrl) { logDebug(`主播放列表中未找到 BANDWIDTH 信息,尝试查找第一个 URI: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // 更可靠地匹配 .m3u8 链接 if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`备选方案: 找到第一个子播放列表 URI: ${bestVariantUrl}`); break; } } } // 如果仍然没有找到子列表 URL if (!bestVariantUrl) { logDebug(`在主播放列表 ${url} 中未找到有效的子列表 URI,将其作为媒体列表处理。`); return processMediaPlaylist(url, content); } logDebug(`选择的子播放列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`); // 请求选定的子播放列表内容 (注意:这里传递 {} 作为请求头,不传递客户端的原始请求头) const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {}); // 检查获取的内容是否是 M3U8 if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`获取的子播放列表 ${bestVariantUrl} 不是 M3U8 (类型: ${variantContentType}),将其作为媒体列表处理。`); return processMediaPlaylist(bestVariantUrl, variantContent); } // 递归处理获取到的子 M3U8 内容 return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1); } /** * 验证代理请求的鉴权 */ async function validateAuth(req) { const authHash = req.query.auth; const timestamp = req.query.t; // 获取服务器端密码哈希 const serverPassword = process.env.PASSWORD; if (!serverPassword) { console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝'); return false; } // 使用 crypto 模块计算 SHA-256 哈希 const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex'); if (!authHash || authHash !== serverPasswordHash) { console.warn('代理请求鉴权失败:密码哈希不匹配'); return false; } // 验证时间戳(10分钟有效期) if (timestamp) { const now = Date.now(); const maxAge = 10 * 60 * 1000; // 10分钟 if (now - parseInt(timestamp) > maxAge) { console.warn('代理请求鉴权失败:时间戳过期'); return false; } } return true; } // --- Vercel Handler 函数 --- export default async function handler(req, res) { // --- 记录请求开始 --- console.info('--- Vercel 代理请求开始 ---'); console.info('时间:', new Date().toISOString()); console.info('方法:', req.method); console.info('URL:', req.url); // 原始请求 URL (例如 /proxy/...) console.info('查询参数:', JSON.stringify(req.query)); // Vercel 解析的查询参数 // --- 提前设置 CORS 头 --- res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', '*'); // 允许所有请求头 // --- 处理 OPTIONS 预检请求 --- if (req.method === 'OPTIONS') { console.info("处理 OPTIONS 预检请求"); res.status(204).setHeader('Access-Control-Max-Age', '86400').end(); // 缓存预检结果 24 小时 return; } let targetUrl = null; // 初始化目标 URL try { // ---- 开始主处理逻辑的 try 块 ---- // --- 验证鉴权 --- const isAuthorized = await validateAuth(req); if (!isAuthorized) { console.warn('代理请求鉴权失败'); res.status(401).json({ success: false, error: '代理访问未授权:请检查密码配置或鉴权参数' }); return; } // --- 提取目标 URL (主要依赖 req.query["...path"]) --- // Vercel 将 :path* 捕获的内容(可能包含斜杠)放入 req.query["...path"] 数组 const pathData = req.query["...path"]; // 使用正确的键名 let encodedUrlPath = ''; if (pathData) { if (Array.isArray(pathData)) { encodedUrlPath = pathData.join('/'); // 重新组合 console.info(`从 req.query["...path"] (数组) 组合的编码路径: ${encodedUrlPath}`); } else if (typeof pathData === 'string') { encodedUrlPath = pathData; // 也处理 Vercel 可能只返回字符串的情况 console.info(`从 req.query["...path"] (字符串) 获取的编码路径: ${encodedUrlPath}`); } else { console.warn(`[代理警告] req.query["...path"] 类型未知: ${typeof pathData}`); } } else { console.warn(`[代理警告] req.query["...path"] 为空或未定义。`); // 备选:尝试从 req.url 提取(如果需要) if (req.url && req.url.startsWith('/proxy/')) { encodedUrlPath = req.url.substring('/proxy/'.length); console.info(`使用备选方法从 req.url 提取的编码路径: ${encodedUrlPath}`); } } // 如果仍然为空,则无法继续 if (!encodedUrlPath) { throw new Error("无法从请求中确定编码后的目标路径。"); } // 解析目标 URL targetUrl = getTargetUrlFromPath(encodedUrlPath); console.info(`解析出的目标 URL: ${targetUrl || 'null'}`); // 记录解析结果 // 检查目标 URL 是否有效 if (!targetUrl) { // 抛出包含更多上下文的错误 throw new Error(`无效的代理请求路径。无法从组合路径 "${encodedUrlPath}" 中提取有效的目标 URL。`); } console.info(`开始处理目标 URL 的代理请求: ${targetUrl}`); // --- 获取并处理目标内容 --- const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, req.headers); // --- 如果是 M3U8,处理并返回 --- if (isM3u8Content(content, contentType)) { console.info(`正在处理 M3U8 内容: ${targetUrl}`); const processedM3u8 = await processM3u8Content(targetUrl, content); console.info(`成功处理 M3U8: ${targetUrl}`); // 发送处理后的 M3U8 响应 res.status(200) .setHeader('Content-Type', 'application/vnd.apple.mpegurl;charset=utf-8') .setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`) // 移除可能导致问题的原始响应头 .removeHeader('content-encoding') // 很重要!node-fetch 已解压 .removeHeader('content-length') // 长度已改变 .send(processedM3u8); // 发送 M3U8 文本 } else { // --- 如果不是 M3U8,直接返回原始内容 --- console.info(`直接返回非 M3U8 内容: ${targetUrl}, 类型: ${contentType}`); // 设置原始响应头,但排除有问题的头和 CORS 头(已设置) responseHeaders.forEach((value, key) => { const lowerKey = key.toLowerCase(); if (!lowerKey.startsWith('access-control-') && lowerKey !== 'content-encoding' && // 很重要! lowerKey !== 'content-length') { // 很重要! res.setHeader(key, value); // 设置其他原始头 } }); // 设置我们自己的缓存策略 res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`); // 发送原始(已解压)内容 res.status(200).send(content); } // ---- 结束主处理逻辑的 try 块 ---- } catch (error) { // ---- 捕获处理过程中的任何错误 ---- // **检查这个错误是否是 "Assignment to constant variable"** console.error(`[代理错误处理 V3] 捕获错误!目标: ${targetUrl || '解析失败'} | 错误类型: ${error.constructor.name} | 错误消息: ${error.message}`); console.error(`[代理错误堆栈 V3] ${error.stack}`); // 记录完整的错误堆栈信息 // 特别标记 "Assignment to constant variable" 错误 if (error instanceof TypeError && error.message.includes("Assignment to constant variable")) { console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); console.error("捕获到 'Assignment to constant variable' 错误!"); console.error("请再次检查函数代码及所有辅助函数中,是否有 const 声明的变量被重新赋值。"); console.error("错误堆栈指向:", error.stack); console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); } // 尝试从错误对象获取状态码,否则默认为 500 const statusCode = error.status || 500; // 确保在发送错误响应前没有发送过响应头 if (!res.headersSent) { res.setHeader('Content-Type', 'application/json'); // CORS 头应该已经在前面设置好了 res.status(statusCode).json({ success: false, error: `代理处理错误: ${error.message}`, // 返回错误消息给前端 targetUrl: targetUrl // 包含目标 URL 以便调试 }); } else { // 如果响应头已发送,无法再发送 JSON 错误 console.error("[代理错误处理 V3] 响应头已发送,无法发送 JSON 错误响应。"); // 尝试结束响应 if (!res.writableEnded) { res.end(); } } } finally { // 记录请求处理结束 console.info('--- Vercel 代理请求结束 ---'); } } // --- [确保所有辅助函数定义都在这里] --- // getTargetUrlFromPath, getBaseUrl, resolveUrl, rewriteUrlToProxy, getRandomUserAgent, // fetchContentWithType, isM3u8Content, processKeyLine, processMapLine, // processMediaPlaylist, processM3u8Content, processMasterPlaylist ================================================ FILE: css/index.css ================================================ /* 主页特定样式 */ /* 历史记录和设置按钮定位样式 */ .top-corner-button { position: fixed; z-index: 10; background: #222; border: 1px solid #333; border-radius: 0.5rem; padding: 0.375rem 0.75rem; transition: all 0.2s ease; } .top-corner-button:hover { background: #333; border-color: white; } /* 搜索区域样式 */ .search-box { height: 3.5rem; box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); border-radius: 0.5rem; overflow: hidden; display: flex; align-items: stretch; } .search-button { width: 5rem; display: flex; align-items: center; justify-content: center; background: white; color: black; font-weight: 500; transition: background-color 0.2s; } .search-button:hover { background: #f0f0f0; } .search-input { flex: 1; background: #111; border-top: 1px solid #333; border-bottom: 1px solid #333; color: white; padding: 0 1.5rem; font-size: 1rem; outline: none; transition: background-color 0.2s; } .search-input:focus { background: #191919; } /* 最近搜索记录样式 */ .recent-search-tag { display: inline-block; padding: 0.25rem 0.75rem; margin: 0.25rem; background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); border-radius: 0.5rem; color: #e5e7eb; font-size: 0.875rem; transition: all 0.2s; } .recent-search-tag:hover { background: rgba(59, 130, 246, 0.2); border-color: rgba(59, 130, 246, 0.4); } /* 豆瓣区域样式 */ .douban-container { margin: 2rem auto; max-width: 1280px; padding: 0 0.5rem; } .douban-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; } .douban-toggle { display: flex; align-items: center; background: #222; border-radius: 9999px; padding: 0.25rem; } .douban-toggle-button { padding: 0.25rem 0.75rem; font-size: 0.875rem; border-radius: 9999px; } .douban-toggle-button.active { background: #db2777; color: white; } .douban-toggle-button:not(.active) { color: #9ca3af; } .douban-toggle-button:not(.active):hover { color: white; } .douban-refresh-button { font-size: 0.875rem; padding: 0.25rem 0.75rem; background: #db2777; color: white; border-radius: 0.5rem; display: flex; align-items: center; gap: 0.25rem; } .douban-refresh-button:hover { background: #be185d; } .douban-tags-container { overflow-x: auto; padding-bottom: 0.5rem; } .douban-tags { display: flex; gap: 0.5rem; min-width: max-content; } .douban-tag { padding: 0.25rem 0.75rem; background: rgba(219, 39, 119, 0.1); border: 1px solid rgba(219, 39, 119, 0.2); border-radius: 0.5rem; color: #f9a8d4; font-size: 0.875rem; transition: all 0.2s; } .douban-tag:hover { background: rgba(219, 39, 119, 0.2); border-color: rgba(219, 39, 119, 0.4); } .douban-tag.active { background: #db2777; border-color: #db2777; color: white; } /* 搜索结果样式 */ .search-results-container { width: 100%; max-width: 1280px; margin: 0 auto; padding: 0 0.5rem; } .search-result-stats { text-align: right; font-size: 0.875rem; color: #9ca3af; margin-bottom: 1rem; } /* 响应式网格布局 */ .search-results-grid { display: grid; gap: 1rem; } @media (max-width: 640px) { .search-results-grid { grid-template-columns: 1fr; } } @media (min-width: 641px) and (max-width: 768px) { .search-results-grid { grid-template-columns: repeat(2, 1fr); } } @media (min-width: 769px) and (max-width: 1024px) { .search-results-grid { grid-template-columns: repeat(3, 1fr); } } @media (min-width: 1025px) { .search-results-grid { grid-template-columns: repeat(4, 1fr); } } ================================================ FILE: css/modals.css ================================================ /* 模态框通用样式 */ .modal-overlay { position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.95); display: none; align-items: center; justify-content: center; z-index: 40; transition: opacity 0.3s ease; } .modal-content { background-color: #111; padding: 2rem; border-radius: 0.5rem; border: 1px solid #333; width: 91.666667%; max-width: 56rem; max-height: 90vh; display: flex; flex-direction: column; } .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex: none; } .modal-title { font-size: 1.5rem; font-weight: 700; background: linear-gradient(to right, #00ccff, #ff3c78); -webkit-background-clip: text; -webkit-text-fill-color: transparent; word-break: break-word; padding-right: 1rem; max-width: 80%; } .modal-close { color: #9ca3af; font-size: 1.5rem; transition: color 0.2s; flex-shrink: 0; } .modal-close:hover { color: white; } .modal-body { overflow: auto; flex: 1; min-height: 0; } /* 密码验证模态框 */ .password-modal { z-index: 65; } .password-form { margin-bottom: 1.5rem; } .password-input { width: 100%; background-color: #111; border: 1px solid #333; color: white; padding: 1rem; border-radius: 0.5rem; margin-bottom: 1rem; } .password-input:focus { outline: none; border-color: white; } .password-submit { width: 100%; background-color: #3b82f6; color: white; padding: 0.5rem 1rem; border-radius: 0.25rem; font-weight: 500; } .password-submit:hover { background-color: #2563eb; } .password-error { color: #ef4444; margin-top: 0.5rem; display: none; } /* 声明模态框 */ .disclaimer-modal { z-index: 60; } .disclaimer-content { color: #d1d5db; line-height: 1.5; } .disclaimer-content p { margin-bottom: 1rem; } .disclaimer-content strong { color: #60a5fa; } .disclaimer-button { margin-top: 1.5rem; padding: 0.75rem 1.5rem; background: linear-gradient(to right, #4f46e5, #8b5cf6, #ec4899); color: white; font-weight: 600; border-radius: 0.5rem; transition: all 0.3s; } .disclaimer-button:hover { box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2); transform: translateY(-1px); } /* Toast 和 Loading 提示 */ .toast { position: fixed; top: 1rem; left: 50%; transform: translateX(-50%) translateY(-100%); background-color: #ef4444; color: white; padding: 0.75rem 1.5rem; border-radius: 0.5rem; box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.3); transition: all 0.3s; opacity: 0; z-index: 50; } .toast.show { transform: translateX(-50%) translateY(0); opacity: 1; } .loading-overlay { position: fixed; inset: 0; background-color: rgba(0, 0, 0, 0.8); display: none; align-items: center; justify-content: center; z-index: 50; } .loading-content { background-color: #111; padding: 2rem; border-radius: 0.5rem; border: 1px solid #333; display: flex; align-items: center; gap: 1rem; } .loading-spinner { width: 2rem; height: 2rem; border: 4px solid white; border-top-color: transparent; border-radius: 50%; animation: spin 1s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } .loading-text { color: white; font-size: 1.125rem; } /* 动画效果 */ @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } .modal-overlay.show { animation: fadeIn 0.3s forwards; display: flex; } .modal-overlay.hide { animation: fadeOut 0.3s forwards; } /* 资源速率测试相关样式 */ .speed-indicator { display: inline-flex; align-items: center; gap: 2px; font-size: 10px; font-weight: 500; line-height: 1; } .speed-indicator.good { color: #10b981; } .speed-indicator.medium { color: #f59e0b; } .speed-indicator.poor { color: #ef4444; } .speed-indicator.error { color: #ef4444; } /* 资源卡片悬停效果增强 */ .resource-card { transition: all 0.2s ease; } .resource-card:hover { transform: translateY(-2px); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); } .resource-card.current { border: 1px solid #3b82f6; box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.3); } /* 速率徽章样式 */ .speed-badge { backdrop-filter: blur(4px); font-size: 9px; font-weight: 600; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); border-radius: 4px; padding: 2px 4px; } ================================================ FILE: css/player.css ================================================ body, html { margin: 0; padding: 0; width: 100%; height: 100%; background-color: #0f1622; color: white; padding-top: 37px; } /* Critical header and navigation styles */ .player-header { position: relative; z-index: 2147483647 !important; pointer-events: auto !important; } .player-header-fixed { position: fixed !important; top: 0; left: 0; width: 100vw; z-index: 9000 !important; pointer-events: auto !important; background: #111; } #homeButton { pointer-events: auto !important; } .home-button { background: none !important; border: none !important; padding: 0 !important; } /* Critical loading styles to prevent FOUC */ .player-placeholder { width: 100% !important; height: auto !important; aspect-ratio: 16/9 !important; background-color: #1f2937 !important; position: relative !important; display: block !important; border-radius: 8px !important; overflow: hidden !important; } .player-loading-container { width: 100% !important; height: 0 !important; padding-bottom: 56.25% !important; position: relative !important; background-color: #1f2937 !important; border-radius: 8px !important; overflow: hidden !important; display: block !important; } .player-loading-overlay { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; display: flex !important; flex-direction: column !important; align-items: center !important; justify-content: center !important; background-color: rgba(17, 24, 39, 0.7) !important; } .player-loading-spinner { width: 48px !important; height: 48px !important; border: 4px solid rgba(255, 255, 255, 0.1) !important; border-radius: 50% !important; border-top-color: #f97316 !important; position: relative !important; margin-bottom: 16px !important; animation: spin 1s linear infinite !important; } /* Critical styles for loading text */ .player-loading-text, .player-loading-overlay div:nth-child(2), div.player-loading-text { display: block !important; visibility: visible !important; opacity: 1 !important; color: #f9fafb !important; font-size: 16px !important; font-weight: 500 !important; margin-bottom: 8px !important; text-align: center !important; font-family: system-ui, -apple-system, sans-serif !important; line-height: 1.4 !important; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important; pointer-events: none !important; z-index: 100 !important; background: transparent !important; max-width: 90% !important; } @keyframes player-spinner-rotate { to { transform: rotate(360deg); } } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } /* ArtPlayer specific styles */ .art-video-player, .art-video-player video { width: 100% !important; height: 100% !important; min-height: 150px !important; max-height: 100vh !important; background: #000 !important; object-fit: contain !important; display: block !important; } /* Fix for Chrome-specific issues */ @media screen and (-webkit-min-device-pixel-ratio: 0) { .art-video-player video { transform: translateZ(0) !important; will-change: transform !important; } /* Force visibility of video element */ .art-video-player.art-playing video { visibility: visible !important; opacity: 1 !important; } } .player-container { width: 100%; max-width: 1000px; margin: 0 auto; } #player { width: 100%; height: 60vh; /* 视频播放器高度 */ } .loading-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; background-color: rgba(0, 0, 0, 0.7); color: white; z-index: 100; flex-direction: column; } .loading-spinner { width: 50px; height: 50px; border: 4px solid rgba(255, 255, 255, 0.3); border-radius: 50%; border-top-color: white; animation: spin 1s ease-in-out infinite; margin-bottom: 10px; } .error-container { position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: none; align-items: center; justify-content: center; background-color: rgba(0, 0, 0, 0.7); color: white; z-index: 100; flex-direction: column; text-align: center; padding: 1rem; } .error-icon { font-size: 48px; margin-bottom: 10px; } .error-message-sub { margin-top: 10px; font-size: 14px; color: #aaa; } .episode-active { background-color: #3b82f6 !important; border-color: #60a5fa !important; } .episode-grid { max-height: 30vh; overflow-y: auto; } /* 恢复播放位置提示样式 */ .position-restore-hint { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(100%); background-color: rgba(0, 0, 0, 0.8); color: white; padding: 10px 20px; border-radius: 4px; z-index: 1000; transition: transform 0.3s ease; font-size: 14px; } .position-restore-hint.show { transform: translateX(-50%) translateY(0); } .hint-content { display: flex; align-items: center; justify-content: center; } .switch { position: relative; display: inline-block; width: 46px; height: 24px; } .switch input { opacity: 0; width: 0; height: 0; } .slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background-color: #333; transition: .4s; border-radius: 24px; } .slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: .4s; border-radius: 50%; } input:checked + .slider { background-color: #00ccff; } input:checked + .slider:before { transform: translateX(22px); } /* 添加快捷键提示样式 */ .shortcut-hint { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background-color: rgba(0, 0, 0, 0.8); color: white; padding: 1rem 2rem; border-radius: 0.5rem; font-size: 1.5rem; display: flex; align-items: center; gap: 0.5rem; z-index: 1000; opacity: 0; transition: opacity 0.3s ease; } .shortcut-hint.show { opacity: 1; } /* 原生全屏时,播放器容器铺满 */ .player-container:-webkit-full-screen, .player-container:fullscreen { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; z-index: 10000; background-color: #000; } .player-container:-webkit-full-screen #player, .player-container:fullscreen #player { width: 100%; height: 100%; } /* 资源信息卡片区 */ #resourceInfoBarContainer { align-items: center; border-radius: 0.5rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); } .resource-info-bar-left { align-items: center; font-size: 1.1rem; font-weight: bold; color: #fff; flex: 1; } .resource-info-bar-videos { font-size: 1rem; font-weight: normal; margin-left: 10px; color: #ccc; } .resource-switch-btn { align-items: center; background: none; border: none; color: #a67c2d; font-weight: bold; font-size: 1rem; cursor: pointer; gap: 6px; padding: 6px 12px; border-radius: 0.5rem; transition: background 0.2s; } .resource-switch-btn:hover { background: #f5e9d7; } .resource-switch-btn:active { background: #f5e9d7; } .resource-switch-icon { width: 20px; height: 20px; margin-right: 0; color: #a67c2d; vertical-align: middle; transition: transform 0.3s; } /* 新增:移动端响应式样式 */ @media (max-width: 640px) { .episode-grid { max-height: 40vh; /* 移动端增加集数列表高度 */ } /* 改进移动端按钮显示 */ button { white-space: nowrap; } /* 控制栏在小屏幕上可能需要换行 */ .player-container .flex-wrap { margin-bottom: 4px; } } /* 隐藏用户名输入框 */ #username { display: none !important; } ================================================ FILE: css/styles.css ================================================ /* LibreTV 全局样式 包含多个页面共享的基础样式 对于特定页面的样式,请参考: - index.css: 首页特定样式 - player.css: 播放器页面特定样式 - watch.css: 重定向页面特定样式 - modals.css: 模态框和提示框样式 */ .close-btn { position: absolute; top: 12px; right: 12px; background: #222; border: 1px solid #333; border-radius: 8px; padding: 6px; color: white; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.2s ease; z-index: 10; } .close-btn:hover { background: #333; border-color: #555; } .close-btn svg { width: 16px; height: 16px; stroke: currentColor; } :root { /* 赛博影视主题配色方案 - 柔和版 */ --primary-color: #00ccff; /* 霓虹蓝主色调 */ --primary-light: #33d6ff; /* 浅霓虹蓝变体 */ --secondary-color: #0f1622; /* 深蓝黑背景色 */ --accent-color: #ff3c78; /* 霓虹粉强调色 */ --text-color: #e6f2ff; /* 柔和的蓝白色文本 */ --text-muted: #8599b2; /* 淡蓝灰色次级文本 */ --border-color: rgba(0, 204, 255, 0.15); --page-gradient-start: #0f1622; /* 深蓝黑起始色 */ --page-gradient-end: #192231; /* 深靛蓝结束色 */ --card-gradient-start: #121b29; /* 卡片起始色 */ --card-gradient-end: #1c2939; /* 卡片结束色 */ --card-accent: rgba(0, 204, 255, 0.12); /* 霓虹蓝卡片强调色 */ --card-hover-border: rgba(0, 204, 255, 0.5); /* 悬停边框颜色 */ } .page-bg { background: linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)); min-height: 100vh; /* 柔和赛博点状背景 */ background-image: linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)), radial-gradient(circle at 25px 25px, rgba(0, 204, 255, 0.04) 2px, transparent 3px), radial-gradient(circle at 75px 75px, rgba(255, 60, 120, 0.02) 1px, transparent 2px), radial-gradient(circle at 50px 50px, rgba(150, 255, 250, 0.015) 1px, transparent 2px); background-blend-mode: normal; background-size: cover, 100px 100px, 50px 50px, 75px 75px; } button, .card-hover { transition: all 0.3s ease; } /* 改进卡片适应不同内容长度 */ .card-hover { border: 1px solid var(--border-color); background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); position: relative; overflow: hidden; border-radius: 6px; display: flex; flex-direction: column; height: 100%; } /* 确保卡片内容区域高度一致性 */ .card-hover .flex-grow { min-height: unset; /* 移除最小高度限制,让内容自然流动 */ display: flex; flex-direction: column; } /* 针对不同长度的标题优化显示 */ .card-hover h3 { min-height: unset; max-height: unset; display: -webkit-box; -webkit-box-orient: vertical; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; line-height: 1.2rem; word-break: break-word; /* 允许在任何字符间断行 */ hyphens: auto; /* 允许断词 */ } .card-hover::before { content: ""; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, var(--card-accent), transparent); transition: left 0.6s ease; } .card-hover:hover { border-color: var(--card-hover-border); transform: translateY(-3px); box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5); } .card-hover:hover::before { left: 100%; } .gradient-text { background: linear-gradient(to right, var(--primary-color), var(--accent-color)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } /* 改进设置面板样式 */ .settings-panel { scrollbar-width: thin; scrollbar-color: #444 #222; transform: translateX(100%); transition: transform 0.3s ease; background: linear-gradient(135deg, var(--page-gradient-end), var(--page-gradient-start)); border-left: 1px solid var(--primary-color); } .settings-panel.show { transform: translateX(0); } .settings-panel::-webkit-scrollbar { width: 6px; } .settings-panel::-webkit-scrollbar-track { background: transparent; } .settings-panel::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; } .search-button { background: var(--primary-color); color: var(--text-color); } .search-button:hover { background: var(--primary-light); } ::-webkit-scrollbar { width: 8px; height: 8px; } ::-webkit-scrollbar-track { background: #111; border-radius: 4px; } ::-webkit-scrollbar-thumb { background: #333; border-radius: 4px; transition: all 0.3s ease; } ::-webkit-scrollbar-thumb:hover { background: #444; } * { scrollbar-width: thin; scrollbar-color: #333 #111; } .search-tag { background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); color: var(--text-color); padding: 0.5rem 1rem; border-radius: 0.5rem; font-size: 0.875rem; border: 1px solid var(--border-color); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .search-tag:hover { background: linear-gradient(135deg, var(--card-gradient-end), var(--card-gradient-start)); border-color: var(--primary-color); } .footer { width: 100%; transition: all 0.3s ease; margin-top: auto; background: linear-gradient(to bottom, transparent, var(--page-gradient-start)); border-top: 1px solid var(--border-color); } .footer a:hover { text-decoration: underline; } body { display: flex; flex-direction: column; min-height: 100vh; } .container { flex: 1; } @media screen and (min-height: 800px) { body { display: flex; flex-direction: column; min-height: 100vh; } .container { flex: 1; } .footer { margin-top: auto; } } @media screen and (max-width: 640px) { .footer { padding-bottom: 2rem; } } /* 移动端布局优化 */ @media screen and (max-width: 768px) { .card-hover h3 { min-height: 2.5rem; } .card-hover .flex-grow { min-height: 80px; } } @keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes fadeOut { from { opacity: 1; } to { opacity: 0; } } #modal.show { animation: fadeIn 0.3s forwards; } #modal.hide { animation: fadeOut 0.3s forwards; } #modal > div { background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end)); border: 1px solid var(--primary-color); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), 0 0 15px rgba(0, 204, 255, 0.1); border-radius: 8px; } #episodesGrid button { background: rgba(0, 204, 255, 0.08); border: 1px solid rgba(0, 204, 255, 0.2); transition: all 0.2s ease; } #episodesGrid button:hover { background: rgba(0, 204, 255, 0.15); border-color: var(--primary-color); box-shadow: 0 0 8px rgba(0, 204, 255, 0.3); } #yellowFilterToggle:checked + .toggle-bg { background-color: var(--primary-color); } #yellowFilterToggle:checked ~ .toggle-dot { transform: translateX(1.5rem); } #yellowFilterToggle:focus + .toggle-bg, #yellowFilterToggle:hover + .toggle-bg { box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3); } /* 添加广告过滤开关的CSS */ #adFilterToggle:checked + .toggle-bg { background-color: var(--primary-color); } #adFilterToggle:checked ~ .toggle-dot { transform: translateX(1.5rem); } #adFilterToggle:focus + .toggle-bg, #adFilterToggle:hover + .toggle-bg { box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3); } .toggle-dot { transition: transform 0.3s ease-in-out; box-shadow: 0 2px 4px rgba(0,0,0,0.2); } .toggle-bg { transition: background-color 0.3s ease-in-out; } #yellowFilterToggle:checked ~ .toggle-dot { box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3); } #adFilterToggle:checked ~ .toggle-dot { box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3); } /* 添加API复选框样式 */ .form-checkbox { appearance: none; -webkit-appearance: none; -moz-appearance: none; height: 14px; width: 14px; background-color: #222; border: 1px solid #333; border-radius: 3px; cursor: pointer; position: relative; outline: none; } .form-checkbox:checked { background-color: var(--primary-color); border-color: var(--primary-color); } .form-checkbox:checked::after { content: ''; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); } /* API滚动区域美化 */ #apiCheckboxes { scrollbar-width: thin; scrollbar-color: #444 #222; } #apiCheckboxes::-webkit-scrollbar { width: 6px; } #apiCheckboxes::-webkit-scrollbar-track { background: #222; border-radius: 4px; } #apiCheckboxes::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; } /* 自定义API列表样式 */ #customApisList { scrollbar-width: thin; scrollbar-color: #444 #222; } #customApisList::-webkit-scrollbar { width: 6px; } #customApisList::-webkit-scrollbar-track { background: transparent; } #customApisList::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; } /* 设置面板滚动样式 */ .settings-panel { scrollbar-width: thin; scrollbar-color: #444 #222; } .settings-panel::-webkit-scrollbar { width: 6px; } .settings-panel::-webkit-scrollbar-track { background: transparent; } .settings-panel::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; } /* 添加自定义API表单动画 */ #addCustomApiForm { transition: all 0.3s ease; max-height: 0; opacity: 0; overflow: hidden; } #addCustomApiForm.hidden { max-height: 0; padding: 0; opacity: 0; } #addCustomApiForm:not(.hidden) { max-height: 230px; opacity: 1; } /* 成人内容API标记样式 */ .api-adult + label { color: #ff6b8b !important; } /* 添加警告图标和标签样式 */ .adult-warning { display: inline-flex; align-items: center; margin-left: 0.25rem; color: #ff6b8b; } .adult-warning svg { width: 12px; height: 12px; margin-right: 4px; } /* 过滤器禁用样式 */ .filter-disabled { opacity: 0.5; pointer-events: none; cursor: not-allowed; } /* API组标题样式 */ .api-group-title { grid-column: span 2; padding: 0.25rem 0; margin-top: 0.5rem; border-top: 1px solid #333; color: #8599b2; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; } .api-group-title.adult { color: #ff6b8b; } /* 过滤器禁用样式 - 改进版本 */ .filter-disabled { position: relative; } .filter-disabled::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.4); border-radius: 0.5rem; z-index: 5; } .filter-disabled > * { opacity: 0.7; } .filter-disabled .toggle-bg { background-color: #333 !important; } .filter-disabled .toggle-dot { transform: translateX(0) !important; background-color: #666 !important; } /* 改进过滤器禁用样式 */ .filter-disabled .filter-description { color: #ff6b8b !important; font-style: italic; font-weight: 500; } /* 修改过滤器禁用样式,确保文字清晰可见 */ .filter-disabled { position: relative; } .filter-disabled::after { content: ''; position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.3); border-radius: 0.5rem; z-index: 5; } .filter-disabled > * { opacity: 1; /* 提高子元素不透明度,保证可见性 */ z-index: 6; /* 确保内容在遮罩上方 */ } /* 改进过滤器禁用状态下的描述样式 */ .filter-disabled .filter-description { color: #ff7b9d !important; /* 更亮的粉色 */ font-style: italic; font-weight: 500; text-shadow: 0 0 2px rgba(0,0,0,0.8); /* 添加文字阴影提高对比度 */ } /* 开关的禁用样式 */ .filter-disabled .toggle-bg { background-color: #444 !important; opacity: 0.8; } .filter-disabled .toggle-dot { transform: translateX(0) ; background-color: #777 ; opacity: 0.9; } /* 警告提示样式改进 */ .filter-tooltip { background-color: rgba(255, 61, 87, 0.1); border: 1px solid rgba(255, 61, 87, 0.2); border-radius: 0.25rem; padding: 0.5rem; margin-top: 0.5rem; display: flex; align-items: center; font-size: 0.75rem; line-height: 1.25; position: relative; z-index: 10; } .filter-tooltip svg { flex-shrink: 0; width: 14px; height: 14px; margin-right: 0.35rem; } /* 编辑按钮样式 */ .custom-api-edit { color: #3b82f6; transition: color 0.2s ease; } .custom-api-edit:hover { color: #2563eb; } /* 自定义API条目样式改进 */ #customApisList .api-item { display: flex; align-items: center; justify-content: space-between; padding: 0.25rem 0.5rem; margin-bottom: 0.25rem; background-color: #222; border-radius: 0.25rem; transition: background-color 0.2s ease; } #customApisList .api-item:hover { background-color: #2a2a2a; } /* 成人内容标签样式 */ .adult-tag { display: inline-flex; align-items: center; color: #ff6b8b; font-size: 0.7rem; font-weight: 500; margin-right: 0.35rem; } /* 历史记录面板样式 */ .history-panel { box-shadow: 2px 0 10px rgba(0,0,0,0.5); transition: transform 0.3s ease-in-out; overflow-y: scroll; /* 始终显示滚动条,防止宽度变化 */ overflow-x: hidden; /* 防止水平滚动 */ width: 320px; /* 固定宽度 */ box-sizing: border-box; /* 确保padding不影响总宽度 */ scrollbar-gutter: stable; /* 现代浏览器:为滚动条预留空间 */ } .history-panel.show { transform: translateX(0); } #historyList { padding-right: 6px; /* 为滚动条预留空间,确保内容不被挤压 */ } /* 历史记录项样式优化 */ .history-item { background: #1a1a1a; border-radius: 6px; /* 减小圆角 */ border: 1px solid #333; overflow: hidden; transition: all 0.2s ease; padding: 10px 14px; position: relative; margin-bottom: 8px; /* 减小底部间距 */ width: 100%; /* 确保宽度一致 */ } .history-item:hover { transform: translateY(-2px); border-color: #444; box-shadow: 0 4px 8px rgba(0,0,0,0.2); } /* 添加组悬停效果,使删除按钮在悬停时显示 */ .history-item .delete-btn { opacity: 0; transition: opacity 0.2s ease; } .history-item:hover .delete-btn { opacity: 1; } .history-info { padding: 0; /* 移除额外的内边距 */ min-height: 70px; } .history-title { font-weight: 500; font-size: 0.95rem; /* 减小字体大小 */ margin-bottom: 2px; /* 减小底部边距 */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .history-meta { color: #bbb; font-size: 0.75rem; /* 减小字体大小 */ display: flex; flex-wrap: wrap; margin-bottom: 1px; /* 减小边距 */ } .history-episode { color: #3b82f6; } .history-source { color: #10b981; } .history-time { color: #888; font-size: 0.7rem; /* 减小字体大小 */ margin-top: 1px; /* 减小顶部边距 */ } .history-separator { color: #666; } .history-thumbnail { width: 100%; height: 90px; background-color: #222; overflow: hidden; } .history-thumbnail img { width: 100%; height: 100%; object-fit: cover; } .history-info { padding: 10px; } .history-time { color: #888; font-size: 0.8rem; margin-top: 4px; } .history-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* 添加播放进度条样式 */ .history-progress { margin: 5px 0; } .progress-bar { height: 3px; background-color: rgba(255, 255, 255, 0.1); border-radius: 2px; overflow: hidden; margin-bottom: 2px; } .progress-filled { height: 100%; background: linear-gradient(to right, #00ccff, #3b82f6); border-radius: 2px; } .progress-text { font-size: 10px; color: #888; text-align: right; } /* 添加恢复播放提示样式 */ .position-restore-hint { position: absolute; bottom: 60px; left: 50%; transform: translateX(-50%) translateY(20px); background-color: rgba(0, 0, 0, 0.7); color: white; padding: 8px 16px; border-radius: 4px; font-size: 14px; z-index: 100; opacity: 0; transition: all 0.3s ease; } .position-restore-hint.show { opacity: 1; transform: translateX(-50%) translateY(0); } /* 锁定控制时屏蔽交互 */ .player-container.controls-locked .dplayer-controller, .player-container.controls-locked .dplayer-mask, .player-container.controls-locked .dplayer-bar-wrap, .player-container.controls-locked .dplayer-statusbar, .player-container.controls-locked .shortcut-hint { opacity: 0 !important; pointer-events: none !important; } /* 保留锁按钮可见可点 */ .player-container.controls-locked #lockToggle { opacity: 1 !important; pointer-events: auto !important; } /* 播放器顶部header移动端优化 */ .player-header { gap: 0.5rem; } .custom-title-scroll { overflow-x: auto; white-space: nowrap; text-overflow: ellipsis; -webkit-overflow-scrolling: touch; scrollbar-width: none; } .custom-title-scroll::-webkit-scrollbar { display: none; } .logo-text { display: inline; } .home-btn-text { display: inline; } @media (max-width: 640px) { .logo-text { display: none; } .home-btn-text { display: none; } .logo-icon { margin-right: 0; } .home-btn svg { margin-right: 0; } .player-header { padding-left: 2px !important; padding-right: 2px !important; } .custom-title-scroll { font-size: 1rem; } } /* 搜索结果卡片优化:横向布局 */ .search-card-img-container { width: 100px; /* 增加宽度,从80px到100px */ height: 150px; /* 增加高度,从120px到150px */ overflow: hidden; background-color: #191919; } /* 确保图片不会被拉伸,并且能够正确显示 */ .search-card-img-container img { width: 100%; height: 100%; object-fit: cover; } /* 针对搜索结果卡片修改网格布局以适应横向卡片 */ @media (max-width: 640px) { #results { grid-template-columns: repeat(1, minmax(0, 1fr)) !important; } } /* 响应式调整:在小屏幕上依然保持较好的视觉效果 */ @media (min-width: 641px) and (max-width: 768px) { #results { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; } } /* 调整网格布局,减少每行卡片数量以适应更大尺寸的卡片 */ @media (min-width: 769px) and (max-width: 1024px) { #results { grid-template-columns: repeat(3, minmax(0, 1fr)) !important; } } @media (min-width: 1025px) { #results { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; } } /* 优化卡片内元素间距 */ .card-hover .p-2 { padding: 0.75rem; /* 增加内边距 */ } /* 增加卡片内字体大小 */ .card-hover h3 { font-size: 0.95rem; /* 增加标题字体大小 */ line-height: 1.3rem; margin-bottom: 0.5rem; } .card-hover p { font-size: 0.8rem; /* 增加描述字体大小 */ } /* 优化卡片内元素间距 */ .card-hover .p-2 { padding: 0.5rem; } /* 确保Toast显示在顶层并有适当的转换效果 */ #toast { z-index: 9999; /* 确保显示在最上层 */ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); min-width: 200px; text-align: center; pointer-events: none; /* 防止toast阻挡点击事件 */ transform: translateX(-50%) translateY(0); transition: opacity 0.3s ease, transform 0.3s ease; } #toast.hidden { opacity: 0; transform: translateX(-50%) translateY(-100%); } /* 详情模态框样式优化 */ #modal .modal-detail-info { background: linear-gradient(135deg, #0a0a0a, #111); border: 1px solid #222; border-radius: 8px; padding: 1rem; margin-bottom: 1.5rem; } #modal .detail-grid { display: grid; grid-template-columns: 1fr; gap: 0.75rem; font-size: 0.875rem; } @media (min-width: 768px) { #modal .detail-grid { grid-template-columns: 1fr 1fr; } } #modal .detail-item { display: flex; flex-wrap: wrap; gap: 0.5rem; } #modal .detail-label { color: #9ca3af; font-weight: 500; min-width: 3rem; flex-shrink: 0; } #modal .detail-value { color: white; flex: 1; word-break: break-word; } #modal .detail-desc { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #333; } #modal .detail-desc-content { color: #d1d5db; font-size: 0.875rem; line-height: 1.6; max-height: 8rem; overflow-y: auto; scrollbar-width: thin; scrollbar-color: #444 #222; } #modal .detail-desc-content::-webkit-scrollbar { width: 6px; } #modal .detail-desc-content::-webkit-scrollbar-track { background: #222; border-radius: 4px; } #modal .detail-desc-content::-webkit-scrollbar-thumb { background-color: #444; border-radius: 4px; } /* 集数统计信息样式 */ #modal .episode-stats { color: #9ca3af; font-size: 0.875rem; display: flex; align-items: center; gap: 0.5rem; } /* 移动端优化 */ @media (max-width: 640px) { #modal .detail-grid { gap: 0.5rem; font-size: 0.8rem; } #modal .detail-label { min-width: 2.5rem; } #modal .detail-desc-content { max-height: 6rem; font-size: 0.8rem; } } ================================================ FILE: css/watch.css ================================================ /* 添加重定向页面的基本样式 */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #0f1622; color: white; margin: 0; padding: 0; height: 100vh; display: flex; align-items: center; justify-content: center; } .redirect-container { text-align: center; max-width: 90%; width: 380px; padding: 2rem; background-color: rgba(0, 0, 0, 0.3); border-radius: 16px; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); border: 1px solid rgba(255, 255, 255, 0.1); } .logo-container { display: flex; align-items: center; justify-content: center; margin-bottom: 1.5rem; } .logo-icon { width: 40px; height: 40px; color: #00ccff; margin-right: 10px; } .logo-text { font-size: 2rem; margin: 0; background: linear-gradient(to right, #00ccff, #ff3c78); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } .loading-animation { display: inline-block; width: 50px; height: 50px; border: 3px solid rgba(255, 255, 255, 0.2); border-radius: 50%; border-top-color: #00ccff; animation: spin 1s linear infinite; margin-bottom: 20px; } @keyframes spin { to { transform: rotate(360deg); } } .redirect-message { font-size: 1.2rem; margin-bottom: 10px; font-weight: 500; } #redirect-status { font-size: 0.9rem; color: #8599b2; margin-bottom: 1.5rem; height: 20px; } .redirect-hint { font-size: 0.9rem; color: #8599b2; margin-top: 20px; } .redirect-hint a { color: #00ccff; text-decoration: none; font-weight: 500; transition: all 0.2s ease; padding: 5px 10px; border-radius: 4px; background-color: rgba(0, 204, 255, 0.1); } .redirect-hint a:hover { background-color: rgba(0, 204, 255, 0.2); text-decoration: underline; } /* 移动端优化 */ @media (max-width: 480px) { .redirect-container { padding: 1.5rem; width: 85%; } .logo-icon { width: 30px; height: 30px; } .logo-text { font-size: 1.7rem; } .loading-animation { width: 40px; height: 40px; margin-bottom: 15px; } .redirect-message { font-size: 1rem; } #redirect-status { font-size: 0.8rem; } } ================================================ FILE: docker-compose.yml ================================================ services: libretv: image: bestzwei/libretv:latest container_name: libretv ports: - "8899:8080" # 将内部 8080 端口映射到主机的 8899 端口 environment: - PASSWORD=${PASSWORD:-your_password} # 可将 your_password 修改为你想要的密码,默认为 your_password # volumes: # - libretv_data:/app # 不要修改 restart: unless-stopped #volumes: # libretv_data: # driver: local # driver_opts: # type: none # o: bind # device: ${PWD:-.}/data # 可将 ${PWD:-.} 修改为你想要的路径,默认为当前目录下的 data 文件夹 ================================================ FILE: functions/_middleware.js ================================================ import { sha256 } from '../js/sha256.js'; export async function onRequest(context) { const { request, env, next } = context; const response = await next(); const contentType = response.headers.get("content-type") || ""; if (contentType.includes("text/html")) { let html = await response.text(); // 处理普通密码 const password = env.PASSWORD || ""; let passwordHash = ""; if (password) { passwordHash = await sha256(password); } html = html.replace('window.__ENV__.PASSWORD = "{{PASSWORD}}";', `window.__ENV__.PASSWORD = "${passwordHash}";`); return new Response(html, { headers: response.headers, status: response.status, statusText: response.statusText, }); } return response; } ================================================ FILE: functions/proxy/[[path]].js ================================================ // functions/proxy/[[path]].js // --- 配置 (现在从 Cloudflare 环境变量读取) --- // 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量: // CACHE_TTL (例如 86400) // MAX_RECURSION (例如 5) // FILTER_DISCONTINUITY (不再需要,设为 false 或移除) // USER_AGENTS_JSON (例如 ["UA1", "UA2"]) - JSON 字符串数组 // DEBUG (例如 false 或 true) // PASSWORD (例如 "your_password") - 鉴权密码 // --- 配置结束 --- // --- 常量 (之前在 config.js 中,现在移到这里,因为它们与代理逻辑相关) --- const MEDIA_FILE_EXTENSIONS = [ '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts', '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus', '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic' ]; const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/']; // --- 常量结束 --- /** * 主要的 Pages Function 处理函数 * 拦截发往 /proxy/* 的请求 */ export async function onRequest(context) { const { request, env, next, waitUntil } = context; // next 和 waitUntil 可能需要 const url = new URL(request.url); // 验证鉴权(主函数调用) const isValidAuth = await validateAuth(request, env); if (!isValidAuth) { return new Response(JSON.stringify({ success: false, error: '代理访问未授权:请检查密码配置或鉴权参数' }), { status: 401, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS', 'Access-Control-Allow-Headers': '*', 'Content-Type': 'application/json' } }); } // --- 从环境变量读取配置 --- const DEBUG_ENABLED = (env.DEBUG === 'true'); const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); // 默认 24 小时 const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); // 默认 5 层 // 广告过滤已移至播放器处理,代理不再执行 let USER_AGENTS = [ // 提供一个基础的默认值 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ]; try { // 尝试从环境变量解析 USER_AGENTS_JSON const agentsJson = env.USER_AGENTS_JSON; if (agentsJson) { const parsedAgents = JSON.parse(agentsJson); if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { USER_AGENTS = parsedAgents; } else { logDebug("环境变量 USER_AGENTS_JSON 格式无效或为空,使用默认值"); } } } catch (e) { logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message},使用默认值`); } // --- 配置读取结束 --- // --- 辅助函数 --- // 验证代理请求的鉴权 async function validateAuth(request, env) { const url = new URL(request.url); const authHash = url.searchParams.get('auth'); const timestamp = url.searchParams.get('t'); // 获取服务器端密码 const serverPassword = env.PASSWORD; if (!serverPassword) { console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝'); return false; } // 使用 SHA-256 哈希算法(与其他平台保持一致) // 在 Cloudflare Workers 中使用 crypto.subtle try { const encoder = new TextEncoder(); const data = encoder.encode(serverPassword); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); const serverPasswordHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); if (!authHash || authHash !== serverPasswordHash) { console.warn('代理请求鉴权失败:密码哈希不匹配'); return false; } } catch (error) { console.error('计算密码哈希失败:', error); return false; } // 验证时间戳(10分钟有效期) if (timestamp) { const now = Date.now(); const maxAge = 10 * 60 * 1000; // 10分钟 if (now - parseInt(timestamp) > maxAge) { console.warn('代理请求鉴权失败:时间戳过期'); return false; } } return true; } // 验证鉴权(主函数调用) if (!validateAuth(request, env)) { return new Response('Unauthorized', { status: 401, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS', 'Access-Control-Allow-Headers': '*' } }); } // 输出调试日志 (需要设置 DEBUG: true 环境变量) function logDebug(message) { if (DEBUG_ENABLED) { console.log(`[Proxy Func] ${message}`); } } // 从请求路径中提取目标 URL function getTargetUrlFromPath(pathname) { // 路径格式: /proxy/经过编码的URL // 例如: /proxy/https%3A%2F%2Fexample.com%2Fplaylist.m3u8 const encodedUrl = pathname.replace(/^\/proxy\//, ''); if (!encodedUrl) return null; try { // 解码 let decodedUrl = decodeURIComponent(encodedUrl); // 简单检查解码后是否是有效的 http/https URL if (!decodedUrl.match(/^https?:\/\//i)) { // 也许原始路径就没有编码?如果看起来像URL就直接用 if (encodedUrl.match(/^https?:\/\//i)) { decodedUrl = encodedUrl; logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`); } else { logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`); return null; } } return decodedUrl; } catch (e) { logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`); return null; } } // 创建标准化的响应 function createResponse(body, status = 200, headers = {}) { const responseHeaders = new Headers(headers); // 关键:添加 CORS 跨域头,允许前端 JS 访问代理后的响应 responseHeaders.set("Access-Control-Allow-Origin", "*"); // 允许任何来源访问 responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); // 允许的方法 responseHeaders.set("Access-Control-Allow-Headers", "*"); // 允许所有请求头 // 处理 CORS 预检请求 (OPTIONS) - 放在这里确保所有响应都处理 if (request.method === "OPTIONS") { // 使用下面的 onOptions 函数可以更规范,但在这里处理也可以 return new Response(null, { status: 204, // No Content headers: responseHeaders // 包含上面设置的 CORS 头 }); } return new Response(body, { status, headers: responseHeaders }); } // 创建 M3U8 类型的响应 function createM3u8Response(content) { return createResponse(content, 200, { "Content-Type": "application/vnd.apple.mpegurl", // M3U8 的标准 MIME 类型 "Cache-Control": `public, max-age=${CACHE_TTL}` // 允许浏览器和CDN缓存 }); } // 获取随机 User-Agent function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } // 获取 URL 的基础路径 (用于解析相对路径) function getBaseUrl(urlStr) { try { const parsedUrl = new URL(urlStr); // 如果路径是根目录,或者没有斜杠,直接返回 origin + / if (!parsedUrl.pathname || parsedUrl.pathname === '/') { return `${parsedUrl.origin}/`; } const pathParts = parsedUrl.pathname.split('/'); pathParts.pop(); // 移除文件名或最后一个路径段 return `${parsedUrl.origin}${pathParts.join('/')}/`; } catch (e) { logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`); // 备用方法:找到最后一个斜杠 const lastSlashIndex = urlStr.lastIndexOf('/'); // 确保不是协议部分的斜杠 (http://) return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/'; } } // 将相对 URL 转换为绝对 URL function resolveUrl(baseUrl, relativeUrl) { // 如果已经是绝对 URL,直接返回 if (relativeUrl.match(/^https?:\/\//i)) { return relativeUrl; } try { // 使用 URL 对象来处理相对路径 return new URL(relativeUrl, baseUrl).toString(); } catch (e) { logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`); // 简单的备用方法 if (relativeUrl.startsWith('/')) { // 处理根路径相对 URL const urlObj = new URL(baseUrl); return `${urlObj.origin}${relativeUrl}`; } // 处理同级目录相对 URL return `${baseUrl.replace(/\/[^/]*$/, '/')}${relativeUrl}`; // 确保baseUrl以 / 结尾 } } // 将目标 URL 重写为内部代理路径 (/proxy/...) function rewriteUrlToProxy(targetUrl) { // 确保目标URL被正确编码,以便作为路径的一部分 return `/proxy/${encodeURIComponent(targetUrl)}`; } // 获取远程内容及其类型 async function fetchContentWithType(targetUrl) { const headers = new Headers({ 'User-Agent': getRandomUserAgent(), 'Accept': '*/*', // 尝试传递一些原始请求的头信息 'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8', // 尝试设置 Referer 为目标网站的域名,或者传递原始 Referer 'Referer': request.headers.get('Referer') || new URL(targetUrl).origin }); try { // 直接请求目标 URL logDebug(`开始直接请求: ${targetUrl}`); // Cloudflare Functions 的 fetch 默认支持重定向 const response = await fetch(targetUrl, { headers, redirect: 'follow' }); if (!response.ok) { const errorBody = await response.text().catch(() => ''); logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`); throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`); } // 读取响应内容为文本 const content = await response.text(); const contentType = response.headers.get('Content-Type') || ''; logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`); return { content, contentType, responseHeaders: response.headers }; // 同时返回原始响应头 } catch (error) { logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`); // 抛出更详细的错误 throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`); } } // 判断是否是 M3U8 内容 function isM3u8Content(content, contentType) { // 检查 Content-Type if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; } // 检查内容本身是否以 #EXTM3U 开头 return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); } // 判断是否是媒体文件 (根据扩展名和 Content-Type) - 这部分在此代理中似乎未使用,但保留 function isMediaFile(url, contentType) { if (contentType) { for (const mediaType of MEDIA_CONTENT_TYPES) { if (contentType.toLowerCase().startsWith(mediaType)) { return true; } } } const urlLower = url.toLowerCase(); for (const ext of MEDIA_FILE_EXTENSIONS) { if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) { return true; } } return false; } // 处理 M3U8 中的 #EXT-X-KEY 行 (加密密钥) function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径 }); } // 处理 M3U8 中的 #EXT-X-MAP 行 (初始化片段) function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径 }); } // 处理媒体 M3U8 播放列表 (包含视频/音频片段) function processMediaPlaylist(url, content) { const baseUrl = getBaseUrl(url); const lines = content.split('\n'); const output = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // 保留最后的空行 if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue; // 跳过中间的空行 if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; } if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; } if (line.startsWith('#EXTINF')) { output.push(line); continue; } if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; } // 其他 M3U8 标签直接添加 output.push(line); } return output.join('\n'); } // 递归处理 M3U8 内容 async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) { if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`检测到主播放列表: ${targetUrl}`); return await processMasterPlaylist(targetUrl, content, recursionDepth, env); } logDebug(`检测到媒体播放列表: ${targetUrl}`); return processMediaPlaylist(targetUrl, content); } // 处理主 M3U8 播放列表 async function processMasterPlaylist(url, content, recursionDepth, env) { if (recursionDepth > MAX_RECURSION) { throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`); } const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = ''; 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); } } } if (!bestVariantUrl) { logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF,尝试查找第一个子列表引用: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { // 修复:检查是否包含 .m3u8? bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`备选方案:找到第一个子列表引用: ${bestVariantUrl}`); break; } } } if (!bestVariantUrl) { logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`); return processMediaPlaylist(url, content); } // --- 获取并处理选中的子 M3U8 --- const cacheKey = `m3u8_processed:${bestVariantUrl}`; // 使用处理后的缓存键 let kvNamespace = null; try { kvNamespace = env.LIBRETV_PROXY_KV; // 从环境获取 KV 命名空间 (变量名在 Cloudflare 设置) if (!kvNamespace) throw new Error("KV 命名空间未绑定"); } catch (e) { logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); kvNamespace = null; // 确保设为 null } if (kvNamespace) { try { const cachedContent = await kvNamespace.get(cacheKey); if (cachedContent) { logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`); return cachedContent; } else { logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`); } } catch (kvError) { logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`); // 出错则继续执行,不影响功能 } } logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`); const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl); if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件,返回原始内容。`); // 如果不是M3U8,但看起来像媒体内容,直接返回代理后的内容 // 注意:这里可能需要决定是否直接代理这个非 M3U8 的 URL // 为了简化,我们假设如果不是 M3U8,则流程中断或按原样处理 // 或者,尝试将其作为媒体列表处理?(当前行为) // return createResponse(variantContent, 200, { 'Content-Type': variantContentType || 'application/octet-stream' }); // 尝试按媒体列表处理,以防万一 return processMediaPlaylist(bestVariantUrl, variantContent); } const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env); if (kvNamespace) { try { // 使用 waitUntil 异步写入缓存,不阻塞响应返回 // 注意 KV 的写入限制 (免费版每天 1000 次) waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL })); logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`); } catch (kvError) { logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); // 写入失败不影响返回结果 } } return processedVariant; } // --- 主要请求处理逻辑 --- try { const targetUrl = getTargetUrlFromPath(url.pathname); if (!targetUrl) { logDebug(`无效的代理请求路径: ${url.pathname}`); return createResponse("无效的代理请求。路径应为 /proxy/<经过编码的URL>", 400); } logDebug(`收到代理请求: ${targetUrl}`); // --- 缓存检查 (KV) --- const cacheKey = `proxy_raw:${targetUrl}`; // 使用原始内容的缓存键 let kvNamespace = null; try { kvNamespace = env.LIBRETV_PROXY_KV; if (!kvNamespace) throw new Error("KV 命名空间未绑定"); } catch (e) { logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`); kvNamespace = null; } if (kvNamespace) { try { const cachedDataJson = await kvNamespace.get(cacheKey); // 直接获取字符串 if (cachedDataJson) { logDebug(`[缓存命中] 原始内容: ${targetUrl}`); const cachedData = JSON.parse(cachedDataJson); // 解析 JSON const content = cachedData.body; let headers = {}; try { headers = JSON.parse(cachedData.headers); } catch(e){} // 解析头部 const contentType = headers['content-type'] || headers['Content-Type'] || ''; if (isM3u8Content(content, contentType)) { logDebug(`缓存内容是 M3U8,重新处理: ${targetUrl}`); const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); return createM3u8Response(processedM3u8); } else { logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`); return createResponse(content, 200, new Headers(headers)); } } else { logDebug(`[缓存未命中] 原始内容: ${targetUrl}`); } } catch (kvError) { logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`); // 出错则继续执行,不影响功能 } } // --- 实际请求 --- const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl); // --- 写入缓存 (KV) --- if (kvNamespace) { try { const headersToCache = {}; responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; }); const cacheValue = { body: content, headers: JSON.stringify(headersToCache) }; // 注意 KV 写入限制 waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL })); logDebug(`已将原始内容写入缓存: ${targetUrl}`); } catch (kvError) { logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`); // 写入失败不影响返回结果 } } // --- 处理响应 --- if (isM3u8Content(content, contentType)) { logDebug(`内容是 M3U8,开始处理: ${targetUrl}`); const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env); return createM3u8Response(processedM3u8); } else { logDebug(`内容不是 M3U8 (类型: ${contentType}),直接返回: ${targetUrl}`); const finalHeaders = new Headers(responseHeaders); finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`); // 添加 CORS 头,确保非 M3U8 内容也能跨域访问(例如图片、字幕文件等) finalHeaders.set("Access-Control-Allow-Origin", "*"); finalHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); finalHeaders.set("Access-Control-Allow-Headers", "*"); return createResponse(content, 200, finalHeaders); } } catch (error) { logDebug(`处理代理请求时发生严重错误: ${error.message} \n ${error.stack}`); return createResponse(`代理处理错误: ${error.message}`, 500); } } // 处理 OPTIONS 预检请求的函数 export async function onOptions(context) { // 直接返回允许跨域的头信息 return new Response(null, { status: 204, // No Content headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS", "Access-Control-Allow-Headers": "*", // 允许所有请求头 "Access-Control-Max-Age": "86400", // 预检请求结果缓存一天 }, }); } ================================================ FILE: index.html ================================================ LibreTV - 免费在线视频搜索与观看平台

自由观影,畅享精彩

================================================ FILE: js/api.js ================================================ // 改进的API请求处理函数 async function handleApiRequest(url) { const customApi = url.searchParams.get('customApi') || ''; const customDetail = url.searchParams.get('customDetail') || ''; const source = url.searchParams.get('source') || 'heimuer'; try { if (url.pathname === '/api/search') { const searchQuery = url.searchParams.get('wd'); if (!searchQuery) { throw new Error('缺少搜索参数'); } // 验证API和source的有效性 if (source === 'custom' && !customApi) { throw new Error('使用自定义API时必须提供API地址'); } if (!API_SITES[source] && source !== 'custom') { throw new Error('无效的API来源'); } const apiUrl = customApi ? `${customApi}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}` : `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; // 添加超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) : PROXY_URL + encodeURIComponent(apiUrl); const response = await fetch(proxiedUrl, { headers: API_CONFIG.search.headers, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`API请求失败: ${response.status}`); } const data = await response.json(); // 检查JSON格式的有效性 if (!data || !Array.isArray(data.list)) { throw new Error('API返回的数据格式无效'); } // 添加源信息到每个结果 data.list.forEach(item => { item.source_name = source === 'custom' ? '自定义源' : API_SITES[source].name; item.source_code = source; // 对于自定义源,添加API URL信息 if (source === 'custom') { item.api_url = customApi; } }); return JSON.stringify({ code: 200, list: data.list || [], }); } catch (fetchError) { clearTimeout(timeoutId); throw fetchError; } } // 详情处理 if (url.pathname === '/api/detail') { const id = url.searchParams.get('id'); const sourceCode = url.searchParams.get('source') || 'heimuer'; // 获取源代码 if (!id) { throw new Error('缺少视频ID参数'); } // 验证ID格式 - 只允许数字和有限的特殊字符 if (!/^[\w-]+$/.test(id)) { throw new Error('无效的视频ID格式'); } // 验证API和source的有效性 if (sourceCode === 'custom' && !customApi) { throw new Error('使用自定义API时必须提供API地址'); } if (!API_SITES[sourceCode] && sourceCode !== 'custom') { throw new Error('无效的API来源'); } // 对于有detail参数的源,都使用特殊处理方式 if (sourceCode !== 'custom' && API_SITES[sourceCode].detail) { return await handleSpecialSourceDetail(id, sourceCode); } // 如果是自定义API,并且传递了detail参数,尝试特殊处理 // 优先 customDetail if (sourceCode === 'custom' && customDetail) { return await handleCustomApiSpecialDetail(id, customDetail); } if (sourceCode === 'custom' && url.searchParams.get('useDetail') === 'true') { return await handleCustomApiSpecialDetail(id, customApi); } const detailUrl = customApi ? `${customApi}${API_CONFIG.detail.path}${id}` : `${API_SITES[sourceCode].api}${API_CONFIG.detail.path}${id}`; // 添加超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) : PROXY_URL + encodeURIComponent(detailUrl); const response = await fetch(proxiedUrl, { headers: API_CONFIG.detail.headers, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`详情请求失败: ${response.status}`); } // 解析JSON const data = await response.json(); // 检查返回的数据是否有效 if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) { throw new Error('获取到的详情内容无效'); } // 获取第一个匹配的视频详情 const videoDetail = data.list[0]; // 提取播放地址 let episodes = []; if (videoDetail.vod_play_url) { // 分割不同播放源 const playSources = videoDetail.vod_play_url.split('$$$'); // 提取第一个播放源的集数(通常为主要源) if (playSources.length > 0) { const mainSource = playSources[0]; const episodeList = mainSource.split('#'); // 从每个集数中提取URL episodes = episodeList.map(ep => { const parts = ep.split('$'); // 返回URL部分(通常是第二部分,如果有的话) return parts.length > 1 ? parts[1] : ''; }).filter(url => url && (url.startsWith('http://') || url.startsWith('https://'))); } } // 如果没有找到播放地址,尝试使用正则表达式查找m3u8链接 if (episodes.length === 0 && videoDetail.vod_content) { const matches = videoDetail.vod_content.match(M3U8_PATTERN) || []; episodes = matches.map(link => link.replace(/^\$/, '')); } return JSON.stringify({ code: 200, episodes: episodes, detailUrl: detailUrl, videoInfo: { title: videoDetail.vod_name, cover: videoDetail.vod_pic, desc: videoDetail.vod_content, type: videoDetail.type_name, year: videoDetail.vod_year, area: videoDetail.vod_area, director: videoDetail.vod_director, actor: videoDetail.vod_actor, remarks: videoDetail.vod_remarks, // 添加源信息 source_name: sourceCode === 'custom' ? '自定义源' : API_SITES[sourceCode].name, source_code: sourceCode } }); } catch (fetchError) { clearTimeout(timeoutId); throw fetchError; } } throw new Error('未知的API路径'); } catch (error) { console.error('API处理错误:', error); return JSON.stringify({ code: 400, msg: error.message || '请求处理失败', list: [], episodes: [], }); } } // 处理自定义API的特殊详情页 async function handleCustomApiSpecialDetail(id, customApi) { try { // 构建详情页URL const detailUrl = `${customApi}/index.php/vod/detail/id/${id}.html`; // 添加超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) : PROXY_URL + encodeURIComponent(detailUrl); // 获取详情页HTML const response = await fetch(proxiedUrl, { headers: { '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', }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`自定义API详情页请求失败: ${response.status}`); } // 获取HTML内容 const html = await response.text(); // 使用通用模式提取m3u8链接 const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; let matches = html.match(generalPattern) || []; // 处理链接 matches = matches.map(link => { link = link.substring(1, link.length); const parenIndex = link.indexOf('('); return parenIndex > 0 ? link.substring(0, parenIndex) : link; }); // 提取基本信息 const titleMatch = html.match(/]*>([^<]+)<\/h1>/); const titleText = titleMatch ? titleMatch[1].trim() : ''; const descMatch = html.match(/]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/); const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : ''; return JSON.stringify({ code: 200, episodes: matches, detailUrl: detailUrl, videoInfo: { title: titleText, desc: descText, source_name: '自定义源', source_code: 'custom' } }); } catch (error) { console.error(`自定义API详情获取失败:`, error); throw error; } } // 通用特殊源详情处理函数 async function handleSpecialSourceDetail(id, sourceCode) { try { // 构建详情页URL(使用配置中的detail URL而不是api URL) const detailUrl = `${API_SITES[sourceCode].detail}/index.php/vod/detail/id/${id}.html`; // 添加超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(detailUrl)) : PROXY_URL + encodeURIComponent(detailUrl); // 获取详情页HTML const response = await fetch(proxiedUrl, { headers: { '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', }, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`详情页请求失败: ${response.status}`); } // 获取HTML内容 const html = await response.text(); // 根据不同源类型使用不同的正则表达式 let matches = []; if (sourceCode === 'ffzy') { // 非凡影视使用特定的正则表达式 const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g; matches = html.match(ffzyPattern) || []; } // 如果没有找到链接或者是其他源类型,尝试一个更通用的模式 if (matches.length === 0) { const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g; matches = html.match(generalPattern) || []; } // 去重处理,避免一个播放源多集显示 matches = [...new Set(matches)]; // 处理链接 matches = matches.map(link => { link = link.substring(1, link.length); const parenIndex = link.indexOf('('); return parenIndex > 0 ? link.substring(0, parenIndex) : link; }); // 提取可能存在的标题、简介等基本信息 const titleMatch = html.match(/]*>([^<]+)<\/h1>/); const titleText = titleMatch ? titleMatch[1].trim() : ''; const descMatch = html.match(/]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/); const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : ''; return JSON.stringify({ code: 200, episodes: matches, detailUrl: detailUrl, videoInfo: { title: titleText, desc: descText, source_name: API_SITES[sourceCode].name, source_code: sourceCode } }); } catch (error) { console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error); throw error; } } // 处理聚合搜索 async function handleAggregatedSearch(searchQuery) { // 获取可用的API源列表(排除aggregated和custom) const availableSources = Object.keys(API_SITES).filter(key => key !== 'aggregated' && key !== 'custom' ); if (availableSources.length === 0) { throw new Error('没有可用的API源'); } // 创建所有API源的搜索请求 const searchPromises = availableSources.map(async (source) => { try { const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; // 使用Promise.race添加超时处理 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000) ); // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) : PROXY_URL + encodeURIComponent(apiUrl); const fetchPromise = fetch(proxiedUrl, { headers: API_CONFIG.search.headers }); const response = await Promise.race([fetchPromise, timeoutPromise]); if (!response.ok) { throw new Error(`${source}源请求失败: ${response.status}`); } const data = await response.json(); if (!data || !Array.isArray(data.list)) { throw new Error(`${source}源返回的数据格式无效`); } // 为搜索结果添加源信息 const results = data.list.map(item => ({ ...item, source_name: API_SITES[source].name, source_code: source })); return results; } catch (error) { console.warn(`${source}源搜索失败:`, error); return []; // 返回空数组表示该源搜索失败 } }); try { // 并行执行所有搜索请求 const resultsArray = await Promise.all(searchPromises); // 合并所有结果 let allResults = []; resultsArray.forEach(results => { if (Array.isArray(results) && results.length > 0) { allResults = allResults.concat(results); } }); // 如果没有搜索结果,返回空结果 if (allResults.length === 0) { return JSON.stringify({ code: 200, list: [], msg: '所有源均无搜索结果' }); } // 去重(根据vod_id和source_code组合) const uniqueResults = []; const seen = new Set(); allResults.forEach(item => { const key = `${item.source_code}_${item.vod_id}`; if (!seen.has(key)) { seen.add(key); uniqueResults.push(item); } }); // 按照视频名称和来源排序 uniqueResults.sort((a, b) => { // 首先按照视频名称排序 const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || ''); if (nameCompare !== 0) return nameCompare; // 如果名称相同,则按照来源排序 return (a.source_name || '').localeCompare(b.source_name || ''); }); return JSON.stringify({ code: 200, list: uniqueResults, }); } catch (error) { console.error('聚合搜索处理错误:', error); return JSON.stringify({ code: 400, msg: '聚合搜索处理失败: ' + error.message, list: [] }); } } // 处理多个自定义API源的聚合搜索 async function handleMultipleCustomSearch(searchQuery, customApiUrls) { // 解析自定义API列表 const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator) .map(url => url.trim()) .filter(url => url.length > 0 && /^https?:\/\//.test(url)) .slice(0, CUSTOM_API_CONFIG.maxSources); if (apiUrls.length === 0) { throw new Error('没有提供有效的自定义API地址'); } // 为每个API创建搜索请求 const searchPromises = apiUrls.map(async (apiUrl, index) => { try { const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`; // 使用Promise.race添加超时处理 const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000) ); // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(fullUrl)) : PROXY_URL + encodeURIComponent(fullUrl); const fetchPromise = fetch(proxiedUrl, { headers: API_CONFIG.search.headers }); const response = await Promise.race([fetchPromise, timeoutPromise]); if (!response.ok) { throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`); } const data = await response.json(); if (!data || !Array.isArray(data.list)) { throw new Error(`自定义API ${index+1} 返回的数据格式无效`); } // 为搜索结果添加源信息 const results = data.list.map(item => ({ ...item, source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`, source_code: 'custom', api_url: apiUrl // 保存API URL以便详情获取 })); return results; } catch (error) { console.warn(`自定义API ${index+1} 搜索失败:`, error); return []; // 返回空数组表示该源搜索失败 } }); try { // 并行执行所有搜索请求 const resultsArray = await Promise.all(searchPromises); // 合并所有结果 let allResults = []; resultsArray.forEach(results => { if (Array.isArray(results) && results.length > 0) { allResults = allResults.concat(results); } }); // 如果没有搜索结果,返回空结果 if (allResults.length === 0) { return JSON.stringify({ code: 200, list: [], msg: '所有自定义API源均无搜索结果' }); } // 去重(根据vod_id和api_url组合) const uniqueResults = []; const seen = new Set(); allResults.forEach(item => { const key = `${item.api_url || ''}_${item.vod_id}`; if (!seen.has(key)) { seen.add(key); uniqueResults.push(item); } }); return JSON.stringify({ code: 200, list: uniqueResults, }); } catch (error) { console.error('自定义API聚合搜索处理错误:', error); return JSON.stringify({ code: 400, msg: '自定义API聚合搜索处理失败: ' + error.message, list: [] }); } } // 拦截API请求 (function() { const originalFetch = window.fetch; window.fetch = async function(input, init) { const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url; if (requestUrl.pathname.startsWith('/api/')) { if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { return; } } try { const data = await handleApiRequest(requestUrl); return new Response(data, { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', }, }); } catch (error) { return new Response(JSON.stringify({ code: 500, msg: '服务器内部错误', }), { status: 500, headers: { 'Content-Type': 'application/json', }, }); } } // 非API请求使用原始fetch return originalFetch.apply(this, arguments); }; })(); async function testSiteAvailability(apiUrl) { try { // 使用更简单的测试查询 const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), { // 添加超时 signal: AbortSignal.timeout(5000) }); // 检查响应状态 if (!response.ok) { return false; } const data = await response.json(); // 检查API响应的有效性 return data && data.code !== 400 && Array.isArray(data.list); } catch (error) { console.error('站点可用性测试失败:', error); return false; } } ================================================ FILE: js/app.js ================================================ // 全局变量 let selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '["tyyszy","dyttzy", "bfzy", "ruyi"]'); // 默认选中资源 let customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表 // 添加当前播放的集数索引 let currentEpisodeIndex = 0; // 添加当前视频的所有集数 let currentEpisodes = []; // 添加当前视频的标题 let currentVideoTitle = ''; // 全局变量用于倒序状态 let episodesReversed = false; // 页面初始化 document.addEventListener('DOMContentLoaded', function () { // 初始化API复选框 initAPICheckboxes(); // 初始化自定义API列表 renderCustomAPIsList(); // 初始化显示选中的API数量 updateSelectedApiCount(); // 渲染搜索历史 renderSearchHistory(); // 设置默认API选择(如果是第一次加载) if (!localStorage.getItem('hasInitializedDefaults')) { // 默认选中资源 selectedAPIs = ["tyyszy", "bfzy", "dyttzy", "ruyi"]; localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); // 默认选中过滤开关 localStorage.setItem('yellowFilterEnabled', 'true'); localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, 'true'); // 默认启用豆瓣功能 localStorage.setItem('doubanEnabled', 'true'); // 标记已初始化默认值 localStorage.setItem('hasInitializedDefaults', 'true'); } // 设置黄色内容过滤器开关初始状态 const yellowFilterToggle = document.getElementById('yellowFilterToggle'); if (yellowFilterToggle) { yellowFilterToggle.checked = localStorage.getItem('yellowFilterEnabled') === 'true'; } // 设置广告过滤开关初始状态 const adFilterToggle = document.getElementById('adFilterToggle'); if (adFilterToggle) { adFilterToggle.checked = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true } // 设置事件监听器 setupEventListeners(); // 初始检查成人API选中状态 setTimeout(checkAdultAPIsSelected, 100); }); // 初始化API复选框 function initAPICheckboxes() { const container = document.getElementById('apiCheckboxes'); container.innerHTML = ''; // 添加普通API组标题 const normaldiv = document.createElement('div'); normaldiv.id = 'normaldiv'; normaldiv.className = 'grid grid-cols-2 gap-2'; const normalTitle = document.createElement('div'); normalTitle.className = 'api-group-title'; normalTitle.textContent = '普通资源'; normaldiv.appendChild(normalTitle); // 创建普通API源的复选框 Object.keys(API_SITES).forEach(apiKey => { const api = API_SITES[apiKey]; if (api.adult) return; // 跳过成人内容API,稍后添加 const checked = selectedAPIs.includes(apiKey); const checkbox = document.createElement('div'); checkbox.className = 'flex items-center'; checkbox.innerHTML = ` `; normaldiv.appendChild(checkbox); // 添加事件监听器 checkbox.querySelector('input').addEventListener('change', function () { updateSelectedAPIs(); checkAdultAPIsSelected(); }); }); container.appendChild(normaldiv); // 添加成人API列表 addAdultAPI(); // 初始检查成人内容状态 checkAdultAPIsSelected(); } // 添加成人API列表 function addAdultAPI() { // 仅在隐藏设置为false时添加成人API组 if (!HIDE_BUILTIN_ADULT_APIS && (localStorage.getItem('yellowFilterEnabled') === 'false')) { const container = document.getElementById('apiCheckboxes'); // 添加成人API组标题 const adultdiv = document.createElement('div'); adultdiv.id = 'adultdiv'; adultdiv.className = 'grid grid-cols-2 gap-2'; const adultTitle = document.createElement('div'); adultTitle.className = 'api-group-title adult'; adultTitle.innerHTML = `黄色资源采集站 `; adultdiv.appendChild(adultTitle); // 创建成人API源的复选框 Object.keys(API_SITES).forEach(apiKey => { const api = API_SITES[apiKey]; if (!api.adult) return; // 仅添加成人内容API const checked = selectedAPIs.includes(apiKey); const checkbox = document.createElement('div'); checkbox.className = 'flex items-center'; checkbox.innerHTML = ` `; adultdiv.appendChild(checkbox); // 添加事件监听器 checkbox.querySelector('input').addEventListener('change', function () { updateSelectedAPIs(); checkAdultAPIsSelected(); }); }); container.appendChild(adultdiv); } } // 检查是否有成人API被选中 function checkAdultAPIsSelected() { // 查找所有内置成人API复选框 const adultBuiltinCheckboxes = document.querySelectorAll('#apiCheckboxes .api-adult:checked'); // 查找所有自定义成人API复选框 const customApiCheckboxes = document.querySelectorAll('#customApisList .api-adult:checked'); const hasAdultSelected = adultBuiltinCheckboxes.length > 0 || customApiCheckboxes.length > 0; const yellowFilterToggle = document.getElementById('yellowFilterToggle'); const yellowFilterContainer = yellowFilterToggle.closest('div').parentNode; const filterDescription = yellowFilterContainer.querySelector('p.filter-description'); // 如果选择了成人API,禁用黄色内容过滤器 if (hasAdultSelected) { yellowFilterToggle.checked = false; yellowFilterToggle.disabled = true; localStorage.setItem('yellowFilterEnabled', 'false'); // 添加禁用样式 yellowFilterContainer.classList.add('filter-disabled'); // 修改描述文字 if (filterDescription) { filterDescription.innerHTML = '选中黄色资源站时无法启用此过滤'; } // 移除提示信息(如果存在) const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip'); if (existingTooltip) { existingTooltip.remove(); } } else { // 启用黄色内容过滤器 yellowFilterToggle.disabled = false; yellowFilterContainer.classList.remove('filter-disabled'); // 恢复原来的描述文字 if (filterDescription) { filterDescription.innerHTML = '过滤"伦理片"等黄色内容'; } // 移除提示信息 const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip'); if (existingTooltip) { existingTooltip.remove(); } } } // 渲染自定义API列表 function renderCustomAPIsList() { const container = document.getElementById('customApisList'); if (!container) return; if (customAPIs.length === 0) { container.innerHTML = '

未添加自定义API

'; return; } container.innerHTML = ''; customAPIs.forEach((api, index) => { const apiItem = document.createElement('div'); apiItem.className = 'flex items-center justify-between p-1 mb-1 bg-[#222] rounded'; const textColorClass = api.isAdult ? 'text-pink-400' : 'text-white'; const adultTag = api.isAdult ? '(18+)' : ''; // 新增 detail 地址显示 const detailLine = api.detail ? `
detail: ${api.detail}
` : ''; apiItem.innerHTML = `
${adultTag}${api.name}
${api.url}
${detailLine}
`; container.appendChild(apiItem); apiItem.querySelector('input').addEventListener('change', function () { updateSelectedAPIs(); checkAdultAPIsSelected(); }); }); } // 编辑自定义API function editCustomApi(index) { if (index < 0 || index >= customAPIs.length) return; const api = customAPIs[index]; document.getElementById('customApiName').value = api.name; document.getElementById('customApiUrl').value = api.url; document.getElementById('customApiDetail').value = api.detail || ''; const isAdultInput = document.getElementById('customApiIsAdult'); if (isAdultInput) isAdultInput.checked = api.isAdult || false; const form = document.getElementById('addCustomApiForm'); if (form) { form.classList.remove('hidden'); const buttonContainer = form.querySelector('div:last-child'); buttonContainer.innerHTML = ` `; } } // 更新自定义API function updateCustomApi(index) { if (index < 0 || index >= customAPIs.length) return; const nameInput = document.getElementById('customApiName'); const urlInput = document.getElementById('customApiUrl'); const detailInput = document.getElementById('customApiDetail'); const isAdultInput = document.getElementById('customApiIsAdult'); const name = nameInput.value.trim(); let url = urlInput.value.trim(); const detail = detailInput ? detailInput.value.trim() : ''; const isAdult = isAdultInput ? isAdultInput.checked : false; if (!name || !url) { showToast('请输入API名称和链接', 'warning'); return; } if (!/^https?:\/\/.+/.test(url)) { showToast('API链接格式不正确,需以http://或https://开头', 'warning'); return; } if (url.endsWith('/')) url = url.slice(0, -1); // 保存 detail 字段 customAPIs[index] = { name, url, detail, isAdult }; localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); renderCustomAPIsList(); checkAdultAPIsSelected(); restoreAddCustomApiButtons(); nameInput.value = ''; urlInput.value = ''; if (detailInput) detailInput.value = ''; if (isAdultInput) isAdultInput.checked = false; document.getElementById('addCustomApiForm').classList.add('hidden'); showToast('已更新自定义API: ' + name, 'success'); } // 取消编辑自定义API function cancelEditCustomApi() { // 清空表单 document.getElementById('customApiName').value = ''; document.getElementById('customApiUrl').value = ''; document.getElementById('customApiDetail').value = ''; const isAdultInput = document.getElementById('customApiIsAdult'); if (isAdultInput) isAdultInput.checked = false; // 隐藏表单 document.getElementById('addCustomApiForm').classList.add('hidden'); // 恢复添加按钮 restoreAddCustomApiButtons(); } // 恢复自定义API添加按钮 function restoreAddCustomApiButtons() { const form = document.getElementById('addCustomApiForm'); const buttonContainer = form.querySelector('div:last-child'); buttonContainer.innerHTML = ` `; } // 更新选中的API列表 function updateSelectedAPIs() { // 获取所有内置API复选框 const builtInApiCheckboxes = document.querySelectorAll('#apiCheckboxes input:checked'); // 获取选中的内置API const builtInApis = Array.from(builtInApiCheckboxes).map(input => input.dataset.api); // 获取选中的自定义API const customApiCheckboxes = document.querySelectorAll('#customApisList input:checked'); const customApiIndices = Array.from(customApiCheckboxes).map(input => 'custom_' + input.dataset.customIndex); // 合并内置和自定义API selectedAPIs = [...builtInApis, ...customApiIndices]; // 保存到localStorage localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); // 更新显示选中的API数量 updateSelectedApiCount(); } // 更新选中的API数量显示 function updateSelectedApiCount() { const countEl = document.getElementById('selectedApiCount'); if (countEl) { countEl.textContent = selectedAPIs.length; } } // 全选或取消全选API function selectAllAPIs(selectAll = true, excludeAdult = false) { const checkboxes = document.querySelectorAll('#apiCheckboxes input[type="checkbox"]'); checkboxes.forEach(checkbox => { if (excludeAdult && checkbox.classList.contains('api-adult')) { checkbox.checked = false; } else { checkbox.checked = selectAll; } }); updateSelectedAPIs(); checkAdultAPIsSelected(); } // 显示添加自定义API表单 function showAddCustomApiForm() { const form = document.getElementById('addCustomApiForm'); if (form) { form.classList.remove('hidden'); } } // 取消添加自定义API - 修改函数来重用恢复按钮逻辑 function cancelAddCustomApi() { const form = document.getElementById('addCustomApiForm'); if (form) { form.classList.add('hidden'); document.getElementById('customApiName').value = ''; document.getElementById('customApiUrl').value = ''; document.getElementById('customApiDetail').value = ''; const isAdultInput = document.getElementById('customApiIsAdult'); if (isAdultInput) isAdultInput.checked = false; // 确保按钮是添加按钮 restoreAddCustomApiButtons(); } } // 添加自定义API function addCustomApi() { const nameInput = document.getElementById('customApiName'); const urlInput = document.getElementById('customApiUrl'); const detailInput = document.getElementById('customApiDetail'); const isAdultInput = document.getElementById('customApiIsAdult'); const name = nameInput.value.trim(); let url = urlInput.value.trim(); const detail = detailInput ? detailInput.value.trim() : ''; const isAdult = isAdultInput ? isAdultInput.checked : false; if (!name || !url) { showToast('请输入API名称和链接', 'warning'); return; } if (!/^https?:\/\/.+/.test(url)) { showToast('API链接格式不正确,需以http://或https://开头', 'warning'); return; } if (url.endsWith('/')) { url = url.slice(0, -1); } // 保存 detail 字段 customAPIs.push({ name, url, detail, isAdult }); localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); const newApiIndex = customAPIs.length - 1; selectedAPIs.push('custom_' + newApiIndex); localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); // 重新渲染自定义API列表 renderCustomAPIsList(); updateSelectedApiCount(); checkAdultAPIsSelected(); nameInput.value = ''; urlInput.value = ''; if (detailInput) detailInput.value = ''; if (isAdultInput) isAdultInput.checked = false; document.getElementById('addCustomApiForm').classList.add('hidden'); showToast('已添加自定义API: ' + name, 'success'); } // 移除自定义API function removeCustomApi(index) { if (index < 0 || index >= customAPIs.length) return; const apiName = customAPIs[index].name; // 从列表中移除API customAPIs.splice(index, 1); localStorage.setItem('customAPIs', JSON.stringify(customAPIs)); // 从选中列表中移除此API const customApiId = 'custom_' + index; selectedAPIs = selectedAPIs.filter(id => id !== customApiId); // 更新大于此索引的自定义API索引 selectedAPIs = selectedAPIs.map(id => { if (id.startsWith('custom_')) { const currentIndex = parseInt(id.replace('custom_', '')); if (currentIndex > index) { return 'custom_' + (currentIndex - 1); } } return id; }); localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); // 重新渲染自定义API列表 renderCustomAPIsList(); // 更新选中的API数量 updateSelectedApiCount(); // 重新检查成人API选中状态 checkAdultAPIsSelected(); showToast('已移除自定义API: ' + apiName, 'info'); } function toggleSettings(e) { const settingsPanel = document.getElementById('settingsPanel'); if (!settingsPanel) return; if (settingsPanel.classList.contains('show')) { settingsPanel.classList.remove('show'); } else { settingsPanel.classList.add('show'); } if (e) { e.preventDefault(); e.stopPropagation(); } } // 设置事件监听器 function setupEventListeners() { // 回车搜索 document.getElementById('searchInput').addEventListener('keypress', function (e) { if (e.key === 'Enter') { search(); } }); // 点击外部关闭设置面板和历史记录面板 document.addEventListener('click', function (e) { // 关闭设置面板 const settingsPanel = document.querySelector('#settingsPanel.show'); const settingsButton = document.querySelector('#settingsPanel .close-btn'); if (settingsPanel && settingsButton && !settingsPanel.contains(e.target) && !settingsButton.contains(e.target)) { settingsPanel.classList.remove('show'); } // 关闭历史记录面板 const historyPanel = document.querySelector('#historyPanel.show'); const historyButton = document.querySelector('#historyPanel .close-btn'); if (historyPanel && historyButton && !historyPanel.contains(e.target) && !historyButton.contains(e.target)) { historyPanel.classList.remove('show'); } }); // 黄色内容过滤开关事件绑定 const yellowFilterToggle = document.getElementById('yellowFilterToggle'); if (yellowFilterToggle) { yellowFilterToggle.addEventListener('change', function (e) { localStorage.setItem('yellowFilterEnabled', e.target.checked); // 控制黄色内容接口的显示状态 const adultdiv = document.getElementById('adultdiv'); if (adultdiv) { if (e.target.checked === true) { adultdiv.style.display = 'none'; } else if (e.target.checked === false) { adultdiv.style.display = '' } } else { // 添加成人API列表 addAdultAPI(); } }); } // 广告过滤开关事件绑定 const adFilterToggle = document.getElementById('adFilterToggle'); if (adFilterToggle) { adFilterToggle.addEventListener('change', function (e) { localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, e.target.checked); }); } } // 重置搜索区域 function resetSearchArea() { // 清理搜索结果 document.getElementById('results').innerHTML = ''; document.getElementById('searchInput').value = ''; // 恢复搜索区域的样式 document.getElementById('searchArea').classList.add('flex-1'); document.getElementById('searchArea').classList.remove('mb-8'); document.getElementById('resultsArea').classList.add('hidden'); // 确保页脚正确显示,移除相对定位 const footer = document.querySelector('.footer'); if (footer) { footer.style.position = ''; } // 如果有豆瓣功能,检查是否需要显示豆瓣推荐区域 if (typeof updateDoubanVisibility === 'function') { updateDoubanVisibility(); } // 重置URL为主页 try { window.history.pushState( {}, `LibreTV - 免费在线视频搜索与观看平台`, `/` ); // 更新页面标题 document.title = `LibreTV - 免费在线视频搜索与观看平台`; } catch (e) { console.error('更新浏览器历史失败:', e); } } // 获取自定义API信息 function getCustomApiInfo(customApiIndex) { const index = parseInt(customApiIndex); if (isNaN(index) || index < 0 || index >= customAPIs.length) { return null; } return customAPIs[index]; } // 搜索功能 - 修改为支持多选API和多页结果 async function search() { // 强化的密码保护校验 - 防止绕过 try { if (window.ensurePasswordProtection) { window.ensurePasswordProtection(); } else { // 兼容性检查 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } } } catch (error) { console.warn('Password protection check failed:', error.message); return; } const query = document.getElementById('searchInput').value.trim(); if (!query) { showToast('请输入搜索内容', 'info'); return; } if (selectedAPIs.length === 0) { showToast('请至少选择一个API源', 'warning'); return; } showLoading(); try { // 保存搜索历史 saveSearchHistory(query); // 从所有选中的API源搜索 let allResults = []; const searchPromises = selectedAPIs.map(apiId => searchByAPIAndKeyWord(apiId, query) ); // 等待所有搜索请求完成 const resultsArray = await Promise.all(searchPromises); // 合并所有结果 resultsArray.forEach(results => { if (Array.isArray(results) && results.length > 0) { allResults = allResults.concat(results); } }); // 对搜索结果进行排序:按名称优先,名称相同时按接口源排序 allResults.sort((a, b) => { // 首先按照视频名称排序 const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || ''); if (nameCompare !== 0) return nameCompare; // 如果名称相同,则按照来源排序 return (a.source_name || '').localeCompare(b.source_name || ''); }); // 更新搜索结果计数 const searchResultsCount = document.getElementById('searchResultsCount'); if (searchResultsCount) { searchResultsCount.textContent = allResults.length; } // 显示结果区域,调整搜索区域 document.getElementById('searchArea').classList.remove('flex-1'); document.getElementById('searchArea').classList.add('mb-8'); document.getElementById('resultsArea').classList.remove('hidden'); // 隐藏豆瓣推荐区域(如果存在) const doubanArea = document.getElementById('doubanArea'); if (doubanArea) { doubanArea.classList.add('hidden'); } const resultsDiv = document.getElementById('results'); // 如果没有结果 if (!allResults || allResults.length === 0) { resultsDiv.innerHTML = `

没有找到匹配的结果

请尝试其他关键词或更换数据源

`; hideLoading(); return; } // 有搜索结果时,才更新URL try { // 使用URI编码确保特殊字符能够正确显示 const encodedQuery = encodeURIComponent(query); // 使用HTML5 History API更新URL,不刷新页面 window.history.pushState( { search: query }, `搜索: ${query} - LibreTV`, `/s=${encodedQuery}` ); // 更新页面标题 document.title = `搜索: ${query} - LibreTV`; } catch (e) { console.error('更新浏览器历史失败:', e); // 如果更新URL失败,继续执行搜索 } // 处理搜索结果过滤:如果启用了黄色内容过滤,则过滤掉分类含有敏感内容的项目 const yellowFilterEnabled = localStorage.getItem('yellowFilterEnabled') === 'true'; if (yellowFilterEnabled) { const banned = ['伦理片', '福利', '里番动漫', '门事件', '萝莉少女', '制服诱惑', '国产传媒', 'cosplay', '黑丝诱惑', '无码', '日本无码', '有码', '日本有码', 'SWAG', '网红主播', '色情片', '同性片', '福利视频', '福利片']; allResults = allResults.filter(item => { const typeName = item.type_name || ''; return !banned.some(keyword => typeName.includes(keyword)); }); } // 添加XSS保护,使用textContent和属性转义 const safeResults = allResults.map(item => { const safeId = item.vod_id ? item.vod_id.toString().replace(/[^\w-]/g, '') : ''; const safeName = (item.vod_name || '').toString() .replace(//g, '>') .replace(/"/g, '"'); const sourceInfo = item.source_name ? `${item.source_name}` : ''; const sourceCode = item.source_code || ''; // 添加API URL属性,用于详情获取 const apiUrlAttr = item.api_url ? `data-api-url="${item.api_url.replace(/"/g, '"')}"` : ''; // 修改为水平卡片布局,图片在左侧,文本在右侧,并优化样式 const hasCover = item.vod_pic && item.vod_pic.startsWith('http'); return `
${hasCover ? `
${safeName}
` : ''}

${safeName}

${(item.type_name || '').toString().replace(/ ${(item.type_name || '').toString().replace(/` : ''} ${(item.vod_year || '') ? ` ${item.vod_year} ` : ''}

${(item.vod_remarks || '暂无介绍').toString().replace(/

${sourceInfo ? `
${sourceInfo}
` : '
'}
`; }).join(''); resultsDiv.innerHTML = safeResults; } catch (error) { console.error('搜索错误:', error); if (error.name === 'AbortError') { showToast('搜索请求超时,请检查网络连接', 'error'); } else { showToast('搜索请求失败,请稍后重试', 'error'); } } finally { hideLoading(); } } // 切换清空按钮的显示状态 function toggleClearButton() { const searchInput = document.getElementById('searchInput'); const clearButton = document.getElementById('clearSearchInput'); if (searchInput.value !== '') { clearButton.classList.remove('hidden'); } else { clearButton.classList.add('hidden'); } } // 清空搜索框内容 function clearSearchInput() { const searchInput = document.getElementById('searchInput'); searchInput.value = ''; const clearButton = document.getElementById('clearSearchInput'); clearButton.classList.add('hidden'); } // 劫持搜索框的value属性以检测外部修改 function hookInput() { const input = document.getElementById('searchInput'); const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); // 重写 value 属性的 getter 和 setter Object.defineProperty(input, 'value', { get: function () { // 确保读取时返回字符串(即使原始值为 undefined/null) const originalValue = descriptor.get.call(this); return originalValue != null ? String(originalValue) : ''; }, set: function (value) { // 显式将值转换为字符串后写入 const strValue = String(value); descriptor.set.call(this, strValue); this.dispatchEvent(new Event('input', { bubbles: true })); } }); // 初始化输入框值为空字符串(避免初始值为 undefined) input.value = ''; } document.addEventListener('DOMContentLoaded', hookInput); // 显示详情 - 修改为支持自定义API async function showDetails(id, vod_name, sourceCode) { // 密码保护校验 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } if (!id) { showToast('视频ID无效', 'error'); return; } showLoading(); try { // 构建API参数 let apiParams = ''; // 处理自定义API源 if (sourceCode.startsWith('custom_')) { const customIndex = sourceCode.replace('custom_', ''); const customApi = getCustomApiInfo(customIndex); if (!customApi) { showToast('自定义API配置无效', 'error'); hideLoading(); return; } // 传递 detail 字段 if (customApi.detail) { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom'; } else { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom'; } } else { // 内置API apiParams = '&source=' + sourceCode; } // Add a timestamp to prevent caching const timestamp = new Date().getTime(); const cacheBuster = `&_t=${timestamp}`; const response = await fetch(`/api/detail?id=${encodeURIComponent(id)}${apiParams}${cacheBuster}`); const data = await response.json(); const modal = document.getElementById('modal'); const modalTitle = document.getElementById('modalTitle'); const modalContent = document.getElementById('modalContent'); // 显示来源信息 const sourceName = data.videoInfo && data.videoInfo.source_name ? ` (${data.videoInfo.source_name})` : ''; // 不对标题进行截断处理,允许完整显示 modalTitle.innerHTML = `${vod_name || '未知视频'}${sourceName}`; currentVideoTitle = vod_name || '未知视频'; if (data.episodes && data.episodes.length > 0) { // 构建详情信息HTML let detailInfoHtml = ''; if (data.videoInfo) { // Prepare description text, strip HTML and trim whitespace const descriptionText = data.videoInfo.desc ? data.videoInfo.desc.replace(/<[^>]+>/g, '').trim() : ''; // Check if there's any actual grid content const hasGridContent = data.videoInfo.type || data.videoInfo.year || data.videoInfo.area || data.videoInfo.director || data.videoInfo.actor || data.videoInfo.remarks; if (hasGridContent || descriptionText) { // Only build if there's something to show detailInfoHtml = ` `; } } currentEpisodes = data.episodes; currentEpisodeIndex = 0; modalContent.innerHTML = ` ${detailInfoHtml}
共 ${data.episodes.length} 集
${renderEpisodes(vod_name, sourceCode, id)}
`; } else { modalContent.innerHTML = `
❌ 未找到播放资源
该视频可能暂时无法播放,请尝试其他视频
`; } modal.classList.remove('hidden'); } catch (error) { console.error('获取详情错误:', error); showToast('获取详情失败,请稍后重试', 'error'); } finally { hideLoading(); } } // 更新播放视频函数,修改为使用/watch路径而不是直接打开player.html function playVideo(url, vod_name, sourceCode, episodeIndex = 0, vodId = '') { // 密码保护校验 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } // 获取当前路径作为返回页面 let currentPath = window.location.href; // 构建播放页面URL,使用watch.html作为中间跳转页 let watchUrl = `watch.html?id=${vodId || ''}&source=${sourceCode || ''}&url=${encodeURIComponent(url)}&index=${episodeIndex}&title=${encodeURIComponent(vod_name || '')}`; // 添加返回URL参数 if (currentPath.includes('index.html') || currentPath.endsWith('/')) { watchUrl += `&back=${encodeURIComponent(currentPath)}`; } // 保存当前状态到localStorage try { localStorage.setItem('currentVideoTitle', vod_name || '未知视频'); localStorage.setItem('currentEpisodes', JSON.stringify(currentEpisodes)); localStorage.setItem('currentEpisodeIndex', episodeIndex); localStorage.setItem('currentSourceCode', sourceCode || ''); localStorage.setItem('lastPlayTime', Date.now()); localStorage.setItem('lastSearchPage', currentPath); localStorage.setItem('lastPageUrl', currentPath); // 确保保存返回页面URL } catch (e) { console.error('保存播放状态失败:', e); } // 在当前标签页中打开播放页面 window.location.href = watchUrl; } // 弹出播放器页面 function showVideoPlayer(url) { // 在打开播放器前,隐藏详情弹窗 const detailModal = document.getElementById('modal'); if (detailModal) { detailModal.classList.add('hidden'); } // 临时隐藏搜索结果和豆瓣区域,防止高度超出播放器而出现滚动条 document.getElementById('resultsArea').classList.add('hidden'); document.getElementById('doubanArea').classList.add('hidden'); // 在框架中打开播放页面 videoPlayerFrame = document.createElement('iframe'); videoPlayerFrame.id = 'VideoPlayerFrame'; videoPlayerFrame.className = 'fixed w-full h-screen z-40'; videoPlayerFrame.src = url; document.body.appendChild(videoPlayerFrame); // 将焦点移入iframe videoPlayerFrame.focus(); } // 关闭播放器页面 function closeVideoPlayer(home = false) { videoPlayerFrame = document.getElementById('VideoPlayerFrame'); if (videoPlayerFrame) { videoPlayerFrame.remove(); // 恢复搜索结果显示 document.getElementById('resultsArea').classList.remove('hidden'); // 关闭播放器时也隐藏详情弹窗 const detailModal = document.getElementById('modal'); if (detailModal) { detailModal.classList.add('hidden'); } // 如果启用豆瓣区域则显示豆瓣区域 if (localStorage.getItem('doubanEnabled') === 'true') { document.getElementById('doubanArea').classList.remove('hidden'); } } if (home) { // 刷新主页 window.location.href = '/' } } // 播放上一集 function playPreviousEpisode(sourceCode) { if (currentEpisodeIndex > 0) { const prevIndex = currentEpisodeIndex - 1; const prevUrl = currentEpisodes[prevIndex]; playVideo(prevUrl, currentVideoTitle, sourceCode, prevIndex); } } // 播放下一集 function playNextEpisode(sourceCode) { if (currentEpisodeIndex < currentEpisodes.length - 1) { const nextIndex = currentEpisodeIndex + 1; const nextUrl = currentEpisodes[nextIndex]; playVideo(nextUrl, currentVideoTitle, sourceCode, nextIndex); } } // 处理播放器加载错误 function handlePlayerError() { hideLoading(); showToast('视频播放加载失败,请尝试其他视频源', 'error'); } // 辅助函数用于渲染剧集按钮(使用当前的排序状态) function renderEpisodes(vodName, sourceCode, vodId) { const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes; return episodes.map((episode, index) => { // 根据倒序状态计算真实的剧集索引 const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index; return ` `; }).join(''); } // 复制视频链接到剪贴板 function copyLinks() { const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes; const linkList = episodes.join('\r\n'); navigator.clipboard.writeText(linkList).then(() => { showToast('播放链接已复制', 'success'); }).catch(err => { showToast('复制失败,请检查浏览器权限', 'error'); }); } // 切换排序状态的函数 function toggleEpisodeOrder(sourceCode, vodId) { episodesReversed = !episodesReversed; // 重新渲染剧集区域,使用 currentVideoTitle 作为视频标题 const episodesGrid = document.getElementById('episodesGrid'); if (episodesGrid) { episodesGrid.innerHTML = renderEpisodes(currentVideoTitle, sourceCode, vodId); } // 更新按钮文本和箭头方向 const toggleBtn = document.querySelector(`button[onclick="toggleEpisodeOrder('${sourceCode}', '${vodId}')"]`); if (toggleBtn) { toggleBtn.querySelector('span').textContent = episodesReversed ? '正序排列' : '倒序排列'; const arrowIcon = toggleBtn.querySelector('svg'); if (arrowIcon) { arrowIcon.style.transform = episodesReversed ? 'rotate(180deg)' : 'rotate(0deg)'; } } } // 从URL导入配置 async function importConfigFromUrl() { // 创建模态框元素 let modal = document.getElementById('importUrlModal'); if (modal) { document.body.removeChild(modal); } modal = document.createElement('div'); modal.id = 'importUrlModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40'; modal.innerHTML = `

从URL导入配置

`; document.body.appendChild(modal); // 关闭按钮事件 document.getElementById('closeUrlModal').addEventListener('click', () => { document.body.removeChild(modal); }); // 取消按钮事件 document.getElementById('cancelUrlImport').addEventListener('click', () => { document.body.removeChild(modal); }); // 确认导入按钮事件 document.getElementById('confirmUrlImport').addEventListener('click', async () => { const url = document.getElementById('configUrl').value.trim(); if (!url) { showToast('请输入配置文件URL', 'warning'); return; } // 验证URL格式 try { const urlObj = new URL(url); if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') { showToast('URL必须以http://或https://开头', 'warning'); return; } } catch (e) { showToast('URL格式不正确', 'warning'); return; } showLoading('正在从URL导入配置...'); try { // 获取配置文件 - 直接请求URL const response = await fetch(url, { mode: 'cors', headers: { 'Accept': 'application/json' } }); if (!response.ok) throw '获取配置文件失败'; // 验证响应内容类型 const contentType = response.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) { throw '响应不是有效的JSON格式'; } const config = await response.json(); if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确'; // 验证哈希 const dataHash = await sha256(JSON.stringify(config.data)); if (dataHash !== config.hash) throw '配置文件哈希值不匹配'; // 导入配置 for (let item in config.data) { localStorage.setItem(item, config.data[item]); } showToast('配置文件导入成功,3 秒后自动刷新本页面。', 'success'); setTimeout(() => { window.location.reload(); }, 3000); } catch (error) { const message = typeof error === 'string' ? error : '导入配置失败'; showToast(`从URL导入配置出错 (${message})`, 'error'); } finally { hideLoading(); document.body.removeChild(modal); } }); // 点击模态框外部关闭 modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); } }); } // 配置文件导入功能 async function importConfig() { showImportBox(async (file) => { try { // 检查文件类型 if (!(file.type === 'application/json' || file.name.endsWith('.json'))) throw '文件类型不正确'; // 检查文件大小 if (file.size > 1024 * 1024 * 10) throw new Error('文件大小超过 10MB'); // 读取文件内容 const content = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject('文件读取失败'); reader.readAsText(file); }); // 解析并验证配置 const config = JSON.parse(content); if (config.name !== 'LibreTV-Settings') throw '配置文件格式不正确'; // 验证哈希 const dataHash = await sha256(JSON.stringify(config.data)); if (dataHash !== config.hash) throw '配置文件哈希值不匹配'; // 导入配置 for (let item in config.data) { localStorage.setItem(item, config.data[item]); } showToast('配置文件导入成功,3 秒后自动刷新本页面。', 'success'); setTimeout(() => { window.location.reload(); }, 3000); } catch (error) { const message = typeof error === 'string' ? error : '配置文件格式错误'; showToast(`配置文件读取出错 (${message})`, 'error'); } }); } // 配置文件导出功能 async function exportConfig() { // 存储配置数据 const config = {}; const items = {}; const settingsToExport = [ 'selectedAPIs', 'customAPIs', 'yellowFilterEnabled', 'adFilteringEnabled', 'doubanEnabled', 'hasInitializedDefaults' ]; // 导出设置项 settingsToExport.forEach(key => { const value = localStorage.getItem(key); if (value !== null) { items[key] = value; } }); // 导出历史记录 const viewingHistory = localStorage.getItem('viewingHistory'); if (viewingHistory) { items['viewingHistory'] = viewingHistory; } const searchHistory = localStorage.getItem(SEARCH_HISTORY_KEY); if (searchHistory) { items[SEARCH_HISTORY_KEY] = searchHistory; } const times = Date.now().toString(); config['name'] = 'LibreTV-Settings'; // 配置文件名,用于校验 config['time'] = times; // 配置文件生成时间 config['cfgVer'] = '1.0.0'; // 配置文件版本 config['data'] = items; // 配置文件数据 config['hash'] = await sha256(JSON.stringify(config['data'])); // 计算数据的哈希值,用于校验 // 将配置数据保存为 JSON 文件 saveStringAsFile(JSON.stringify(config), 'LibreTV-Settings_' + times + '.json'); } // 将字符串保存为文件 function saveStringAsFile(content, fileName) { // 创建Blob对象并指定类型 const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); // 生成临时URL const url = window.URL.createObjectURL(blob); // 创建标签并触发下载 const a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); // 清理临时对象 document.body.removeChild(a); window.URL.revokeObjectURL(url); } // 移除Node.js的require语句,因为这是在浏览器环境中运行的 ================================================ FILE: js/config.js ================================================ // 全局常量配置 const PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写) // const HOPLAYER_URL = 'https://hoplayer.com/index.html'; const SEARCH_HISTORY_KEY = 'videoSearchHistory'; const MAX_HISTORY_ITEMS = 5; // 密码保护配置 // 注意:PASSWORD 环境变量是必需的,所有部署都必须设置密码以确保安全 const PASSWORD_CONFIG = { localStorageKey: 'passwordVerified', // 存储验证状态的键名 verificationTTL: 90 * 24 * 60 * 60 * 1000 // 验证有效期(90天,约3个月) }; // 网站信息配置 const SITE_CONFIG = { name: 'LibreTV', url: 'https://libretv.is-an.org', description: '免费在线视频搜索与观看平台', logo: 'image/logo.png', version: '1.0.3' }; // API站点配置 const API_SITES = { testSource: { api: 'https://www.example.com/api.php/provide/vod', name: '空内容测试源', adult: true } //ARCHIVE https://telegra.ph/APIs-08-12 }; // 定义合并方法 function extendAPISites(newSites) { Object.assign(API_SITES, newSites); } // 暴露到全局 window.API_SITES = API_SITES; window.extendAPISites = extendAPISites; // 添加聚合搜索的配置选项 const AGGREGATED_SEARCH_CONFIG = { enabled: true, // 是否启用聚合搜索 timeout: 8000, // 单个源超时时间(毫秒) maxResults: 10000, // 最大结果数量 parallelRequests: true, // 是否并行请求所有源 showSourceBadges: true // 是否显示来源徽章 }; // 抽象API请求配置 const API_CONFIG = { search: { // 只拼接参数部分,不再包含 /api.php/provide/vod/ path: '?ac=videolist&wd=', pagePath: '?ac=videolist&wd={query}&pg={page}', maxPages: 50, // 最大获取页数 headers: { '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', 'Accept': 'application/json' } }, detail: { // 只拼接参数部分 path: '?ac=videolist&ids=', headers: { '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', 'Accept': 'application/json' } } }; // 优化后的正则表达式模式 const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g; // 添加自定义播放器URL const CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html // 增加视频播放相关配置 const PLAYER_CONFIG = { autoplay: true, allowFullscreen: true, width: '100%', height: '600', timeout: 15000, // 播放器加载超时时间 filterAds: true, // 是否启用广告过滤 autoPlayNext: true, // 默认启用自动连播功能 adFilteringEnabled: true, // 默认开启分片广告过滤 adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名 }; // 增加错误信息本地化 const ERROR_MESSAGES = { NETWORK_ERROR: '网络连接错误,请检查网络设置', TIMEOUT_ERROR: '请求超时,服务器响应时间过长', API_ERROR: 'API接口返回错误,请尝试更换数据源', PLAYER_ERROR: '播放器加载失败,请尝试其他视频源', UNKNOWN_ERROR: '发生未知错误,请刷新页面重试' }; // 添加进一步安全设置 const SECURITY_CONFIG = { enableXSSProtection: true, // 是否启用XSS保护 sanitizeUrls: true, // 是否清理URL maxQueryLength: 100, // 最大搜索长度 // allowedApiDomains 不再需要,因为所有请求都通过内部代理 }; // 添加多个自定义API源的配置 const CUSTOM_API_CONFIG = { separator: ',', // 分隔符 maxSources: 5, // 最大允许的自定义源数量 testTimeout: 5000, // 测试超时时间(毫秒) namePrefix: 'Custom-', // 自定义源名称前缀 validateUrl: true, // 验证URL格式 cacheResults: true, // 缓存测试结果 cacheExpiry: 5184000000, // 缓存过期时间(2个月) adultPropName: 'isAdult' // 用于标记成人内容的属性名 }; // 隐藏内置黄色采集站API的变量 const HIDE_BUILTIN_ADULT_APIS = false; ================================================ FILE: js/customer_site.js ================================================ const CUSTOMER_SITES = { qiqi: { api: 'https://www.qiqidys.com/api.php/provide/vod', name: '七七资源', } }; // 调用全局方法合并 if (window.extendAPISites) { window.extendAPISites(CUSTOMER_SITES); } else { console.error("错误:请先加载 config.js!"); } ================================================ FILE: js/douban.js ================================================ // 豆瓣热门电影电视剧推荐功能 // 豆瓣标签列表 - 修改为默认标签 let defaultMovieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '日综', '爱情', '科幻', '悬疑', '恐怖', '治愈']; let defaultTvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片']; // 用户标签列表 - 存储用户实际使用的标签(包含保留的系统标签和用户添加的自定义标签) let movieTags = []; let tvTags = []; // 加载用户标签 function loadUserTags() { try { // 尝试从本地存储加载用户保存的标签 const savedMovieTags = localStorage.getItem('userMovieTags'); const savedTvTags = localStorage.getItem('userTvTags'); // 如果本地存储中有标签数据,则使用它 if (savedMovieTags) { movieTags = JSON.parse(savedMovieTags); } else { // 否则使用默认标签 movieTags = [...defaultMovieTags]; } if (savedTvTags) { tvTags = JSON.parse(savedTvTags); } else { // 否则使用默认标签 tvTags = [...defaultTvTags]; } } catch (e) { console.error('加载标签失败:', e); // 初始化为默认值,防止错误 movieTags = [...defaultMovieTags]; tvTags = [...defaultTvTags]; } } // 保存用户标签 function saveUserTags() { try { localStorage.setItem('userMovieTags', JSON.stringify(movieTags)); localStorage.setItem('userTvTags', JSON.stringify(tvTags)); } catch (e) { console.error('保存标签失败:', e); showToast('保存标签失败', 'error'); } } let doubanMovieTvCurrentSwitch = 'movie'; let doubanCurrentTag = '热门'; let doubanPageStart = 0; const doubanPageSize = 16; // 一次显示的项目数量 // 初始化豆瓣功能 function initDouban() { // 设置豆瓣开关的初始状态 const doubanToggle = document.getElementById('doubanToggle'); if (doubanToggle) { const isEnabled = localStorage.getItem('doubanEnabled') === 'true'; doubanToggle.checked = isEnabled; // 设置开关外观 const toggleBg = doubanToggle.nextElementSibling; const toggleDot = toggleBg.nextElementSibling; if (isEnabled) { toggleBg.classList.add('bg-pink-600'); toggleDot.classList.add('translate-x-6'); } // 添加事件监听 doubanToggle.addEventListener('change', function(e) { const isChecked = e.target.checked; localStorage.setItem('doubanEnabled', isChecked); // 更新开关外观 if (isChecked) { toggleBg.classList.add('bg-pink-600'); toggleDot.classList.add('translate-x-6'); } else { toggleBg.classList.remove('bg-pink-600'); toggleDot.classList.remove('translate-x-6'); } // 更新显示状态 updateDoubanVisibility(); }); // 初始更新显示状态 updateDoubanVisibility(); // 滚动到页面顶部 window.scrollTo(0, 0); } // 加载用户标签 loadUserTags(); // 渲染电影/电视剧切换 renderDoubanMovieTvSwitch(); // 渲染豆瓣标签 renderDoubanTags(); // 换一批按钮事件监听 setupDoubanRefreshBtn(); // 初始加载热门内容 if (localStorage.getItem('doubanEnabled') === 'true') { renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); } } // 根据设置更新豆瓣区域的显示状态 function updateDoubanVisibility() { const doubanArea = document.getElementById('doubanArea'); if (!doubanArea) return; const isEnabled = localStorage.getItem('doubanEnabled') === 'true'; const isSearching = document.getElementById('resultsArea') && !document.getElementById('resultsArea').classList.contains('hidden'); // 只有在启用且没有搜索结果显示时才显示豆瓣区域 if (isEnabled && !isSearching) { doubanArea.classList.remove('hidden'); // 如果豆瓣结果为空,重新加载 if (document.getElementById('douban-results').children.length === 0) { renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); } } else { doubanArea.classList.add('hidden'); } } // 只填充搜索框,不执行搜索,让用户自主决定搜索时机 function fillSearchInput(title) { if (!title) return; // 安全处理标题,防止XSS const safeTitle = title .replace(//g, '>') .replace(/"/g, '"'); const input = document.getElementById('searchInput'); if (input) { input.value = safeTitle; // 聚焦搜索框,便于用户立即使用键盘操作 input.focus(); // 显示一个提示,告知用户点击搜索按钮进行搜索 showToast('已填充搜索内容,点击搜索按钮开始搜索', 'info'); } } // 填充搜索框并执行搜索 function fillAndSearch(title) { if (!title) return; // 安全处理标题,防止XSS const safeTitle = title .replace(//g, '>') .replace(/"/g, '"'); const input = document.getElementById('searchInput'); if (input) { input.value = safeTitle; search(); // 使用已有的search函数执行搜索 // 同时更新浏览器URL,使其反映当前的搜索状态 try { // 使用URI编码确保特殊字符能够正确显示 const encodedQuery = encodeURIComponent(safeTitle); // 使用HTML5 History API更新URL,不刷新页面 window.history.pushState( { search: safeTitle }, `搜索: ${safeTitle} - LibreTV`, `/s=${encodedQuery}` ); // 更新页面标题 document.title = `搜索: ${safeTitle} - LibreTV`; } catch (e) { console.error('更新浏览器历史失败:', e); } } } // 填充搜索框,确保豆瓣资源API被选中,然后执行搜索 async function fillAndSearchWithDouban(title) { if (!title) return; // 安全处理标题,防止XSS const safeTitle = title .replace(//g, '>') .replace(/"/g, '"'); // 确保豆瓣资源API被选中 if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) { // 在设置中勾选豆瓣资源API复选框 const doubanCheckbox = document.querySelector('input[id="api_dbzy"]'); if (doubanCheckbox) { doubanCheckbox.checked = true; // 触发updateSelectedAPIs函数以更新状态 if (typeof updateSelectedAPIs === 'function') { updateSelectedAPIs(); } else { // 如果函数不可用,则手动添加到selectedAPIs selectedAPIs.push('dbzy'); localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs)); // 更新选中API计数(如果有这个元素) const countEl = document.getElementById('selectedAPICount'); if (countEl) { countEl.textContent = selectedAPIs.length; } } showToast('已自动选择豆瓣资源API', 'info'); } } // 填充搜索框并执行搜索 const input = document.getElementById('searchInput'); if (input) { input.value = safeTitle; await search(); // 使用已有的search函数执行搜索 // 更新浏览器URL,使其反映当前的搜索状态 try { // 使用URI编码确保特殊字符能够正确显示 const encodedQuery = encodeURIComponent(safeTitle); // 使用HTML5 History API更新URL,不刷新页面 window.history.pushState( { search: safeTitle }, `搜索: ${safeTitle} - LibreTV`, `/s=${encodedQuery}` ); // 更新页面标题 document.title = `搜索: ${safeTitle} - LibreTV`; } catch (e) { console.error('更新浏览器历史失败:', e); } if (window.innerWidth <= 768) { window.scrollTo({ top: 0, behavior: 'smooth' }); } } } // 渲染电影/电视剧切换器 function renderDoubanMovieTvSwitch() { // 获取切换按钮元素 const movieToggle = document.getElementById('douban-movie-toggle'); const tvToggle = document.getElementById('douban-tv-toggle'); if (!movieToggle ||!tvToggle) return; movieToggle.addEventListener('click', function() { if (doubanMovieTvCurrentSwitch !== 'movie') { // 更新按钮样式 movieToggle.classList.add('bg-pink-600', 'text-white'); movieToggle.classList.remove('text-gray-300'); tvToggle.classList.remove('bg-pink-600', 'text-white'); tvToggle.classList.add('text-gray-300'); doubanMovieTvCurrentSwitch = 'movie'; doubanCurrentTag = '热门'; // 重新加载豆瓣内容 renderDoubanTags(movieTags); // 换一批按钮事件监听 setupDoubanRefreshBtn(); // 初始加载热门内容 if (localStorage.getItem('doubanEnabled') === 'true') { renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); } } }); // 电视剧按钮点击事件 tvToggle.addEventListener('click', function() { if (doubanMovieTvCurrentSwitch !== 'tv') { // 更新按钮样式 tvToggle.classList.add('bg-pink-600', 'text-white'); tvToggle.classList.remove('text-gray-300'); movieToggle.classList.remove('bg-pink-600', 'text-white'); movieToggle.classList.add('text-gray-300'); doubanMovieTvCurrentSwitch = 'tv'; doubanCurrentTag = '热门'; // 重新加载豆瓣内容 renderDoubanTags(tvTags); // 换一批按钮事件监听 setupDoubanRefreshBtn(); // 初始加载热门内容 if (localStorage.getItem('doubanEnabled') === 'true') { renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); } } }); } // 渲染豆瓣标签选择器 function renderDoubanTags(tags) { const tagContainer = document.getElementById('douban-tags'); if (!tagContainer) return; // 确定当前应该使用的标签列表 const currentTags = doubanMovieTvCurrentSwitch === 'movie' ? movieTags : tvTags; // 清空标签容器 tagContainer.innerHTML = ''; // 先添加标签管理按钮 const manageBtn = document.createElement('button'); 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'; manageBtn.innerHTML = '管理标签'; manageBtn.onclick = function() { showTagManageModal(); }; tagContainer.appendChild(manageBtn); // 添加所有标签 currentTags.forEach(tag => { const btn = document.createElement('button'); // 设置样式 let btnClass = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 border '; // 当前选中的标签使用高亮样式 if (tag === doubanCurrentTag) { btnClass += 'bg-pink-600 text-white shadow-md border-white'; } else { btnClass += 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white border-[#333] hover:border-white'; } btn.className = btnClass; btn.textContent = tag; btn.onclick = function() { if (doubanCurrentTag !== tag) { doubanCurrentTag = tag; doubanPageStart = 0; renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); renderDoubanTags(); } }; tagContainer.appendChild(btn); }); } // 设置换一批按钮事件 function setupDoubanRefreshBtn() { // 修复ID,使用正确的ID douban-refresh 而不是 douban-refresh-btn const btn = document.getElementById('douban-refresh'); if (!btn) return; btn.onclick = function() { doubanPageStart += doubanPageSize; if (doubanPageStart > 9 * doubanPageSize) { doubanPageStart = 0; } renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); }; } function fetchDoubanTags() { const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie` fetchDoubanData(movieTagsTarget) .then(data => { movieTags = data.tags; if (doubanMovieTvCurrentSwitch === 'movie') { renderDoubanTags(movieTags); } }) .catch(error => { console.error("获取豆瓣热门电影标签失败:", error); }); const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv` fetchDoubanData(tvTagsTarget) .then(data => { tvTags = data.tags; if (doubanMovieTvCurrentSwitch === 'tv') { renderDoubanTags(tvTags); } }) .catch(error => { console.error("获取豆瓣热门电视剧标签失败:", error); }); } // 渲染热门推荐内容 function renderRecommend(tag, pageLimit, pageStart) { const container = document.getElementById("douban-results"); if (!container) return; const loadingOverlayHTML = `
加载中...
`; container.classList.add("relative"); container.insertAdjacentHTML('beforeend', loadingOverlayHTML); const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`; // 使用通用请求函数 fetchDoubanData(target) .then(data => { renderDoubanCards(data, container); }) .catch(error => { console.error("获取豆瓣数据失败:", error); container.innerHTML = `
❌ 获取豆瓣数据失败,请稍后重试
提示:使用VPN可能有助于解决此问题
`; }); } async function fetchDoubanData(url) { // 添加超时控制 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时 // 设置请求选项,包括信号和头部 const fetchOptions = { signal: controller.signal, headers: { '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', 'Referer': 'https://movie.douban.com/', 'Accept': 'application/json, text/plain, */*', } }; try { // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(url)) : PROXY_URL + encodeURIComponent(url); // 尝试直接访问(豆瓣API可能允许部分CORS请求) const response = await fetch(proxiedUrl, fetchOptions); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`HTTP error! Status: ${response.status}`); } return await response.json(); } catch (err) { console.error("豆瓣 API 请求失败(直接代理):", err); // 失败后尝试备用方法:作为备选 const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; try { const fallbackResponse = await fetch(fallbackUrl); if (!fallbackResponse.ok) { throw new Error(`备用API请求失败! 状态: ${fallbackResponse.status}`); } const data = await fallbackResponse.json(); // 解析原始内容 if (data && data.contents) { return JSON.parse(data.contents); } else { throw new Error("无法获取有效数据"); } } catch (fallbackErr) { console.error("豆瓣 API 备用请求也失败:", fallbackErr); throw fallbackErr; // 向上抛出错误,让调用者处理 } } } // 抽取渲染豆瓣卡片的逻辑到单独函数 function renderDoubanCards(data, container) { // 创建文档片段以提高性能 const fragment = document.createDocumentFragment(); // 如果没有数据 if (!data.subjects || data.subjects.length === 0) { const emptyEl = document.createElement("div"); emptyEl.className = "col-span-full text-center py-8"; emptyEl.innerHTML = `
❌ 暂无数据,请尝试其他分类或刷新
`; fragment.appendChild(emptyEl); } else { // 循环创建每个影视卡片 data.subjects.forEach(item => { const card = document.createElement("div"); 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"; // 生成卡片内容,确保安全显示(防止XSS) const safeTitle = item.title .replace(//g, '>') .replace(/"/g, '"'); const safeRate = (item.rate || "暂无") .replace(//g, '>'); // 处理图片URL // 1. 直接使用豆瓣图片URL (添加no-referrer属性) const originalCoverUrl = item.cover; // 2. 也准备代理URL作为备选 const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl); // 为不同设备优化卡片布局 card.innerHTML = `
`; fragment.appendChild(card); }); } // 清空并添加所有新元素 container.innerHTML = ""; container.appendChild(fragment); } // 重置到首页 function resetToHome() { resetSearchArea(); updateDoubanVisibility(); } // 加载豆瓣首页内容 document.addEventListener('DOMContentLoaded', initDouban); // 显示标签管理模态框 function showTagManageModal() { // 确保模态框在页面上只有一个实例 let modal = document.getElementById('tagManageModal'); if (modal) { document.body.removeChild(modal); } // 创建模态框元素 modal = document.createElement('div'); modal.id = 'tagManageModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40'; // 当前使用的标签类型和默认标签 const isMovie = doubanMovieTvCurrentSwitch === 'movie'; const currentTags = isMovie ? movieTags : tvTags; const defaultTags = isMovie ? defaultMovieTags : defaultTvTags; // 模态框内容 modal.innerHTML = `

标签管理 (${isMovie ? '电影' : '电视剧'})

标签列表

${currentTags.length ? currentTags.map(tag => { // "热门"标签不能删除 const canDelete = tag !== '热门'; return `
${tag} ${canDelete ? `` : `必需` }
`; }).join('') : `
无标签,请添加或恢复默认
`}

添加新标签

提示:标签名称不能为空,不能重复,不能包含特殊字符

`; // 添加模态框到页面 document.body.appendChild(modal); // 焦点放在输入框上 setTimeout(() => { document.getElementById('newTagInput').focus(); }, 100); // 添加事件监听器 - 关闭按钮 document.getElementById('closeTagModal').addEventListener('click', function() { document.body.removeChild(modal); }); // 添加事件监听器 - 点击模态框外部关闭 modal.addEventListener('click', function(e) { if (e.target === modal) { document.body.removeChild(modal); } }); // 添加事件监听器 - 恢复默认标签按钮 document.getElementById('resetTagsBtn').addEventListener('click', function() { resetTagsToDefault(); showTagManageModal(); // 重新加载模态框 }); // 添加事件监听器 - 删除标签按钮 const deleteButtons = document.querySelectorAll('.delete-tag-btn'); deleteButtons.forEach(btn => { btn.addEventListener('click', function() { const tagToDelete = this.getAttribute('data-tag'); deleteTag(tagToDelete); showTagManageModal(); // 重新加载模态框 }); }); // 添加事件监听器 - 表单提交 document.getElementById('addTagForm').addEventListener('submit', function(e) { e.preventDefault(); const input = document.getElementById('newTagInput'); const newTag = input.value.trim(); if (newTag) { addTag(newTag); input.value = ''; showTagManageModal(); // 重新加载模态框 } }); } // 添加标签 function addTag(tag) { // 安全处理标签名,防止XSS const safeTag = tag .replace(//g, '>') .replace(/"/g, '"'); // 确定当前使用的是电影还是电视剧标签 const isMovie = doubanMovieTvCurrentSwitch === 'movie'; const currentTags = isMovie ? movieTags : tvTags; // 检查是否已存在(忽略大小写) const exists = currentTags.some( existingTag => existingTag.toLowerCase() === safeTag.toLowerCase() ); if (exists) { showToast('标签已存在', 'warning'); return; } // 添加到对应的标签数组 if (isMovie) { movieTags.push(safeTag); } else { tvTags.push(safeTag); } // 保存到本地存储 saveUserTags(); // 重新渲染标签 renderDoubanTags(); showToast('标签添加成功', 'success'); } // 删除标签 function deleteTag(tag) { // 热门标签不能删除 if (tag === '热门') { showToast('热门标签不能删除', 'warning'); return; } // 确定当前使用的是电影还是电视剧标签 const isMovie = doubanMovieTvCurrentSwitch === 'movie'; const currentTags = isMovie ? movieTags : tvTags; // 寻找标签索引 const index = currentTags.indexOf(tag); // 如果找到标签,则删除 if (index !== -1) { currentTags.splice(index, 1); // 保存到本地存储 saveUserTags(); // 如果当前选中的是被删除的标签,则重置为"热门" if (doubanCurrentTag === tag) { doubanCurrentTag = '热门'; doubanPageStart = 0; renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); } // 重新渲染标签 renderDoubanTags(); showToast('标签删除成功', 'success'); } } // 重置为默认标签 function resetTagsToDefault() { // 确定当前使用的是电影还是电视剧 const isMovie = doubanMovieTvCurrentSwitch === 'movie'; // 重置为默认标签 if (isMovie) { movieTags = [...defaultMovieTags]; } else { tvTags = [...defaultTvTags]; } // 设置当前标签为热门 doubanCurrentTag = '热门'; doubanPageStart = 0; // 保存到本地存储 saveUserTags(); // 重新渲染标签和内容 renderDoubanTags(); renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); showToast('已恢复默认标签', 'success'); } ================================================ FILE: js/index-page.js ================================================ // 页面加载后显示弹窗脚本 document.addEventListener('DOMContentLoaded', function() { // 弹窗显示脚本 // 检查用户是否已经看过声明 const hasSeenDisclaimer = localStorage.getItem('hasSeenDisclaimer'); if (!hasSeenDisclaimer) { // 显示弹窗 const disclaimerModal = document.getElementById('disclaimerModal'); disclaimerModal.style.display = 'flex'; // 添加接受按钮事件 document.getElementById('acceptDisclaimerBtn').addEventListener('click', function() { // 保存用户已看过声明的状态 localStorage.setItem('hasSeenDisclaimer', 'true'); // 隐藏弹窗 disclaimerModal.style.display = 'none'; }); } // URL搜索参数处理脚本 // 首先检查是否是播放URL格式 (/watch 开头的路径) if (window.location.pathname.startsWith('/watch')) { // 播放URL,不做额外处理,watch.html会处理重定向 return; } // 检查页面路径中的搜索参数 (格式: /s=keyword) const path = window.location.pathname; const searchPrefix = '/s='; if (path.startsWith(searchPrefix)) { // 提取搜索关键词 const keyword = decodeURIComponent(path.substring(searchPrefix.length)); if (keyword) { // 设置搜索框的值 document.getElementById('searchInput').value = keyword; // 显示清空按钮 toggleClearButton(); // 执行搜索 setTimeout(() => { // 使用setTimeout确保其他DOM加载和初始化完成 search(); // 更新浏览器历史,不改变URL (保持搜索参数在地址栏) try { window.history.replaceState( { search: keyword }, `搜索: ${keyword} - LibreTV`, window.location.href ); } catch (e) { console.error('更新浏览器历史失败:', e); } }, 300); } } // 也检查查询字符串中的搜索参数 (格式: ?s=keyword) const urlParams = new URLSearchParams(window.location.search); const searchQuery = urlParams.get('s'); if (searchQuery) { // 设置搜索框的值 document.getElementById('searchInput').value = searchQuery; // 执行搜索 setTimeout(() => { search(); // 更新URL为规范格式 try { window.history.replaceState( { search: searchQuery }, `搜索: ${searchQuery} - LibreTV`, `/s=${encodeURIComponent(searchQuery)}` ); } catch (e) { console.error('更新浏览器历史失败:', e); } }, 300); } }); ================================================ FILE: js/password.js ================================================ // 密码保护功能 /** * 检查是否设置了密码保护 * 通过读取页面上嵌入的环境变量来检查 */ function isPasswordProtected() { // 只检查普通密码 const pwd = window.__ENV__ && window.__ENV__.PASSWORD; // 检查普通密码是否有效 return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd); } /** * 检查是否强制要求设置密码 * 如果没有设置有效的 PASSWORD,则认为需要强制设置密码 * 为了安全考虑,所有部署都必须设置密码 */ function isPasswordRequired() { return !isPasswordProtected(); } /** * 强制密码保护检查 - 防止绕过 * 在关键操作前都应该调用此函数 */ function ensurePasswordProtection() { if (isPasswordRequired()) { showPasswordModal(); throw new Error('Password protection is required'); } if (isPasswordProtected() && !isPasswordVerified()) { showPasswordModal(); throw new Error('Password verification required'); } return true; } window.isPasswordProtected = isPasswordProtected; window.isPasswordRequired = isPasswordRequired; /** * 验证用户输入的密码是否正确(异步,使用SHA-256哈希) */ async function verifyPassword(password) { try { const correctHash = window.__ENV__?.PASSWORD; if (!correctHash) return false; const inputHash = await sha256(password); const isValid = inputHash === correctHash; if (isValid) { localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify({ verified: true, timestamp: Date.now(), passwordHash: correctHash })); } return isValid; } catch (error) { console.error('验证密码时出错:', error); return false; } } // 验证状态检查 function isPasswordVerified() { try { if (!isPasswordProtected()) return true; const stored = localStorage.getItem(PASSWORD_CONFIG.localStorageKey); if (!stored) return false; const { timestamp, passwordHash } = JSON.parse(stored); const currentHash = window.__ENV__?.PASSWORD; return timestamp && passwordHash === currentHash && Date.now() - timestamp < PASSWORD_CONFIG.verificationTTL; } catch (error) { console.error('检查密码验证状态时出错:', error); return false; } } // 更新全局导出 window.isPasswordProtected = isPasswordProtected; window.isPasswordRequired = isPasswordRequired; window.isPasswordVerified = isPasswordVerified; window.verifyPassword = verifyPassword; window.ensurePasswordProtection = ensurePasswordProtection; // SHA-256实现,可用Web Crypto API async function sha256(message) { if (window.crypto && crypto.subtle && crypto.subtle.digest) { const msgBuffer = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // HTTP 下调用原始 js‑sha256 if (typeof window._jsSha256 === 'function') { return window._jsSha256(message); } throw new Error('No SHA-256 implementation available.'); } /** * 显示密码验证弹窗 */ function showPasswordModal() { const passwordModal = document.getElementById('passwordModal'); if (passwordModal) { // 防止出现豆瓣区域滚动条 document.getElementById('doubanArea').classList.add('hidden'); document.getElementById('passwordCancelBtn').classList.add('hidden'); // 检查是否需要强制设置密码 if (isPasswordRequired()) { // 修改弹窗内容提示用户需要先设置密码 const title = passwordModal.querySelector('h2'); const description = passwordModal.querySelector('p'); if (title) title.textContent = '需要设置密码'; if (description) description.textContent = '请先在部署平台设置 PASSWORD 环境变量来保护您的实例'; // 隐藏密码输入框和提交按钮,只显示提示信息 const form = passwordModal.querySelector('form'); const errorMsg = document.getElementById('passwordError'); if (form) form.style.display = 'none'; if (errorMsg) { errorMsg.textContent = '为确保安全,必须设置 PASSWORD 环境变量才能使用本服务,请联系管理员进行配置'; errorMsg.classList.remove('hidden'); errorMsg.className = 'text-red-500 mt-2 font-medium'; // 改为更醒目的红色 } } else { // 正常的密码验证模式 const title = passwordModal.querySelector('h2'); const description = passwordModal.querySelector('p'); if (title) title.textContent = '访问验证'; if (description) description.textContent = '请输入密码继续访问'; const form = passwordModal.querySelector('form'); if (form) form.style.display = 'block'; } passwordModal.style.display = 'flex'; // 只有在非强制设置密码模式下才聚焦输入框 if (!isPasswordRequired()) { // 确保输入框获取焦点 setTimeout(() => { const passwordInput = document.getElementById('passwordInput'); if (passwordInput) { passwordInput.focus(); } }, 100); } } } /** * 隐藏密码验证弹窗 */ function hidePasswordModal() { const passwordModal = document.getElementById('passwordModal'); if (passwordModal) { // 隐藏密码错误提示 hidePasswordError(); // 清空密码输入框 const passwordInput = document.getElementById('passwordInput'); if (passwordInput) passwordInput.value = ''; passwordModal.style.display = 'none'; // 如果启用豆瓣区域则显示豆瓣区域 if (localStorage.getItem('doubanEnabled') === 'true') { document.getElementById('doubanArea').classList.remove('hidden'); initDouban(); } } } /** * 显示密码错误信息 */ function showPasswordError() { const errorElement = document.getElementById('passwordError'); if (errorElement) { errorElement.classList.remove('hidden'); } } /** * 隐藏密码错误信息 */ function hidePasswordError() { const errorElement = document.getElementById('passwordError'); if (errorElement) { errorElement.classList.add('hidden'); } } /** * 处理密码提交事件(异步) */ async function handlePasswordSubmit() { const passwordInput = document.getElementById('passwordInput'); const password = passwordInput ? passwordInput.value.trim() : ''; if (await verifyPassword(password)) { hidePasswordModal(); // 触发密码验证成功事件 document.dispatchEvent(new CustomEvent('passwordVerified')); } else { showPasswordError(); if (passwordInput) { passwordInput.value = ''; passwordInput.focus(); } } } /** * 初始化密码验证系统 */ function initPasswordProtection() { // 如果需要强制设置密码,显示警告弹窗 if (isPasswordRequired()) { showPasswordModal(); return; } // 如果设置了密码但用户未验证,显示密码输入框 if (isPasswordProtected() && !isPasswordVerified()) { showPasswordModal(); return; } } // 在页面加载完成后初始化密码保护 document.addEventListener('DOMContentLoaded', function () { initPasswordProtection(); }); ================================================ FILE: js/player.js ================================================ const selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[]'); const customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表 // 改进返回功能 function goBack(event) { // 防止默认链接行为 if (event) event.preventDefault(); // 1. 优先检查URL参数中的returnUrl const urlParams = new URLSearchParams(window.location.search); const returnUrl = urlParams.get('returnUrl'); if (returnUrl) { // 如果URL中有returnUrl参数,优先使用 window.location.href = decodeURIComponent(returnUrl); return; } // 2. 检查localStorage中保存的lastPageUrl const lastPageUrl = localStorage.getItem('lastPageUrl'); if (lastPageUrl && lastPageUrl !== window.location.href) { window.location.href = lastPageUrl; return; } // 3. 检查是否是从搜索页面进入的播放器 const referrer = document.referrer; // 检查 referrer 是否包含搜索参数 if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) { // 如果是从搜索页面来的,返回到搜索页面 window.location.href = referrer; return; } // 4. 如果是在iframe中打开的,尝试关闭iframe if (window.self !== window.top) { try { // 尝试调用父窗口的关闭播放器函数 window.parent.closeVideoPlayer && window.parent.closeVideoPlayer(); return; } catch (e) { console.error('调用父窗口closeVideoPlayer失败:', e); } } // 5. 无法确定上一页,则返回首页 if (!referrer || referrer === '') { window.location.href = '/'; return; } // 6. 以上都不满足,使用默认行为:返回上一页 window.history.back(); } // 页面加载时保存当前URL到localStorage,作为返回目标 window.addEventListener('load', function () { // 保存前一页面URL if (document.referrer && document.referrer !== window.location.href) { localStorage.setItem('lastPageUrl', document.referrer); } // 提取当前URL中的重要参数,以便在需要时能够恢复当前页面 const urlParams = new URLSearchParams(window.location.search); const videoId = urlParams.get('id'); const sourceCode = urlParams.get('source'); if (videoId && sourceCode) { // 保存当前播放状态,以便其他页面可以返回 localStorage.setItem('currentPlayingId', videoId); localStorage.setItem('currentPlayingSource', sourceCode); } }); // ================================= // ============== PLAYER ========== // ================================= // 全局变量 let currentVideoTitle = ''; let currentEpisodeIndex = 0; let art = null; // 用于 ArtPlayer 实例 let currentHls = null; // 跟踪当前HLS实例 let currentEpisodes = []; let episodesReversed = false; let autoplayEnabled = true; // 默认开启自动连播 let videoHasEnded = false; // 跟踪视频是否已经自然结束 let userClickedPosition = null; // 记录用户点击的位置 let shortcutHintTimeout = null; // 用于控制快捷键提示显示时间 let adFilteringEnabled = true; // 默认开启广告过滤 let progressSaveInterval = null; // 定期保存进度的计时器 let currentVideoUrl = ''; // 记录当前实际的视频URL const isWebkit = (typeof window.webkitConvertPointFromNodeToPage === 'function') Artplayer.FULLSCREEN_WEB_IN_BODY = true; // 页面加载 document.addEventListener('DOMContentLoaded', function () { // 先检查用户是否已通过密码验证 if (!isPasswordVerified()) { // 隐藏加载提示 document.getElementById('player-loading').style.display = 'none'; return; } initializePageContent(); }); // 监听密码验证成功事件 document.addEventListener('passwordVerified', () => { document.getElementById('player-loading').style.display = 'block'; initializePageContent(); }); // 初始化页面内容 function initializePageContent() { // 解析URL参数 const urlParams = new URLSearchParams(window.location.search); let videoUrl = urlParams.get('url'); const title = urlParams.get('title'); const sourceCode = urlParams.get('source'); let index = parseInt(urlParams.get('index') || '0'); const episodesList = urlParams.get('episodes'); // 从URL获取集数信息 const savedPosition = parseInt(urlParams.get('position') || '0'); // 获取保存的播放位置 // 解决历史记录问题:检查URL是否是player.html开头的链接 // 如果是,说明这是历史记录重定向,需要解析真实的视频URL if (videoUrl && videoUrl.includes('player.html')) { try { // 尝试从嵌套URL中提取真实的视频链接 const nestedUrlParams = new URLSearchParams(videoUrl.split('?')[1]); // 从嵌套参数中获取真实视频URL const nestedVideoUrl = nestedUrlParams.get('url'); // 检查嵌套URL是否包含播放位置信息 const nestedPosition = nestedUrlParams.get('position'); const nestedIndex = nestedUrlParams.get('index'); const nestedTitle = nestedUrlParams.get('title'); if (nestedVideoUrl) { videoUrl = nestedVideoUrl; // 更新当前URL参数 const url = new URL(window.location.href); if (!urlParams.has('position') && nestedPosition) { url.searchParams.set('position', nestedPosition); } if (!urlParams.has('index') && nestedIndex) { url.searchParams.set('index', nestedIndex); } if (!urlParams.has('title') && nestedTitle) { url.searchParams.set('title', nestedTitle); } // 替换当前URL window.history.replaceState({}, '', url); } else { showError('历史记录链接无效,请返回首页重新访问'); } } catch (e) { } } // 保存当前视频URL currentVideoUrl = videoUrl || ''; // 从localStorage获取数据 currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频'; currentEpisodeIndex = index; // 设置自动连播开关状态 autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; // 默认为true document.getElementById('autoplayToggle').checked = autoplayEnabled; // 获取广告过滤设置 adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true // 监听自动连播开关变化 document.getElementById('autoplayToggle').addEventListener('change', function (e) { autoplayEnabled = e.target.checked; localStorage.setItem('autoplayEnabled', autoplayEnabled); }); // 优先使用URL传递的集数信息,否则从localStorage获取 try { if (episodesList) { // 如果URL中有集数数据,优先使用它 currentEpisodes = JSON.parse(decodeURIComponent(episodesList)); } else { // 否则从localStorage获取 currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]'); } // 检查集数索引是否有效,如果无效则调整为0 if (index < 0 || (currentEpisodes.length > 0 && index >= currentEpisodes.length)) { // 如果索引太大,则使用最大有效索引 if (index >= currentEpisodes.length && currentEpisodes.length > 0) { index = currentEpisodes.length - 1; } else { index = 0; } // 更新URL以反映修正后的索引 const newUrl = new URL(window.location.href); newUrl.searchParams.set('index', index); window.history.replaceState({}, '', newUrl); } // 更新当前索引为验证过的值 currentEpisodeIndex = index; episodesReversed = localStorage.getItem('episodesReversed') === 'true'; } catch (e) { currentEpisodes = []; currentEpisodeIndex = 0; episodesReversed = false; } // 设置页面标题 document.title = currentVideoTitle + ' - LibreTV播放器'; document.getElementById('videoTitle').textContent = currentVideoTitle; // 初始化播放器 if (videoUrl) { initPlayer(videoUrl); } else { showError('无效的视频链接'); } // 渲染源信息 renderResourceInfoBar(); // 更新集数信息 updateEpisodeInfo(); // 渲染集数列表 renderEpisodes(); // 更新按钮状态 updateButtonStates(); // 更新排序按钮状态 updateOrderButton(); // 添加对进度条的监听,确保点击准确跳转 setTimeout(() => { setupProgressBarPreciseClicks(); }, 1000); // 添加键盘快捷键事件监听 document.addEventListener('keydown', handleKeyboardShortcuts); // 添加页面离开事件监听,保存播放位置 window.addEventListener('beforeunload', saveCurrentProgress); // 新增:页面隐藏(切后台/切标签)时也保存 document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') { saveCurrentProgress(); } }); // 视频暂停时也保存 const waitForVideo = setInterval(() => { if (art && art.video) { art.video.addEventListener('pause', saveCurrentProgress); // 新增:播放进度变化时节流保存 let lastSave = 0; art.video.addEventListener('timeupdate', function() { const now = Date.now(); if (now - lastSave > 5000) { // 每5秒最多保存一次 saveCurrentProgress(); lastSave = now; } }); clearInterval(waitForVideo); } }, 200); } // 处理键盘快捷键 function handleKeyboardShortcuts(e) { // 忽略输入框中的按键事件 if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; // Alt + 左箭头 = 上一集 if (e.altKey && e.key === 'ArrowLeft') { if (currentEpisodeIndex > 0) { playPreviousEpisode(); showShortcutHint('上一集', 'left'); e.preventDefault(); } } // Alt + 右箭头 = 下一集 if (e.altKey && e.key === 'ArrowRight') { if (currentEpisodeIndex < currentEpisodes.length - 1) { playNextEpisode(); showShortcutHint('下一集', 'right'); e.preventDefault(); } } // 左箭头 = 快退 if (!e.altKey && e.key === 'ArrowLeft') { if (art && art.currentTime > 5) { art.currentTime -= 5; showShortcutHint('快退', 'left'); e.preventDefault(); } } // 右箭头 = 快进 if (!e.altKey && e.key === 'ArrowRight') { if (art && art.currentTime < art.duration - 5) { art.currentTime += 5; showShortcutHint('快进', 'right'); e.preventDefault(); } } // 上箭头 = 音量+ if (e.key === 'ArrowUp') { if (art && art.volume < 1) { art.volume += 0.1; showShortcutHint('音量+', 'up'); e.preventDefault(); } } // 下箭头 = 音量- if (e.key === 'ArrowDown') { if (art && art.volume > 0) { art.volume -= 0.1; showShortcutHint('音量-', 'down'); e.preventDefault(); } } // 空格 = 播放/暂停 if (e.key === ' ') { if (art) { art.toggle(); showShortcutHint('播放/暂停', 'play'); e.preventDefault(); } } // f 键 = 切换全屏 if (e.key === 'f' || e.key === 'F') { if (art) { art.fullscreen = !art.fullscreen; showShortcutHint('切换全屏', 'fullscreen'); e.preventDefault(); } } } // 显示快捷键提示 function showShortcutHint(text, direction) { const hintElement = document.getElementById('shortcutHint'); const textElement = document.getElementById('shortcutText'); const iconElement = document.getElementById('shortcutIcon'); // 清除之前的超时 if (shortcutHintTimeout) { clearTimeout(shortcutHintTimeout); } // 设置文本和图标方向 textElement.textContent = text; if (direction === 'left') { iconElement.innerHTML = ''; } else if (direction === 'right') { iconElement.innerHTML = ''; } else if (direction === 'up') { iconElement.innerHTML = ''; } else if (direction === 'down') { iconElement.innerHTML = ''; } else if (direction === 'fullscreen') { iconElement.innerHTML = ''; } else if (direction === 'play') { iconElement.innerHTML = ''; } // 显示提示 hintElement.classList.add('show'); // 两秒后隐藏 shortcutHintTimeout = setTimeout(() => { hintElement.classList.remove('show'); }, 2000); } // 初始化播放器 function initPlayer(videoUrl) { if (!videoUrl) { return } // 销毁旧实例 if (art) { art.destroy(); art = null; } // 配置HLS.js选项 const hlsConfig = { debug: false, loader: adFilteringEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader, enableWorker: true, lowLatencyMode: false, backBufferLength: 90, maxBufferLength: 30, maxMaxBufferLength: 60, maxBufferSize: 30 * 1000 * 1000, maxBufferHole: 0.5, fragLoadingMaxRetry: 6, fragLoadingMaxRetryTimeout: 64000, fragLoadingRetryDelay: 1000, manifestLoadingMaxRetry: 3, manifestLoadingRetryDelay: 1000, levelLoadingMaxRetry: 4, levelLoadingRetryDelay: 1000, startLevel: -1, abrEwmaDefaultEstimate: 500000, abrBandWidthFactor: 0.95, abrBandWidthUpFactor: 0.7, abrMaxWithRealBitrate: true, stretchShortVideoTrack: true, appendErrorMaxRetry: 5, // 增加尝试次数 liveSyncDurationCount: 3, liveDurationInfinity: false }; // Create new ArtPlayer instance art = new Artplayer({ container: '#player', url: videoUrl, type: 'm3u8', title: videoTitle, volume: 0.8, isLive: false, muted: false, autoplay: true, pip: true, autoSize: false, autoMini: true, screenshot: true, setting: true, loop: false, flip: false, playbackRate: true, aspectRatio: false, fullscreen: true, fullscreenWeb: true, subtitleOffset: false, miniProgressBar: true, mutex: true, backdrop: true, playsInline: true, autoPlayback: false, airplay: true, hotkey: false, theme: '#23ade5', lang: navigator.language.toLowerCase(), moreVideoAttr: { crossOrigin: 'anonymous', }, customType: { m3u8: function (video, url) { // 清理之前的HLS实例 if (currentHls && currentHls.destroy) { try { currentHls.destroy(); } catch (e) { } } // 创建新的HLS实例 const hls = new Hls(hlsConfig); currentHls = hls; // 跟踪是否已经显示错误 let errorDisplayed = false; // 跟踪是否有错误发生 let errorCount = 0; // 跟踪视频是否开始播放 let playbackStarted = false; // 跟踪视频是否出现bufferAppendError let bufferAppendErrorCount = 0; // 监听视频播放事件 video.addEventListener('playing', function () { playbackStarted = true; document.getElementById('player-loading').style.display = 'none'; document.getElementById('error').style.display = 'none'; }); // 监听视频进度事件 video.addEventListener('timeupdate', function () { if (video.currentTime > 1) { // 视频进度超过1秒,隐藏错误(如果存在) document.getElementById('error').style.display = 'none'; } }); hls.loadSource(url); hls.attachMedia(video); // enable airplay, from https://github.com/video-dev/hls.js/issues/5989 // 检查是否已存在source元素,如果存在则更新,不存在则创建 let sourceElement = video.querySelector('source'); if (sourceElement) { // 更新现有source元素的URL sourceElement.src = videoUrl; } else { // 创建新的source元素 sourceElement = document.createElement('source'); sourceElement.src = videoUrl; video.appendChild(sourceElement); } video.disableRemotePlayback = false; hls.on(Hls.Events.MANIFEST_PARSED, function () { video.play().catch(e => { }); }); hls.on(Hls.Events.ERROR, function (event, data) { // 增加错误计数 errorCount++; // 处理bufferAppendError if (data.details === 'bufferAppendError') { bufferAppendErrorCount++; // 如果视频已经开始播放,则忽略这个错误 if (playbackStarted) { return; } // 如果出现多次bufferAppendError但视频未播放,尝试恢复 if (bufferAppendErrorCount >= 3) { hls.recoverMediaError(); } } // 如果是致命错误,且视频未播放 if (data.fatal && !playbackStarted) { // 尝试恢复错误 switch (data.type) { case Hls.ErrorTypes.NETWORK_ERROR: hls.startLoad(); break; case Hls.ErrorTypes.MEDIA_ERROR: hls.recoverMediaError(); break; default: // 仅在多次恢复尝试后显示错误 if (errorCount > 3 && !errorDisplayed) { errorDisplayed = true; showError('视频加载失败,可能是格式不兼容或源不可用'); } break; } } }); // 监听分段加载事件 hls.on(Hls.Events.FRAG_LOADED, function () { document.getElementById('player-loading').style.display = 'none'; }); // 监听级别加载事件 hls.on(Hls.Events.LEVEL_LOADED, function () { document.getElementById('player-loading').style.display = 'none'; }); } } }); // artplayer 没有 'fullscreenWeb:enter', 'fullscreenWeb:exit' 等事件 // 所以原控制栏隐藏代码并没有起作用 // 实际起作用的是 artplayer 默认行为,它支持自动隐藏工具栏 // 但有一个 bug: 在副屏全屏时,鼠标移出副屏后不会自动隐藏工具栏 // 下面进一并重构和修复: let hideTimer; // 隐藏控制栏 function hideControls() { if (art && art.controls) { art.controls.show = false; } } // 重置计时器,计时器超时时间与 artplayer 保持一致 function resetHideTimer() { clearTimeout(hideTimer); hideTimer = setTimeout(() => { hideControls(); }, Artplayer.CONTROL_HIDE_TIME); } // 处理鼠标离开浏览器窗口 function handleMouseOut(e) { if (e && !e.relatedTarget) { resetHideTimer(); } } // 全屏状态切换时注册/移除 mouseout 事件,监听鼠标移出屏幕事件 // 从而对播放器状态栏进行隐藏倒计时 function handleFullScreen(isFullScreen, isWeb) { if (isFullScreen) { document.addEventListener('mouseout', handleMouseOut); } else { document.removeEventListener('mouseout', handleMouseOut); // 退出全屏时清理计时器 clearTimeout(hideTimer); } if (!isWeb) { if (window.screen.orientation && window.screen.orientation.lock) { window.screen.orientation.lock('landscape') .then(() => { }) .catch((error) => { }); } } } // 播放器加载完成后初始隐藏工具栏 art.on('ready', () => { hideControls(); }); // 全屏 Web 模式处理 art.on('fullscreenWeb', function (isFullScreen) { handleFullScreen(isFullScreen, true); }); // 全屏模式处理 art.on('fullscreen', function (isFullScreen) { handleFullScreen(isFullScreen, false); }); art.on('video:loadedmetadata', function() { document.getElementById('player-loading').style.display = 'none'; videoHasEnded = false; // 视频加载时重置结束标志 // 优先使用URL传递的position参数 const urlParams = new URLSearchParams(window.location.search); const savedPosition = parseInt(urlParams.get('position') || '0'); if (savedPosition > 10 && savedPosition < art.duration - 2) { // 如果URL中有有效的播放位置参数,直接使用它 art.currentTime = savedPosition; showPositionRestoreHint(savedPosition); } else { // 否则尝试从本地存储恢复播放进度 try { const progressKey = 'videoProgress_' + getVideoId(); const progressStr = localStorage.getItem(progressKey); if (progressStr && art.duration > 0) { const progress = JSON.parse(progressStr); if ( progress && typeof progress.position === 'number' && progress.position > 10 && progress.position < art.duration - 2 ) { art.currentTime = progress.position; showPositionRestoreHint(progress.position); } } } catch (e) { } } // 设置进度条点击监听 setupProgressBarPreciseClicks(); // 视频加载成功后,在稍微延迟后将其添加到观看历史 setTimeout(saveToHistory, 3000); // 启动定期保存播放进度 startProgressSaveInterval(); }) // 错误处理 art.on('video:error', function (error) { // 如果正在切换视频,忽略错误 if (window.isSwitchingVideo) { return; } // 隐藏所有加载指示器 const loadingElements = document.querySelectorAll('#player-loading, .player-loading-container'); loadingElements.forEach(el => { if (el) el.style.display = 'none'; }); showError('视频播放失败: ' + (error.message || '未知错误')); }); // 添加移动端长按三倍速播放功能 setupLongPressSpeedControl(); // 视频播放结束事件 art.on('video:ended', function () { videoHasEnded = true; clearVideoProgress(); // 如果自动播放下一集开启,且确实有下一集 if (autoplayEnabled && currentEpisodeIndex < currentEpisodes.length - 1) { // 稍长延迟以确保所有事件处理完成 setTimeout(() => { // 确认不是因为用户拖拽导致的假结束事件 playNextEpisode(); videoHasEnded = false; // 重置标志 }, 1000); } else { art.fullscreen = false; } }); // 添加双击全屏支持 art.on('video:playing', () => { // 绑定双击事件到视频容器 if (art.video) { art.video.addEventListener('dblclick', () => { art.fullscreen = !art.fullscreen; art.play(); }); } }); // 10秒后如果仍在加载,但不立即显示错误 setTimeout(function () { // 如果视频已经播放开始,则不显示错误 if (art && art.video && art.video.currentTime > 0) { return; } const loadingElement = document.getElementById('player-loading'); if (loadingElement && loadingElement.style.display !== 'none') { loadingElement.innerHTML = `
视频加载时间较长,请耐心等待...
如长时间无响应,请尝试其他视频源
`; } }, 10000); } // 自定义M3U8 Loader用于过滤广告 class CustomHlsJsLoader extends Hls.DefaultConfig.loader { constructor(config) { super(config); const load = this.load.bind(this); this.load = function (context, config, callbacks) { // 拦截manifest和level请求 if (context.type === 'manifest' || context.type === 'level') { const onSuccess = callbacks.onSuccess; callbacks.onSuccess = function (response, stats, context) { // 如果是m3u8文件,处理内容以移除广告分段 if (response.data && typeof response.data === 'string') { // 过滤掉广告段 - 实现更精确的广告过滤逻辑 response.data = filterAdsFromM3U8(response.data, true); } return onSuccess(response, stats, context); }; } // 执行原始load方法 load(context, config, callbacks); }; } } // 过滤可疑的广告内容 function filterAdsFromM3U8(m3u8Content, strictMode = false) { if (!m3u8Content) return ''; // 按行分割M3U8内容 const lines = m3u8Content.split('\n'); const filteredLines = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; // 只过滤#EXT-X-DISCONTINUITY标识 if (!line.includes('#EXT-X-DISCONTINUITY')) { filteredLines.push(line); } } return filteredLines.join('\n'); } // 显示错误 function showError(message) { // 在视频已经播放的情况下不显示错误 if (art && art.video && art.video.currentTime > 1) { return; } const loadingEl = document.getElementById('player-loading'); if (loadingEl) loadingEl.style.display = 'none'; const errorEl = document.getElementById('error'); if (errorEl) errorEl.style.display = 'flex'; const errorMsgEl = document.getElementById('error-message'); if (errorMsgEl) errorMsgEl.textContent = message; } // 更新集数信息 function updateEpisodeInfo() { if (currentEpisodes.length > 0) { document.getElementById('episodeInfo').textContent = `第 ${currentEpisodeIndex + 1}/${currentEpisodes.length} 集`; } else { document.getElementById('episodeInfo').textContent = '无集数信息'; } } // 更新按钮状态 function updateButtonStates() { const prevButton = document.getElementById('prevButton'); const nextButton = document.getElementById('nextButton'); // 处理上一集按钮 if (currentEpisodeIndex > 0) { prevButton.classList.remove('bg-gray-700', 'cursor-not-allowed'); prevButton.classList.add('bg-[#222]', 'hover:bg-[#333]'); prevButton.removeAttribute('disabled'); } else { prevButton.classList.add('bg-gray-700', 'cursor-not-allowed'); prevButton.classList.remove('bg-[#222]', 'hover:bg-[#333]'); prevButton.setAttribute('disabled', ''); } // 处理下一集按钮 if (currentEpisodeIndex < currentEpisodes.length - 1) { nextButton.classList.remove('bg-gray-700', 'cursor-not-allowed'); nextButton.classList.add('bg-[#222]', 'hover:bg-[#333]'); nextButton.removeAttribute('disabled'); } else { nextButton.classList.add('bg-gray-700', 'cursor-not-allowed'); nextButton.classList.remove('bg-[#222]', 'hover:bg-[#333]'); nextButton.setAttribute('disabled', ''); } } // 渲染集数按钮 function renderEpisodes() { const episodesList = document.getElementById('episodesList'); if (!episodesList) return; if (!currentEpisodes || currentEpisodes.length === 0) { episodesList.innerHTML = '
没有可用的集数
'; return; } const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes; let html = ''; episodes.forEach((episode, index) => { // 根据倒序状态计算真实的剧集索引 const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index; const isActive = realIndex === currentEpisodeIndex; html += ` `; }); episodesList.innerHTML = html; } // 播放指定集数 function playEpisode(index) { // 确保index在有效范围内 if (index < 0 || index >= currentEpisodes.length) { return; } // 保存当前播放进度(如果正在播放) if (art && art.video && !art.video.paused && !videoHasEnded) { saveCurrentProgress(); } // 清除进度保存计时器 if (progressSaveInterval) { clearInterval(progressSaveInterval); progressSaveInterval = null; } // 首先隐藏之前可能显示的错误 document.getElementById('error').style.display = 'none'; // 显示加载指示器 document.getElementById('player-loading').style.display = 'flex'; document.getElementById('player-loading').innerHTML = `
正在加载视频...
`; // 获取 sourceCode const urlParams2 = new URLSearchParams(window.location.search); const sourceCode = urlParams2.get('source_code'); // 准备切换剧集的URL const url = currentEpisodes[index]; // 更新当前剧集索引 currentEpisodeIndex = index; currentVideoUrl = url; videoHasEnded = false; // 重置视频结束标志 clearVideoProgress(); // 更新URL参数(不刷新页面) const currentUrl = new URL(window.location.href); currentUrl.searchParams.set('index', index); currentUrl.searchParams.set('url', url); currentUrl.searchParams.delete('position'); window.history.replaceState({}, '', currentUrl.toString()); if (isWebkit) { initPlayer(url); } else { art.switch = url; } // 更新UI updateEpisodeInfo(); updateButtonStates(); renderEpisodes(); // 重置用户点击位置记录 userClickedPosition = null; // 三秒后保存到历史记录 setTimeout(() => saveToHistory(), 3000); } // 播放上一集 function playPreviousEpisode() { if (currentEpisodeIndex > 0) { playEpisode(currentEpisodeIndex - 1); } } // 播放下一集 function playNextEpisode() { if (currentEpisodeIndex < currentEpisodes.length - 1) { playEpisode(currentEpisodeIndex + 1); } } // 复制播放链接 function copyLinks() { // 尝试从URL中获取参数 const urlParams = new URLSearchParams(window.location.search); const linkUrl = urlParams.get('url') || ''; if (linkUrl !== '') { navigator.clipboard.writeText(linkUrl).then(() => { showToast('播放链接已复制', 'success'); }).catch(err => { showToast('复制失败,请检查浏览器权限', 'error'); }); } } // 切换集数排序 function toggleEpisodeOrder() { episodesReversed = !episodesReversed; // 保存到localStorage localStorage.setItem('episodesReversed', episodesReversed); // 重新渲染集数列表 renderEpisodes(); // 更新排序按钮 updateOrderButton(); } // 更新排序按钮状态 function updateOrderButton() { const orderText = document.getElementById('orderText'); const orderIcon = document.getElementById('orderIcon'); if (orderText && orderIcon) { orderText.textContent = episodesReversed ? '正序排列' : '倒序排列'; orderIcon.style.transform = episodesReversed ? 'rotate(180deg)' : ''; } } // 设置进度条准确点击处理 function setupProgressBarPreciseClicks() { // 查找DPlayer的进度条元素 const progressBar = document.querySelector('.dplayer-bar-wrap'); if (!progressBar || !art || !art.video) return; // 移除可能存在的旧事件监听器 progressBar.removeEventListener('mousedown', handleProgressBarClick); // 添加新的事件监听器 progressBar.addEventListener('mousedown', handleProgressBarClick); // 在移动端也添加触摸事件支持 progressBar.removeEventListener('touchstart', handleProgressBarTouch); progressBar.addEventListener('touchstart', handleProgressBarTouch); // 处理进度条点击 function handleProgressBarClick(e) { if (!art || !art.video) return; // 计算点击位置相对于进度条的比例 const rect = e.currentTarget.getBoundingClientRect(); const percentage = (e.clientX - rect.left) / rect.width; // 计算点击位置对应的视频时间 const duration = art.video.duration; let clickTime = percentage * duration; // 处理视频接近结尾的情况 if (duration - clickTime < 1) { // 如果点击位置非常接近结尾,稍微往前移一点 clickTime = Math.min(clickTime, duration - 1.5); } // 记录用户点击的位置 userClickedPosition = clickTime; // 阻止默认事件传播,避免DPlayer内部逻辑将视频跳至末尾 e.stopPropagation(); // 直接设置视频时间 art.seek(clickTime); } // 处理移动端触摸事件 function handleProgressBarTouch(e) { if (!art || !art.video || !e.touches[0]) return; const touch = e.touches[0]; const rect = e.currentTarget.getBoundingClientRect(); const percentage = (touch.clientX - rect.left) / rect.width; const duration = art.video.duration; let clickTime = percentage * duration; // 处理视频接近结尾的情况 if (duration - clickTime < 1) { clickTime = Math.min(clickTime, duration - 1.5); } // 记录用户点击的位置 userClickedPosition = clickTime; e.stopPropagation(); art.seek(clickTime); } } // 在播放器初始化后添加视频到历史记录 function saveToHistory() { // 确保 currentEpisodes 非空且有当前视频URL if (!currentEpisodes || currentEpisodes.length === 0 || !currentVideoUrl) { return; } // 尝试从URL中获取参数 const urlParams = new URLSearchParams(window.location.search); const sourceName = urlParams.get('source') || ''; const sourceCode = urlParams.get('source') || ''; const id_from_params = urlParams.get('id'); // Get video ID from player URL (passed as 'id') // 获取当前播放进度 let currentPosition = 0; let videoDuration = 0; if (art && art.video) { currentPosition = art.video.currentTime; videoDuration = art.video.duration; } // Define a show identifier: Prioritize sourceName_id, fallback to first episode URL or current video URL let show_identifier_for_video_info; if (sourceName && id_from_params) { show_identifier_for_video_info = `${sourceName}_${id_from_params}`; } else { show_identifier_for_video_info = (currentEpisodes && currentEpisodes.length > 0) ? currentEpisodes[0] : currentVideoUrl; } // 构建要保存的视频信息对象 const videoInfo = { title: currentVideoTitle, directVideoUrl: currentVideoUrl, // Current episode's direct URL 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)}`, episodeIndex: currentEpisodeIndex, sourceName: sourceName, vod_id: id_from_params || '', // Store the ID from params as vod_id in history item sourceCode: sourceCode, showIdentifier: show_identifier_for_video_info, // Identifier for the show/series timestamp: Date.now(), playbackPosition: currentPosition, duration: videoDuration, episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : [] }; try { const history = JSON.parse(localStorage.getItem('viewingHistory') || '[]'); // 检查是否已经存在相同的系列记录 (基于标题、来源和 showIdentifier) const existingIndex = history.findIndex(item => item.title === videoInfo.title && item.sourceName === videoInfo.sourceName && item.showIdentifier === videoInfo.showIdentifier ); if (existingIndex !== -1) { // 存在则更新现有记录的当前集数、时间戳、播放进度和URL等 const existingItem = history[existingIndex]; existingItem.episodeIndex = videoInfo.episodeIndex; existingItem.timestamp = videoInfo.timestamp; existingItem.sourceName = videoInfo.sourceName; // Should be consistent, but update just in case existingItem.sourceCode = videoInfo.sourceCode; existingItem.vod_id = videoInfo.vod_id; // Update URLs to reflect the current episode being watched existingItem.directVideoUrl = videoInfo.directVideoUrl; // Current episode's direct URL existingItem.url = videoInfo.url; // Player link for the current episode // 更新播放进度信息 existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0); existingItem.duration = videoInfo.duration || existingItem.duration; // 更新集数列表(如果新的集数列表与存储的不同,例如集数增加了) if (videoInfo.episodes && videoInfo.episodes.length > 0) { if (!existingItem.episodes || !Array.isArray(existingItem.episodes) || existingItem.episodes.length !== videoInfo.episodes.length || !videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) { // Basic check for content change existingItem.episodes = [...videoInfo.episodes]; // Deep copy } } // 移到最前面 const updatedItem = history.splice(existingIndex, 1)[0]; history.unshift(updatedItem); } else { // 添加新记录到最前面 history.unshift(videoInfo); } // 限制历史记录数量为50条 if (history.length > 50) history.splice(50); localStorage.setItem('viewingHistory', JSON.stringify(history)); } catch (e) { } } // 显示恢复位置提示 function showPositionRestoreHint(position) { if (!position || position < 10) return; // 创建提示元素 const hint = document.createElement('div'); hint.className = 'position-restore-hint'; hint.innerHTML = `
已从 ${formatTime(position)} 继续播放
`; // 添加到播放器容器 const playerContainer = document.querySelector('.player-container'); // Ensure this selector is correct if (playerContainer) { // Check if playerContainer exists playerContainer.appendChild(hint); } else { return; // Exit if container not found } // 显示提示 setTimeout(() => { hint.classList.add('show'); // 3秒后隐藏 setTimeout(() => { hint.classList.remove('show'); setTimeout(() => hint.remove(), 300); }, 3000); }, 100); } // 格式化时间为 mm:ss 格式 function formatTime(seconds) { if (isNaN(seconds)) return '00:00'; const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; } // 开始定期保存播放进度 function startProgressSaveInterval() { // 清除可能存在的旧计时器 if (progressSaveInterval) { clearInterval(progressSaveInterval); } // 每30秒保存一次播放进度 progressSaveInterval = setInterval(saveCurrentProgress, 30000); } // 保存当前播放进度 function saveCurrentProgress() { if (!art || !art.video) return; const currentTime = art.video.currentTime; const duration = art.video.duration; if (!duration || currentTime < 1) return; // 在localStorage中保存进度 const progressKey = `videoProgress_${getVideoId()}`; const progressData = { position: currentTime, duration: duration, timestamp: Date.now() }; try { localStorage.setItem(progressKey, JSON.stringify(progressData)); // --- 新增:同步更新 viewingHistory 中的进度 --- try { const historyRaw = localStorage.getItem('viewingHistory'); if (historyRaw) { const history = JSON.parse(historyRaw); // 用 title + 集数索引唯一标识 const idx = history.findIndex(item => item.title === currentVideoTitle && (item.episodeIndex === undefined || item.episodeIndex === currentEpisodeIndex) ); if (idx !== -1) { // 只在进度有明显变化时才更新,减少写入 if ( Math.abs((history[idx].playbackPosition || 0) - currentTime) > 2 || Math.abs((history[idx].duration || 0) - duration) > 2 ) { history[idx].playbackPosition = currentTime; history[idx].duration = duration; history[idx].timestamp = Date.now(); localStorage.setItem('viewingHistory', JSON.stringify(history)); } } } } catch (e) { } } catch (e) { } } // 设置移动端长按三倍速播放功能 function setupLongPressSpeedControl() { if (!art || !art.video) return; const playerElement = document.getElementById('player'); let longPressTimer = null; let originalPlaybackRate = 1.0; let isLongPress = false; // 显示快速提示 function showSpeedHint(speed) { showShortcutHint(`${speed}倍速`, 'right'); } // 禁用右键 playerElement.oncontextmenu = () => { // 检测是否为移动设备 const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); // 只在移动设备上禁用右键 if (isMobile) { const dplayerMenu = document.querySelector(".dplayer-menu"); const dplayerMask = document.querySelector(".dplayer-mask"); if (dplayerMenu) dplayerMenu.style.display = "none"; if (dplayerMask) dplayerMask.style.display = "none"; return false; } return true; // 在桌面设备上允许右键菜单 }; // 触摸开始事件 playerElement.addEventListener('touchstart', function (e) { // 检查视频是否正在播放,如果没有播放则不触发长按功能 if (art.video.paused) { return; // 视频暂停时不触发长按功能 } // 保存原始播放速度 originalPlaybackRate = art.video.playbackRate; // 设置长按计时器 longPressTimer = setTimeout(() => { // 再次检查视频是否仍在播放 if (art.video.paused) { clearTimeout(longPressTimer); longPressTimer = null; return; } // 长按超过500ms,设置为3倍速 art.video.playbackRate = 3.0; isLongPress = true; showSpeedHint(3.0); // 只在确认为长按时阻止默认行为 e.preventDefault(); }, 500); }, { passive: false }); // 触摸结束事件 playerElement.addEventListener('touchend', function (e) { // 清除长按计时器 if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } // 如果是长按状态,恢复原始播放速度 if (isLongPress) { art.video.playbackRate = originalPlaybackRate; isLongPress = false; showSpeedHint(originalPlaybackRate); // 阻止长按后的点击事件 e.preventDefault(); } // 如果不是长按,则允许正常的点击事件(暂停/播放) }); // 触摸取消事件 playerElement.addEventListener('touchcancel', function () { // 清除长按计时器 if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } // 如果是长按状态,恢复原始播放速度 if (isLongPress) { art.video.playbackRate = originalPlaybackRate; isLongPress = false; } }); // 触摸移动事件 - 防止在长按时触发页面滚动 playerElement.addEventListener('touchmove', function (e) { if (isLongPress) { e.preventDefault(); } }, { passive: false }); // 视频暂停时取消长按状态 art.video.addEventListener('pause', function () { if (isLongPress) { art.video.playbackRate = originalPlaybackRate; isLongPress = false; } if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }); } // 清除视频进度记录 function clearVideoProgress() { const progressKey = `videoProgress_${getVideoId()}`; try { localStorage.removeItem(progressKey); } catch (e) { } } // 获取视频唯一标识 function getVideoId() { // 使用视频标题和集数索引作为唯一标识 // If currentVideoUrl is available and more unique, prefer it. Otherwise, fallback. if (currentVideoUrl) { return `${encodeURIComponent(currentVideoUrl)}`; } return `${encodeURIComponent(currentVideoTitle)}_${currentEpisodeIndex}`; } let controlsLocked = false; function toggleControlsLock() { const container = document.getElementById('playerContainer'); controlsLocked = !controlsLocked; container.classList.toggle('controls-locked', controlsLocked); const icon = document.getElementById('lockIcon'); // 切换图标:锁 / 解锁 icon.innerHTML = controlsLocked ? '' : ''; } // 支持在iframe中关闭播放器 function closeEmbeddedPlayer() { try { if (window.self !== window.top) { // 如果在iframe中,尝试调用父窗口的关闭方法 if (window.parent && typeof window.parent.closeVideoPlayer === 'function') { window.parent.closeVideoPlayer(); return true; } } } catch (e) { console.error('尝试关闭嵌入式播放器失败:', e); } return false; } function renderResourceInfoBar() { // 获取容器元素 const container = document.getElementById('resourceInfoBarContainer'); if (!container) { console.error('找不到资源信息卡片容器'); return; } // 获取当前视频 source_code const urlParams = new URLSearchParams(window.location.search); const currentSource = urlParams.get('source') || ''; // 显示临时加载状态 container.innerHTML = `
加载中... -
`; // 查找当前源名称,从 API_SITES 和 custom_api 中查找即可 let resourceName = currentSource if (currentSource && API_SITES[currentSource]) { resourceName = API_SITES[currentSource].name; } if (resourceName === currentSource) { const customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); const customIndex = parseInt(currentSource.replace('custom_', ''), 10); if (customAPIs[customIndex]) { resourceName = customAPIs[customIndex].name || '自定义资源'; } } container.innerHTML = `
${resourceName} ${currentEpisodes.length} 个视频
`; } // 测试视频源速率的函数 async function testVideoSourceSpeed(sourceKey, vodId) { try { const startTime = performance.now(); // 构建API参数 let apiParams = ''; if (sourceKey.startsWith('custom_')) { const customIndex = sourceKey.replace('custom_', ''); const customApi = getCustomApiInfo(customIndex); if (!customApi) { return { speed: -1, error: 'API配置无效' }; } if (customApi.detail) { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom'; } else { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom'; } } else { apiParams = '&source=' + sourceKey; } // 添加时间戳防止缓存 const timestamp = new Date().getTime(); const cacheBuster = `&_t=${timestamp}`; // 获取视频详情 const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`, { method: 'GET', cache: 'no-cache' }); if (!response.ok) { return { speed: -1, error: '获取失败' }; } const data = await response.json(); if (!data.episodes || data.episodes.length === 0) { return { speed: -1, error: '无播放源' }; } // 测试第一个播放链接的响应速度 const firstEpisodeUrl = data.episodes[0]; if (!firstEpisodeUrl) { return { speed: -1, error: '链接无效' }; } // 测试视频链接响应时间 const videoTestStart = performance.now(); try { const videoResponse = await fetch(firstEpisodeUrl, { method: 'HEAD', mode: 'no-cors', cache: 'no-cache', signal: AbortSignal.timeout(5000) // 5秒超时 }); const videoTestEnd = performance.now(); const totalTime = videoTestEnd - startTime; // 返回总响应时间(毫秒) return { speed: Math.round(totalTime), episodes: data.episodes.length, error: null }; } catch (videoError) { // 如果视频链接测试失败,只返回API响应时间 const apiTime = performance.now() - startTime; return { speed: Math.round(apiTime), episodes: data.episodes.length, error: null, note: 'API响应' }; } } catch (error) { return { speed: -1, error: error.name === 'AbortError' ? '超时' : '测试失败' }; } } // 格式化速度显示 function formatSpeedDisplay(speedResult) { if (speedResult.speed === -1) { return `❌ ${speedResult.error}`; } const speed = speedResult.speed; let className = 'speed-indicator good'; let icon = '🟢'; if (speed > 2000) { className = 'speed-indicator poor'; icon = '🔴'; } else if (speed > 1000) { className = 'speed-indicator medium'; icon = '🟡'; } const note = speedResult.note ? ` (${speedResult.note})` : ''; return `${icon} ${speed}ms${note}`; } async function showSwitchResourceModal() { const urlParams = new URLSearchParams(window.location.search); const currentSourceCode = urlParams.get('source'); const currentVideoId = urlParams.get('id'); const modal = document.getElementById('modal'); const modalTitle = document.getElementById('modalTitle'); const modalContent = document.getElementById('modalContent'); modalTitle.innerHTML = `${currentVideoTitle}`; modalContent.innerHTML = '
正在加载资源列表...
'; modal.classList.remove('hidden'); // 搜索 const resourceOptions = selectedAPIs.map((curr) => { if (API_SITES[curr]) { return { key: curr, name: API_SITES[curr].name }; } const customIndex = parseInt(curr.replace('custom_', ''), 10); if (customAPIs[customIndex]) { return { key: curr, name: customAPIs[customIndex].name || '自定义资源' }; } return { key: curr, name: '未知资源' }; }); let allResults = {}; await Promise.all(resourceOptions.map(async (opt) => { let queryResult = await searchByAPIAndKeyWord(opt.key, currentVideoTitle); if (queryResult.length == 0) { return } // 优先取完全同名资源,否则默认取第一个 let result = queryResult[0] queryResult.forEach((res) => { if (res.vod_name == currentVideoTitle) { result = res; } }) allResults[opt.key] = result; })); // 更新状态显示:开始速率测试 modalContent.innerHTML = '
正在测试各资源速率...
'; // 同时测试所有资源的速率 const speedResults = {}; await Promise.all(Object.entries(allResults).map(async ([sourceKey, result]) => { if (result) { speedResults[sourceKey] = await testVideoSourceSpeed(sourceKey, result.vod_id); } })); // 对结果进行排序 const sortedResults = Object.entries(allResults).sort(([keyA, resultA], [keyB, resultB]) => { // 当前播放的源放在最前面 const isCurrentA = String(keyA) === String(currentSourceCode) && String(resultA.vod_id) === String(currentVideoId); const isCurrentB = String(keyB) === String(currentSourceCode) && String(resultB.vod_id) === String(currentVideoId); if (isCurrentA && !isCurrentB) return -1; if (!isCurrentA && isCurrentB) return 1; // 其余按照速度排序,速度快的在前面(速度为-1表示失败,排到最后) const speedA = speedResults[keyA]?.speed || 99999; const speedB = speedResults[keyB]?.speed || 99999; if (speedA === -1 && speedB !== -1) return 1; if (speedA !== -1 && speedB === -1) return -1; if (speedA === -1 && speedB === -1) return 0; return speedA - speedB; }); // 渲染资源列表 let html = '
'; for (const [sourceKey, result] of sortedResults) { if (!result) continue; // 修复 isCurrentSource 判断,确保类型一致 const isCurrentSource = String(sourceKey) === String(currentSourceCode) && String(result.vod_id) === String(currentVideoId); const sourceName = resourceOptions.find(opt => opt.key === sourceKey)?.name || '未知资源'; const speedResult = speedResults[sourceKey] || { speed: -1, error: '未测试' }; html += `
${result.vod_name}
${formatSpeedDisplay(speedResult)}
${result.vod_name}
${sourceName}
${speedResult.episodes ? `${speedResult.episodes}集` : ''}
${isCurrentSource ? `
当前播放
` : ''}
`; } html += '
'; modalContent.innerHTML = html; } // 切换资源的函数 async function switchToResource(sourceKey, vodId) { // 关闭模态框 document.getElementById('modal').classList.add('hidden'); showLoading(); try { // 构建API参数 let apiParams = ''; // 处理自定义API源 if (sourceKey.startsWith('custom_')) { const customIndex = sourceKey.replace('custom_', ''); const customApi = getCustomApiInfo(customIndex); if (!customApi) { showToast('自定义API配置无效', 'error'); hideLoading(); return; } // 传递 detail 字段 if (customApi.detail) { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&customDetail=' + encodeURIComponent(customApi.detail) + '&source=custom'; } else { apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom'; } } else { // 内置API apiParams = '&source=' + sourceKey; } // Add a timestamp to prevent caching const timestamp = new Date().getTime(); const cacheBuster = `&_t=${timestamp}`; const response = await fetch(`/api/detail?id=${encodeURIComponent(vodId)}${apiParams}${cacheBuster}`); const data = await response.json(); if (!data.episodes || data.episodes.length === 0) { showToast('未找到播放资源', 'error'); hideLoading(); return; } // 获取当前播放的集数索引 const currentIndex = currentEpisodeIndex; // 确定要播放的集数索引 let targetIndex = 0; if (currentIndex < data.episodes.length) { // 如果当前集数在新资源中存在,则使用相同集数 targetIndex = currentIndex; } // 获取目标集数的URL const targetUrl = data.episodes[targetIndex]; // 构建播放页面URL const watchUrl = `player.html?id=${vodId}&source=${sourceKey}&url=${encodeURIComponent(targetUrl)}&index=${targetIndex}&title=${encodeURIComponent(currentVideoTitle)}`; // 保存当前状态到localStorage try { localStorage.setItem('currentVideoTitle', data.vod_name || '未知视频'); localStorage.setItem('currentEpisodes', JSON.stringify(data.episodes)); localStorage.setItem('currentEpisodeIndex', targetIndex); localStorage.setItem('currentSourceCode', sourceKey); localStorage.setItem('lastPlayTime', Date.now()); } catch (e) { console.error('保存播放状态失败:', e); } // 跳转到播放页面 window.location.href = watchUrl; } catch (error) { console.error('切换资源失败:', error); showToast('切换资源失败,请稍后重试', 'error'); } finally { hideLoading(); } } ================================================ FILE: js/proxy-auth.js ================================================ /** * 代理请求鉴权模块 * 为代理请求添加基于 PASSWORD 的鉴权机制 */ // 从全局配置获取密码哈希(如果存在) let cachedPasswordHash = null; /** * 获取当前会话的密码哈希 */ async function getPasswordHash() { if (cachedPasswordHash) { return cachedPasswordHash; } // 1. 优先从已存储的代理鉴权哈希获取 const storedHash = localStorage.getItem('proxyAuthHash'); if (storedHash) { cachedPasswordHash = storedHash; return storedHash; } // 2. 尝试从密码验证状态获取(password.js 验证后存储的哈希) const passwordVerified = localStorage.getItem('passwordVerified'); const storedPasswordHash = localStorage.getItem('passwordHash'); if (passwordVerified === 'true' && storedPasswordHash) { localStorage.setItem('proxyAuthHash', storedPasswordHash); cachedPasswordHash = storedPasswordHash; return storedPasswordHash; } // 3. 尝试从用户输入的密码生成哈希 const userPassword = localStorage.getItem('userPassword'); if (userPassword) { try { // 动态导入 sha256 函数 const { sha256 } = await import('./sha256.js'); const hash = await sha256(userPassword); localStorage.setItem('proxyAuthHash', hash); cachedPasswordHash = hash; return hash; } catch (error) { console.error('生成密码哈希失败:', error); } } // 4. 如果用户没有设置密码,尝试使用环境变量中的密码哈希 if (window.__ENV__ && window.__ENV__.PASSWORD) { cachedPasswordHash = window.__ENV__.PASSWORD; return window.__ENV__.PASSWORD; } return null; } /** * 为代理请求URL添加鉴权参数 */ async function addAuthToProxyUrl(url) { try { const hash = await getPasswordHash(); if (!hash) { console.warn('无法获取密码哈希,代理请求可能失败'); return url; } // 添加时间戳防止重放攻击 const timestamp = Date.now(); // 检查URL是否已包含查询参数 const separator = url.includes('?') ? '&' : '?'; return `${url}${separator}auth=${encodeURIComponent(hash)}&t=${timestamp}`; } catch (error) { console.error('添加代理鉴权失败:', error); return url; } } /** * 验证代理请求的鉴权 */ function validateProxyAuth(authHash, serverPasswordHash, timestamp) { if (!authHash || !serverPasswordHash) { return false; } // 验证哈希是否匹配 if (authHash !== serverPasswordHash) { return false; } // 验证时间戳(10分钟有效期) const now = Date.now(); const maxAge = 10 * 60 * 1000; // 10分钟 if (timestamp && (now - parseInt(timestamp)) > maxAge) { console.warn('代理请求时间戳过期'); return false; } return true; } /** * 清除缓存的鉴权信息 */ function clearAuthCache() { cachedPasswordHash = null; localStorage.removeItem('proxyAuthHash'); } // 监听密码变化,清除缓存 window.addEventListener('storage', (e) => { if (e.key === 'userPassword' || (window.PASSWORD_CONFIG && e.key === window.PASSWORD_CONFIG.localStorageKey)) { clearAuthCache(); } }); // 导出函数 window.ProxyAuth = { addAuthToProxyUrl, validateProxyAuth, clearAuthCache, getPasswordHash }; ================================================ FILE: js/pwa-register.js ================================================ // PWA 注册 if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/service-worker.js'); }); } ================================================ FILE: js/search.js ================================================ async function searchByAPIAndKeyWord(apiId, query) { try { let apiUrl, apiName, apiBaseUrl; // 处理自定义API if (apiId.startsWith('custom_')) { const customIndex = apiId.replace('custom_', ''); const customApi = getCustomApiInfo(customIndex); if (!customApi) return []; apiBaseUrl = customApi.url; apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); apiName = customApi.name; } else { // 内置API if (!API_SITES[apiId]) return []; apiBaseUrl = API_SITES[apiId].api; apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); apiName = API_SITES[apiId].name; } // 添加超时处理 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); // 添加鉴权参数到代理URL const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) : PROXY_URL + encodeURIComponent(apiUrl); const response = await fetch(proxiedUrl, { headers: API_CONFIG.search.headers, signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { return []; } const data = await response.json(); if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) { return []; } // 处理第一页结果 const results = data.list.map(item => ({ ...item, source_name: apiName, source_code: apiId, api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined })); // 获取总页数 const pageCount = data.pagecount || 1; // 确定需要获取的额外页数 (最多获取maxPages页) const pagesToFetch = Math.min(pageCount - 1, API_CONFIG.search.maxPages - 1); // 如果有额外页数,获取更多页的结果 if (pagesToFetch > 0) { const additionalPagePromises = []; for (let page = 2; page <= pagesToFetch + 1; page++) { // 构建分页URL const pageUrl = apiBaseUrl + API_CONFIG.search.pagePath .replace('{query}', encodeURIComponent(query)) .replace('{page}', page); // 创建获取额外页的Promise const pagePromise = (async () => { try { const pageController = new AbortController(); const pageTimeoutId = setTimeout(() => pageController.abort(), 15000); // 添加鉴权参数到代理URL const proxiedPageUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(pageUrl)) : PROXY_URL + encodeURIComponent(pageUrl); const pageResponse = await fetch(proxiedPageUrl, { headers: API_CONFIG.search.headers, signal: pageController.signal }); clearTimeout(pageTimeoutId); if (!pageResponse.ok) return []; const pageData = await pageResponse.json(); if (!pageData || !pageData.list || !Array.isArray(pageData.list)) return []; // 处理当前页结果 return pageData.list.map(item => ({ ...item, source_name: apiName, source_code: apiId, api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined })); } catch (error) { console.warn(`API ${apiId} 第${page}页搜索失败:`, error); return []; } })(); additionalPagePromises.push(pagePromise); } // 等待所有额外页的结果 const additionalResults = await Promise.all(additionalPagePromises); // 合并所有页的结果 additionalResults.forEach(pageResults => { if (pageResults.length > 0) { results.push(...pageResults); } }); } return results; } catch (error) { console.warn(`API ${apiId} 搜索失败:`, error); return []; } } ================================================ FILE: js/sha256.js ================================================ export async function sha256(message) { const msgBuffer = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } ================================================ FILE: js/ui.js ================================================ // UI相关函数 function toggleSettings(e) { // 强化的密码保护校验 - 防止绕过 try { if (window.ensurePasswordProtection) { window.ensurePasswordProtection(); } else { // 兼容性检查 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } } } catch (error) { console.warn('Password protection check failed:', error.message); return; } // 阻止事件冒泡,防止触发document的点击事件 e && e.stopPropagation(); const panel = document.getElementById('settingsPanel'); panel.classList.toggle('show'); } // 改进的Toast显示函数 - 支持队列显示多个Toast const toastQueue = []; let isShowingToast = false; function showToast(message, type = 'error') { // 首先确保toast元素存在 let toast = document.getElementById('toast'); let toastMessage = document.getElementById('toastMessage'); // 如果toast元素不存在,创建它 if (!toast) { toast = document.createElement('div'); toast.id = 'toast'; 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'; toast.style = 'z-index: 2147483647' toastMessage = document.createElement('p'); toastMessage.id = 'toastMessage'; toast.appendChild(toastMessage); document.body.appendChild(toast); } // 将新的toast添加到队列 toastQueue.push({ message, type }); // 如果当前没有显示中的toast,则开始显示 if (!isShowingToast) { showNextToast(); } } function showNextToast() { if (toastQueue.length === 0) { isShowingToast = false; return; } isShowingToast = true; const { message, type } = toastQueue.shift(); const toast = document.getElementById('toast'); const toastMessage = document.getElementById('toastMessage'); const bgColors = { 'error': 'bg-red-500', 'success': 'bg-green-500', 'info': 'bg-blue-500', 'warning': 'bg-yellow-500' }; const bgColor = bgColors[type] || bgColors.error; 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`; toastMessage.textContent = message; // 显示提示 toast.style.opacity = '1'; toast.style.transform = 'translateX(-50%) translateY(0)'; // 3秒后自动隐藏 setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(-50%) translateY(-100%)'; // 等待动画完成后显示下一个toast setTimeout(() => { showNextToast(); }, 300); }, 3000); } // 添加显示/隐藏 loading 的函数 let loadingTimeoutId = null; function showLoading(message = '加载中...') { // 清除任何现有的超时 if (loadingTimeoutId) { clearTimeout(loadingTimeoutId); } const loading = document.getElementById('loading'); const messageEl = loading.querySelector('p'); messageEl.textContent = message; loading.style.display = 'flex'; // 设置30秒后自动关闭loading,防止无限loading loadingTimeoutId = setTimeout(() => { hideLoading(); showToast('操作超时,请稍后重试', 'warning'); }, 30000); } function hideLoading() { // 清除超时 if (loadingTimeoutId) { clearTimeout(loadingTimeoutId); loadingTimeoutId = null; } const loading = document.getElementById('loading'); loading.style.display = 'none'; } function updateSiteStatus(isAvailable) { const statusEl = document.getElementById('siteStatus'); if (isAvailable) { statusEl.innerHTML = ' 可用'; } else { statusEl.innerHTML = ' 不可用'; } } function closeModal() { document.getElementById('modal').classList.add('hidden'); // 清除 iframe 内容 document.getElementById('modalContent').innerHTML = ''; } // 获取搜索历史的增强版本 - 支持新旧格式 function getSearchHistory() { try { const data = localStorage.getItem(SEARCH_HISTORY_KEY); if (!data) return []; const parsed = JSON.parse(data); // 检查是否是数组 if (!Array.isArray(parsed)) return []; // 支持旧格式(字符串数组)和新格式(对象数组) return parsed.map(item => { if (typeof item === 'string') { return { text: item, timestamp: 0 }; } return item; }).filter(item => item && item.text); } catch (e) { console.error('获取搜索历史出错:', e); return []; } } // 保存搜索历史的增强版本 - 添加时间戳和最大数量限制,现在缓存2个月 function saveSearchHistory(query) { if (!query || !query.trim()) return; // 清理输入,防止XSS query = query.trim().substring(0, 50).replace(//g, '>'); let history = getSearchHistory(); // 获取当前时间 const now = Date.now(); // 过滤掉超过2个月的记录(约60天,60*24*60*60*1000 = 5184000000毫秒) history = history.filter(item => typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000) ); // 删除已存在的相同项 history = history.filter(item => typeof item === 'object' ? item.text !== query : item !== query ); // 新项添加到开头,包含时间戳 history.unshift({ text: query, timestamp: now }); // 限制历史记录数量 if (history.length > MAX_HISTORY_ITEMS) { history = history.slice(0, MAX_HISTORY_ITEMS); } try { localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)); } catch (e) { console.error('保存搜索历史失败:', e); // 如果存储失败(可能是localStorage已满),尝试清理旧数据 try { localStorage.removeItem(SEARCH_HISTORY_KEY); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3))); } catch (e2) { console.error('再次保存搜索历史失败:', e2); } } renderSearchHistory(); } // 渲染最近搜索历史的增强版本 function renderSearchHistory() { const historyContainer = document.getElementById('recentSearches'); if (!historyContainer) return; const history = getSearchHistory(); if (history.length === 0) { historyContainer.innerHTML = ''; return; } // 创建一个包含标题和清除按钮的行 historyContainer.innerHTML = `
最近搜索:
`; history.forEach(item => { const tag = document.createElement('button'); tag.className = 'search-tag flex items-center gap-1'; const textSpan = document.createElement('span'); textSpan.textContent = item.text; tag.appendChild(textSpan); // 添加删除按钮 const deleteButton = document.createElement('span'); deleteButton.className = 'pl-1 text-gray-500 hover:text-red-500 transition-colors'; deleteButton.innerHTML = ''; deleteButton.onclick = function(e) { // 阻止事件冒泡,避免触发搜索 e.stopPropagation(); // 删除对应历史记录 deleteSingleSearchHistory(item.text); // 重新渲染搜索历史 renderSearchHistory(); }; tag.appendChild(deleteButton); // 添加时间提示(如果有时间戳) if (item.timestamp) { const date = new Date(item.timestamp); tag.title = `搜索于: ${date.toLocaleString()}`; } tag.onclick = function() { document.getElementById('searchInput').value = item.text; search(); }; historyContainer.appendChild(tag); }); } // 删除单条搜索历史记录 function deleteSingleSearchHistory(query) { // 当url中包含删除的关键词时,页面刷新后会自动加入历史记录,导致误认为删除功能有bug。此问题无需修复,功能无实际影响。 try { let history = getSearchHistory(); // 过滤掉要删除的记录 history = history.filter(item => item.text !== query); console.log('更新后的搜索历史:', history); localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history)); } catch (e) { console.error('删除单条搜索历史失败:', e); showToast('删除单条搜索历史失败', 'error'); } } // 增加清除搜索历史功能 function clearSearchHistory() { // 密码保护校验 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } try { localStorage.removeItem(SEARCH_HISTORY_KEY); renderSearchHistory(); showToast('搜索历史已清除', 'success'); } catch (e) { console.error('清除搜索历史失败:', e); showToast('清除搜索历史失败:', 'error'); } } // 历史面板相关函数 function toggleHistory(e) { // 密码保护校验 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } if (e) e.stopPropagation(); const panel = document.getElementById('historyPanel'); if (panel) { panel.classList.toggle('show'); // 如果打开了历史记录面板,则加载历史数据 if (panel.classList.contains('show')) { loadViewingHistory(); } // 如果设置面板是打开的,则关闭它 const settingsPanel = document.getElementById('settingsPanel'); if (settingsPanel && settingsPanel.classList.contains('show')) { settingsPanel.classList.remove('show'); } } } // 格式化时间戳为友好的日期时间格式 function formatTimestamp(timestamp) { const date = new Date(timestamp); const now = new Date(); const diff = now - date; // 小于1小时,显示"X分钟前" if (diff < 3600000) { const minutes = Math.floor(diff / 60000); return minutes <= 0 ? '刚刚' : `${minutes}分钟前`; } // 小于24小时,显示"X小时前" if (diff < 86400000) { const hours = Math.floor(diff / 3600000); return `${hours}小时前`; } // 小于7天,显示"X天前" if (diff < 604800000) { const days = Math.floor(diff / 86400000); return `${days}天前`; } // 其他情况,显示完整日期 const year = date.getFullYear(); const month = (date.getMonth() + 1).toString().padStart(2, '0'); const day = date.getDate().toString().padStart(2, '0'); const hour = date.getHours().toString().padStart(2, '0'); const minute = date.getMinutes().toString().padStart(2, '0'); return `${year}-${month}-${day} ${hour}:${minute}`; } // 获取观看历史记录 function getViewingHistory() { try { const data = localStorage.getItem('viewingHistory'); return data ? JSON.parse(data) : []; } catch (e) { console.error('获取观看历史失败:', e); return []; } } // 加载观看历史并渲染 function loadViewingHistory() { const historyList = document.getElementById('historyList'); if (!historyList) return; const history = getViewingHistory(); if (history.length === 0) { historyList.innerHTML = `
暂无观看记录
`; return; } // 渲染历史记录 historyList.innerHTML = history.map(item => { // 防止XSS const safeTitle = item.title .replace(//g, '>') .replace(/"/g, '"'); const safeSource = item.sourceName ? item.sourceName.replace(//g, '>').replace(/"/g, '"') : '未知来源'; const episodeText = item.episodeIndex !== undefined ? `第${item.episodeIndex + 1}集` : ''; // 格式化剧集信息 let episodeInfoHtml = ''; if (item.episodes && Array.isArray(item.episodes) && item.episodes.length > 0) { const totalEpisodes = item.episodes.length; const syncStatus = item.lastSyncTime ? `` : ``; episodeInfoHtml = `共${totalEpisodes}集 ${syncStatus}`; } // 格式化进度信息 let progressHtml = ''; if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) { const percent = Math.round((item.playbackPosition / item.duration) * 100); const formattedTime = formatPlaybackTime(item.playbackPosition); const formattedDuration = formatPlaybackTime(item.duration); progressHtml = `
${formattedTime} / ${formattedDuration}
`; } // 为防止XSS,使用encodeURIComponent编码URL const safeURL = encodeURIComponent(item.url); // 构建历史记录项HTML,添加删除按钮,需要放在position:relative的容器中 return `
${safeTitle}
${episodeText} ${episodeText ? '·' : ''} ${safeSource} ${episodeInfoHtml ? '·' : ''} ${episodeInfoHtml}
${progressHtml}
${formatTimestamp(item.timestamp)}
`; }).join(''); // 检查是否存在较多历史记录,添加底部边距确保底部按钮不会挡住内容 if (history.length > 5) { historyList.classList.add('pb-4'); } } // 格式化播放时间为 mm:ss 格式 function formatPlaybackTime(seconds) { if (!seconds || isNaN(seconds)) return '00:00'; const minutes = Math.floor(seconds / 60); const remainingSeconds = Math.floor(seconds % 60); return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; } // 删除单个历史记录项 function deleteHistoryItem(encodedUrl) { try { // 解码URL const url = decodeURIComponent(encodedUrl); // 获取当前历史记录 const history = getViewingHistory(); // 过滤掉要删除的项 const newHistory = history.filter(item => item.url !== url); // 保存回localStorage localStorage.setItem('viewingHistory', JSON.stringify(newHistory)); // 重新加载历史记录显示 loadViewingHistory(); // 显示成功提示 showToast('已删除该记录', 'success'); } catch (e) { console.error('删除历史记录项失败:', e); showToast('删除记录失败', 'error'); } } // 从历史记录播放 async function playFromHistory(url, title, episodeIndex, playbackPosition = 0) { // console.log('[playFromHistory in ui.js] Called with:', { url, title, episodeIndex, playbackPosition }); // Log 1 try { let episodesList = []; let historyItem = null; // To store the full history item let syncSuccessful = false; // 检查viewingHistory,查找匹配的项 const historyRaw = localStorage.getItem('viewingHistory'); if (historyRaw) { const history = JSON.parse(historyRaw); historyItem = history.find(item => item.url === url); // console.log('[playFromHistory in ui.js] Found historyItem:', historyItem ? JSON.parse(JSON.stringify(historyItem)) : null); // Log 2 (stringify/parse for deep copy) if (historyItem) { // console.log('[playFromHistory in ui.js] historyItem.vod_id:', historyItem.vod_id, 'historyItem.sourceName:', historyItem.sourceName); // Log 3 } if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) { episodesList = historyItem.episodes; // Default to stored episodes // console.log(`从历史记录找到视频 "${title}" 的集数数据 (默认):`, episodesList.length); } } // Always attempt to fetch fresh episode list if we have the necessary info if (historyItem && historyItem.vod_id && historyItem.sourceName) { // Show loading toast to indicate syncing showToast('正在同步最新剧集列表...', 'info'); // console.log(`[playFromHistory in ui.js] Attempting to fetch details for vod_id: ${historyItem.vod_id}, sourceName: ${historyItem.sourceName}`); // Log 4 try { // Construct the API URL for detail fetching // historyItem.sourceName is used as the sourceCode here // Add a cache buster timestamp const timestamp = new Date().getTime(); const apiUrl = `/api/detail?id=${encodeURIComponent(historyItem.vod_id)}&source=${encodeURIComponent(historyItem.sourceName)}&_t=${timestamp}`; // Add timeout to the fetch request const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout const response = await fetch(apiUrl, { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) { throw new Error(`API request failed with status ${response.status}`); } const videoDetails = await response.json(); if (videoDetails && videoDetails.episodes && videoDetails.episodes.length > 0) { const oldEpisodeCount = episodesList.length; episodesList = videoDetails.episodes; syncSuccessful = true; // Show success message with episode count info const newEpisodeCount = episodesList.length; if (newEpisodeCount > oldEpisodeCount) { showToast(`已同步最新剧集列表 (${newEpisodeCount}集,新增${newEpisodeCount - oldEpisodeCount}集)`, 'success'); } else if (newEpisodeCount === oldEpisodeCount) { showToast(`剧集列表已是最新 (${newEpisodeCount}集)`, 'success'); } else { showToast(`已同步最新剧集列表 (${newEpisodeCount}集)`, 'success'); } // console.log(`成功获取 "${title}" 最新剧集列表:`, episodesList.length, "集"); // Update the history item in localStorage with the fresh episodes if (historyItem) { historyItem.episodes = [...episodesList]; // Deep copy historyItem.lastSyncTime = Date.now(); // Add sync timestamp const history = JSON.parse(historyRaw); // Re-parse to ensure we have the latest version const idx = history.findIndex(item => item.url === url); if (idx !== -1) { history[idx] = { ...history[idx], ...historyItem }; // Merge, ensuring other properties are kept localStorage.setItem('viewingHistory', JSON.stringify(history)); // console.log("观看历史中的剧集列表已更新。"); } } } else { // console.log(`未能获取 "${title}" 的最新剧集列表,或列表为空。将使用已存储的剧集。`); showToast('未获取到最新剧集信息,使用缓存数据', 'warning'); } } catch (fetchError) { // console.error(`获取 "${title}" 最新剧集列表失败:`, fetchError, "将使用已存储的剧集。"); if (fetchError.name === 'AbortError') { showToast('同步剧集列表超时,使用缓存数据', 'warning'); } else { showToast('同步剧集列表失败,使用缓存数据', 'warning'); } } } else if (historyItem) { // console.log(`历史记录项 "${title}" 缺少 vod_id 或 sourceName,无法刷新剧集列表。将使用已存储的剧集。`); showToast('无法同步剧集列表,使用缓存数据', 'info'); } // 如果在历史记录中没找到,尝试使用上一个会话的集数数据 if (episodesList.length === 0) { try { const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]'); if (storedEpisodes.length > 0) { episodesList = storedEpisodes; // console.log(`使用localStorage中的集数数据:`, episodesList.length); } } catch (e) { // console.error('解析currentEpisodes失败:', e); } } // 将剧集列表保存到localStorage,播放器页面会读取它 if (episodesList.length > 0) { localStorage.setItem('currentEpisodes', JSON.stringify(episodesList)); // console.log(`已将剧集列表保存到localStorage,共 ${episodesList.length} 集`); } // 保存当前页面URL作为返回地址 let currentPath; if (window.location.pathname.startsWith('/player.html') || window.location.pathname.startsWith('/watch.html')) { currentPath = localStorage.getItem('lastPageUrl') || '/'; } else { currentPath = window.location.origin + window.location.pathname + window.location.search; } localStorage.setItem('lastPageUrl', currentPath); // 构造播放器URL let playerUrl; const sourceNameForUrl = historyItem ? historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source'); const sourceCodeForUrl = historyItem ? historyItem.sourceCode || historyItem.sourceName : (new URLSearchParams(new URL(url, window.location.origin).search)).get('source_code'); const idForUrl = historyItem ? historyItem.vod_id : ''; if (url.includes('player.html') || url.includes('watch.html')) { // console.log('检测到嵌套播放链接,解析真实URL'); try { const nestedUrl = new URL(url, window.location.origin); const nestedParams = nestedUrl.searchParams; const realVideoUrl = nestedParams.get('url') || url; playerUrl = `player.html?url=${encodeURIComponent(realVideoUrl)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`; if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`; if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`; if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`; } catch (e) { // console.error('解析嵌套URL出错:', e); playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}&position=${Math.floor(playbackPosition || 0)}&returnUrl=${encodeURIComponent(currentPath)}`; if (sourceNameForUrl) playerUrl += `&source=${encodeURIComponent(sourceNameForUrl)}`; if (sourceCodeForUrl) playerUrl += `&source_code=${encodeURIComponent(sourceCodeForUrl)}`; if (idForUrl) playerUrl += `&id=${encodeURIComponent(idForUrl)}`; } } else { // This case should ideally not happen if 'url' is always a player.html link from history // console.warn("Playing from history with a non-player.html URL structure. This might be an issue."); const playUrl = new URL(url, window.location.origin); if (!playUrl.searchParams.has('index') && episodeIndex > 0) { playUrl.searchParams.set('index', episodeIndex); } playUrl.searchParams.set('position', Math.floor(playbackPosition || 0).toString()); playUrl.searchParams.set('returnUrl', encodeURIComponent(currentPath)); if (sourceNameForUrl) playUrl.searchParams.set('source', sourceNameForUrl); if (sourceCodeForUrl) playUrl.searchParams.set('source_code', sourceCodeForUrl); if (idForUrl) playUrl.searchParams.set('id', idForUrl); playerUrl = playUrl.toString(); } showVideoPlayer(playerUrl); } catch (e) { // console.error('从历史记录播放失败:', e); const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`; showVideoPlayer(simpleUrl); } } // 添加观看历史 - 确保每个视频标题只有一条记录 // IMPORTANT: videoInfo passed to this function should include a 'showIdentifier' property // (ideally `${sourceName}_${vod_id}`), 'sourceName', and 'vod_id'. function addToViewingHistory(videoInfo) { // 密码保护校验 if (window.isPasswordProtected && window.isPasswordVerified) { if (window.isPasswordProtected() && !window.isPasswordVerified()) { showPasswordModal && showPasswordModal(); return; } } try { const history = getViewingHistory(); // Ensure videoInfo has a showIdentifier if (!videoInfo.showIdentifier) { if (videoInfo.sourceName && videoInfo.vod_id) { videoInfo.showIdentifier = `${videoInfo.sourceName}_${videoInfo.vod_id}`; } else { // Fallback if critical IDs are missing for the preferred identifier videoInfo.showIdentifier = (videoInfo.episodes && videoInfo.episodes.length > 0) ? videoInfo.episodes[0] : videoInfo.directVideoUrl; // console.warn(`addToViewingHistory: videoInfo for "${videoInfo.title}" was missing sourceName or vod_id for preferred showIdentifier. Generated fallback: ${videoInfo.showIdentifier}`); } } const existingIndex = history.findIndex(item => item.title === videoInfo.title && item.sourceName === videoInfo.sourceName && item.showIdentifier === videoInfo.showIdentifier // Strict check using the determined showIdentifier ); if (existingIndex !== -1) { // Exact match with showIdentifier: Update existing series entry const existingItem = history[existingIndex]; existingItem.episodeIndex = videoInfo.episodeIndex; existingItem.timestamp = Date.now(); existingItem.sourceName = videoInfo.sourceName || existingItem.sourceName; existingItem.sourceCode = videoInfo.sourceCode || existingItem.sourceCode; existingItem.vod_id = videoInfo.vod_id || existingItem.vod_id; existingItem.directVideoUrl = videoInfo.directVideoUrl || existingItem.directVideoUrl; existingItem.url = videoInfo.url || existingItem.url; existingItem.playbackPosition = videoInfo.playbackPosition > 10 ? videoInfo.playbackPosition : (existingItem.playbackPosition || 0); existingItem.duration = videoInfo.duration || existingItem.duration; if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) { if (!existingItem.episodes || !Array.isArray(existingItem.episodes) || existingItem.episodes.length !== videoInfo.episodes.length || !videoInfo.episodes.every((ep, i) => ep === existingItem.episodes[i])) { existingItem.episodes = [...videoInfo.episodes]; // console.log(`更新 (addToViewingHistory) "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`); } } history.splice(existingIndex, 1); history.unshift(existingItem); // console.log(`更新历史记录 (addToViewingHistory): "${videoInfo.title}", 第 ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'} 集`); } else { // No exact match: Add as a new entry const newItem = { ...videoInfo, // Includes the showIdentifier we ensured is present timestamp: Date.now() }; if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) { newItem.episodes = [...videoInfo.episodes]; } else { newItem.episodes = []; } history.unshift(newItem); // console.log(`创建新的历史记录 (addToViewingHistory): "${videoInfo.title}", Episode: ${videoInfo.episodeIndex !== undefined ? videoInfo.episodeIndex + 1 : 'N/A'}`); } // 限制历史记录数量为50条 const maxHistoryItems = 50; if (history.length > maxHistoryItems) { history.splice(maxHistoryItems); } // 保存到本地存储 localStorage.setItem('viewingHistory', JSON.stringify(history)); } catch (e) { // console.error('保存观看历史失败:', e); } } // 清空观看历史 function clearViewingHistory() { try { localStorage.removeItem('viewingHistory'); loadViewingHistory(); // 重新加载空的历史记录 showToast('观看历史已清空', 'success'); } catch (e) { // console.error('清除观看历史失败:', e); showToast('清除观看历史失败', 'error'); } } // 更新toggleSettings函数以处理历史面板互动 const originalToggleSettings = toggleSettings; toggleSettings = function(e) { if (e) e.stopPropagation(); // 原始设置面板切换逻辑 originalToggleSettings(e); // 如果历史记录面板是打开的,则关闭它 const historyPanel = document.getElementById('historyPanel'); if (historyPanel && historyPanel.classList.contains('show')) { historyPanel.classList.remove('show'); } }; // 点击外部关闭历史面板 document.addEventListener('DOMContentLoaded', function() { document.addEventListener('click', function(e) { const historyPanel = document.getElementById('historyPanel'); const historyButton = document.querySelector('button[onclick="toggleHistory(event)"]'); if (historyPanel && historyButton && !historyPanel.contains(e.target) && !historyButton.contains(e.target) && historyPanel.classList.contains('show')) { historyPanel.classList.remove('show'); } }); }); // 清除本地存储缓存并刷新页面 function clearLocalStorage() { // 确保模态框在页面上只有一个实例 let modal = document.getElementById('messageBoxModal'); if (modal) { document.body.removeChild(modal); } // 创建模态框元素 modal = document.createElement('div'); modal.id = 'messageBoxModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40'; modal.innerHTML = `

警告

确定要清除页面缓存吗?
此功能会删除你的观看记录、自定义 API 接口和 Cookie,此操作不可恢复!
`; // 添加模态框到页面 document.body.appendChild(modal); // 添加事件监听器 - 关闭按钮 document.getElementById('closeBoxModal').addEventListener('click', function () { document.body.removeChild(modal); }); // 添加事件监听器 - 确定按钮 document.getElementById('confirmBoxModal').addEventListener('click', function () { // 清除所有localStorage数据 localStorage.clear(); // 清除所有cookie const cookies = document.cookie.split(";"); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i]; const eqPos = cookie.indexOf("="); const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim(); document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/"; } modal.innerHTML = `

提示

页面缓存和Cookie已清除,3 秒后自动刷新本页面。
`; let countdown = 3; const countdownElement = document.getElementById('countdown'); const countdownInterval = setInterval(() => { countdown--; if (countdown >= 0) { countdownElement.textContent = countdown; } else { clearInterval(countdownInterval); window.location.reload(); } }, 1000); }); // 添加事件监听器 - 取消按钮 document.getElementById('cancelBoxModal').addEventListener('click', function () { document.body.removeChild(modal); }); // 添加事件监听器 - 点击模态框外部关闭 modal.addEventListener('click', function (e) { if (e.target === modal) { document.body.removeChild(modal); } }); } // 显示配置文件导入页面 function showImportBox(fun) { // 确保模态框在页面上只有一个实例 let modal = document.getElementById('showImportBoxModal'); if (modal) { document.body.removeChild(modal); } // 创建模态框元素 modal = document.createElement('div'); modal.id = 'showImportBoxModal'; modal.className = 'fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-40'; modal.innerHTML = `

将配置文件拖到此处,或手动选择文件

`; // 添加模态框到页面 document.body.appendChild(modal); // 添加事件监听器 - 关闭按钮 document.getElementById('closeBoxModal').addEventListener('click', function () { document.body.removeChild(modal); }); // 添加事件监听器 - 点击模态框外部关闭 modal.addEventListener('click', function (e) { if (e.target === modal) { document.body.removeChild(modal); } }); // 添加事件监听器 - 拖拽文件 const dropZone = document.getElementById('dropZone'); const fileInput = document.getElementById('ChooseFile'); dropZone.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-blue-500'); }); dropZone.addEventListener('dragleave', () => { dropZone.classList.remove('border-blue-500'); }); dropZone.addEventListener('drop', (e) => { e.preventDefault(); fun(e.dataTransfer.files[0]); }); fileInput.addEventListener('change', (e) => { fun(fileInput.files[0]); }); } ================================================ FILE: js/version-check.js ================================================ // 添加动画样式 (function() { const style = document.createElement('style'); style.textContent = ` @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } } .animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } `; document.head.appendChild(style); })(); // 获取版本信息 async function fetchVersion(url, errorMessage, options = {}) { const response = await fetch(url, options); if (!response.ok) { throw new Error(errorMessage); } return await response.text(); } // 版本检查函数 async function checkForUpdates() { try { // 获取当前版本 const currentVersion = await fetchVersion('/VERSION.txt', '获取当前版本失败', { cache: 'no-store' }); // 获取最新版本 let latestVersion; const VERSION_URL = { PROXY: 'https://ghfast.top/raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt', DIRECT: 'https://raw.githubusercontent.com/LibreSpark/LibreTV/main/VERSION.txt' }; const FETCH_TIMEOUT = 1500; try { // 尝试使用代理URL获取最新版本 const proxyPromise = fetchVersion(VERSION_URL.PROXY, '代理请求失败'); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('代理请求超时')), FETCH_TIMEOUT) ); latestVersion = await Promise.race([proxyPromise, timeoutPromise]); console.log('通过代理服务器获取版本成功'); } catch (error) { console.log('代理请求失败,尝试直接请求:', error.message); try { // 代理失败后尝试直接获取 latestVersion = await fetchVersion(VERSION_URL.DIRECT, '获取最新版本失败'); console.log('直接请求获取版本成功'); } catch (directError) { console.error('所有版本检查请求均失败:', directError); throw new Error('无法获取最新版本信息'); } } console.log('当前版本:', currentVersion); console.log('最新版本:', latestVersion); // 清理版本字符串(移除可能的空格或换行符) const cleanCurrentVersion = currentVersion.trim(); const cleanLatestVersion = latestVersion.trim(); // 返回版本信息 return { current: cleanCurrentVersion, latest: cleanLatestVersion, hasUpdate: parseInt(cleanLatestVersion) > parseInt(cleanCurrentVersion), currentFormatted: formatVersion(cleanCurrentVersion), latestFormatted: formatVersion(cleanLatestVersion) }; } catch (error) { console.error('版本检测出错:', error); throw error; } } // 格式化版本号为可读形式 (yyyyMMddhhmm -> yyyy-MM-dd hh:mm) function formatVersion(versionString) { // 检测版本字符串是否有效 if (!versionString) { return '未知版本'; } // 清理版本字符串(移除可能的空格或换行符) const cleanedString = versionString.trim(); // 格式化标准12位版本号 if (cleanedString.length === 12) { const year = cleanedString.substring(0, 4); const month = cleanedString.substring(4, 6); const day = cleanedString.substring(6, 8); const hour = cleanedString.substring(8, 10); const minute = cleanedString.substring(10, 12); return `${year}-${month}-${day} ${hour}:${minute}`; } return cleanedString; } // 创建错误版本信息元素 function createErrorVersionElement(errorMessage) { const errorElement = document.createElement('p'); errorElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left'; errorElement.innerHTML = `版本: 检测失败`; errorElement.title = errorMessage; return errorElement; } // 添加版本信息到页脚 function addVersionInfoToFooter() { checkForUpdates().then(result => { if (!result) { // 如果版本检测失败,显示错误信息 const versionElement = createErrorVersionElement(); // 在页脚显示错误元素 displayVersionElement(versionElement); return; } // 创建版本信息元素 const versionElement = document.createElement('p'); versionElement.className = 'text-gray-500 text-sm mt-1 text-center md:text-left'; // 添加当前版本信息 versionElement.innerHTML = `版本: ${result.currentFormatted}`; // 如果有更新,添加更新提示 if (result.hasUpdate) { versionElement.innerHTML += ` 发现新版 `; setTimeout(() => { const updateBtn = versionElement.querySelector('span'); if (updateBtn) { updateBtn.addEventListener('click', () => { window.open('https://github.com/LibreSpark/LibreTV', '_blank'); }); } }, 100); } else { // 如果没有更新,显示当前版本为最新版本 versionElement.innerHTML = `版本: ${result.currentFormatted} (最新版本)`; } // 显示版本元素 displayVersionElement(versionElement); }).catch(error => { console.error('版本检测出错:', error); // 创建错误版本信息元素并显示 const errorElement = createErrorVersionElement(`错误信息: ${error.message}`); displayVersionElement(errorElement); }); } // 在页脚显示版本元素的辅助函数 function displayVersionElement(element) { // 获取页脚元素 const footerElement = document.querySelector('.footer p.text-gray-500.text-sm'); if (footerElement) { // 在原版权信息后插入版本信息 footerElement.insertAdjacentElement('afterend', element); } else { // 如果找不到页脚元素,尝试在页脚区域最后添加 const footer = document.querySelector('.footer .container'); if (footer) { footer.querySelector('div').appendChild(element); } } } // 页面加载完成后添加版本信息 document.addEventListener('DOMContentLoaded', addVersionInfoToFooter); ================================================ FILE: js/watch.js ================================================ // 获取当前URL的参数,并将它们传递给player.html window.onload = function() { // 获取当前URL的查询参数 const currentParams = new URLSearchParams(window.location.search); // 创建player.html的URL对象 const playerUrlObj = new URL("player.html", window.location.origin); // 更新状态文本 const statusElement = document.getElementById('redirect-status'); const manualRedirect = document.getElementById('manual-redirect'); let statusMessages = [ "准备视频数据中...", "正在加载视频信息...", "即将开始播放...", ]; let currentStatus = 0; // 状态文本动画 let statusInterval = setInterval(() => { if (currentStatus >= statusMessages.length) { currentStatus = 0; } if (statusElement) { statusElement.textContent = statusMessages[currentStatus]; statusElement.style.opacity = 0.7; setTimeout(() => { if (statusElement) statusElement.style.opacity = 1; }, 300); } currentStatus++; }, 1000); // 确保保留所有原始参数 currentParams.forEach((value, key) => { playerUrlObj.searchParams.set(key, value); }); // 获取来源URL (如果存在) const referrer = document.referrer; // 获取当前URL中的返回URL参数(如果有) const backUrl = currentParams.get('back'); // 确定返回URL的优先级:1. 指定的back参数 2. referrer 3. 搜索页面 let returnUrl = ''; if (backUrl) { // 有显式指定的返回URL returnUrl = decodeURIComponent(backUrl); } else if (referrer && (referrer.includes('/s=') || referrer.includes('?s='))) { // 来源是搜索页面 returnUrl = referrer; } else if (referrer && referrer.trim() !== '') { // 如果有referrer但不是搜索页,也使用它 returnUrl = referrer; } else { // 默认回到首页 returnUrl = '/'; } // 将返回URL添加到player.html的参数中 if (!playerUrlObj.searchParams.has('returnUrl')) { playerUrlObj.searchParams.set('returnUrl', encodeURIComponent(returnUrl)); } // 同时保存在localStorage中,作为备用 localStorage.setItem('lastPageUrl', returnUrl); // 标记来自搜索页面 if (returnUrl.includes('/s=') || returnUrl.includes('?s=')) { localStorage.setItem('cameFromSearch', 'true'); localStorage.setItem('searchPageUrl', returnUrl); } // 获取最终的URL字符串 const finalPlayerUrl = playerUrlObj.toString(); // 更新手动重定向链接 if (manualRedirect) { manualRedirect.href = finalPlayerUrl; } // 更新meta refresh标签 const metaRefresh = document.querySelector('meta[http-equiv="refresh"]'); if (metaRefresh) { metaRefresh.content = `3; url=${finalPlayerUrl}`; } // 重定向到播放器页面 setTimeout(() => { clearInterval(statusInterval); window.location.href = finalPlayerUrl; }, 2800); // 稍微早于meta refresh的时间,确保我们的JS控制重定向 }; ================================================ FILE: manifest.json ================================================ { "name": "LibreTV", "short_name": "LibreTV", "description": "免费在线视频搜索与观看平台", "start_url": ".", "display": "standalone", "background_color": "#0f1622", "theme_color": "#000000", "apple-mobile-web-app-capable": "yes", "apple-mobile-web-app-status-bar-style": "black", "icons": [ { "src": "image/logo-black.png", "sizes": "512x512", "type": "image/png" } ] } ================================================ FILE: middleware.js ================================================ import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现 // Vercel Middleware to inject environment variables export default async function middleware(request) { // Get the URL from the request const url = new URL(request.url); // Only process HTML pages const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/'); if (!isHtmlPage) { return; // Let the request pass through unchanged } // Fetch the original response const response = await fetch(request); // Check if it's an HTML response const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('text/html')) { return response; // Return the original response if not HTML } // Get the HTML content const originalHtml = await response.text(); // Replace the placeholder with actual environment variable // If PASSWORD is not set, replace with empty string const password = process.env.PASSWORD || ''; let passwordHash = ''; if (password) { passwordHash = await sha256(password); } // 替换密码占位符 let modifiedHtml = originalHtml.replace( 'window.__ENV__.PASSWORD = "{{PASSWORD}}";', `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash` ); // 修复Response构造 return new Response(modifiedHtml, { status: response.status, statusText: response.statusText, headers: response.headers }); } export const config = { matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'], }; ================================================ FILE: netlify/edge-functions/inject-env.js ================================================ // Netlify Edge Function to inject environment variables into HTML export default async (request, context) => { const url = new URL(request.url); // Only process HTML pages const isHtmlPage = url.pathname.endsWith('.html') || url.pathname === '/'; if (!isHtmlPage) { return; // Let the request pass through unchanged } // Get the original response const response = await context.next(); // Check if it's an HTML response const contentType = response.headers.get('content-type') || ''; if (!contentType.includes('text/html')) { return response; // Return the original response if not HTML } // Get the HTML content const originalHtml = await response.text(); // Simple SHA-256 implementation for Netlify Edge Functions async function sha256(message) { const msgUint8 = new TextEncoder().encode(message); const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); } // Replace the placeholder with actual environment variable const password = Netlify.env.get('PASSWORD') || ''; let passwordHash = ''; if (password) { passwordHash = await sha256(password); } const modifiedHtml = originalHtml.replace( 'window.__ENV__.PASSWORD = "{{PASSWORD}}";', `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash` ); // Create a new response with the modified HTML return new Response(modifiedHtml, { status: response.status, statusText: response.statusText, headers: response.headers }); }; export const config = { path: ["/*"] }; ================================================ FILE: netlify/functions/proxy.mjs ================================================ // /netlify/functions/proxy.mjs - Netlify Function (ES Module) import fetch from 'node-fetch'; import { URL } from 'url'; // Use Node.js built-in URL import crypto from 'crypto'; // 导入 crypto 模块用于密码哈希 // --- Configuration (Read from Environment Variables) --- const DEBUG_ENABLED = process.env.DEBUG === 'true'; const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels // --- User Agent Handling --- let USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', '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' ]; try { const agentsJsonString = process.env.USER_AGENTS_JSON; if (agentsJsonString) { const parsedAgents = JSON.parse(agentsJsonString); if (Array.isArray(parsedAgents) && parsedAgents.length > 0) { USER_AGENTS = parsedAgents; console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`); } else { console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default."); } } else { console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents."); } } catch (e) { console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`); } const FILTER_DISCONTINUITY = false; // Ad filtering disabled // --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) --- function logDebug(message) { if (DEBUG_ENABLED) { console.log(`[Proxy Log Netlify] ${message}`); } } function getTargetUrlFromPath(encodedPath) { if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; } try { const decodedUrl = decodeURIComponent(encodedPath); if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; } else { logDebug(`Invalid decoded URL format: ${decodedUrl}`); if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; } return null; } } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; } } function getBaseUrl(urlStr) { if (!urlStr) return ''; try { const parsedUrl = new URL(urlStr); const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; } pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`; } catch (e) { logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`); const lastSlashIndex = urlStr.lastIndexOf('/'); if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); } return urlStr + '/'; } } function resolveUrl(baseUrl, relativeUrl) { if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl; try { return new URL(relativeUrl, baseUrl).toString(); } catch (e) { logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`); if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } } else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; } } } // ** MODIFIED for Netlify redirect ** function rewriteUrlToProxy(targetUrl) { if (!targetUrl || typeof targetUrl !== 'string') return ''; // Use the path defined in netlify.toml 'from' field return `/proxy/${encodeURIComponent(targetUrl)}`; } function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } /** * 验证代理请求的鉴权 */ function validateAuth(event) { const params = new URLSearchParams(event.queryStringParameters || {}); const authHash = params.get('auth'); const timestamp = params.get('t'); // 获取服务器端密码哈希 const serverPassword = process.env.PASSWORD; if (!serverPassword) { console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝'); return false; } // 使用 crypto 模块计算 SHA-256 哈希 const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex'); if (!authHash || authHash !== serverPasswordHash) { console.warn('代理请求鉴权失败:密码哈希不匹配'); return false; } // 验证时间戳(10分钟有效期) if (timestamp) { const now = Date.now(); const maxAge = 10 * 60 * 1000; // 10分钟 if (now - parseInt(timestamp) > maxAge) { console.warn('代理请求鉴权失败:时间戳过期'); return false; } } return true; } async function fetchContentWithType(targetUrl, requestHeaders) { const headers = { 'User-Agent': getRandomUserAgent(), 'Accept': requestHeaders['accept'] || '*/*', 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8', 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin, }; Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {}); logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`); try { const response = await fetch(targetUrl, { headers, redirect: 'follow' }); if (!response.ok) { const errorBody = await response.text().catch(() => ''); logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`); const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`); err.status = response.status; throw err; } const content = await response.text(); const contentType = response.headers.get('content-type') || ''; logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`); return { content, contentType, responseHeaders: response.headers }; } catch (error) { logDebug(`Fetch exception for ${targetUrl}: ${error.message}`); throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`); } } function isM3u8Content(content, contentType) { if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; } return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U'); } function 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)}"`; }); } function 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)}"`; }); } function processMediaPlaylist(url, content) { const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); } const lines = content.split('\n'); const output = []; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue; if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; } if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; } if (line.startsWith('#EXTINF')) { output.push(line); continue; } if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; } output.push(line); } return output.join('\n'); } async function processM3u8Content(targetUrl, content, recursionDepth = 0) { 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); } logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content); } async function processMasterPlaylist(url, content, recursionDepth) { if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); } const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = ''; 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); } } } 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; } } } if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); } logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`); const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {}); if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); } return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1); } // --- Netlify Handler --- export const handler = async (event, context) => { console.log('--- Netlify Proxy Request ---'); console.log('Time:', new Date().toISOString()); console.log('Method:', event.httpMethod); console.log('Path:', event.path); // Note: event.queryStringParameters contains query params if any // Note: event.headers contains incoming headers // --- CORS Headers (for all responses) --- const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': '*', // Allow all headers client might send }; // --- Handle OPTIONS Preflight Request --- if (event.httpMethod === 'OPTIONS') { logDebug("Handling OPTIONS request"); return { statusCode: 204, headers: { ...corsHeaders, 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours }, body: '', }; } // --- 验证鉴权 --- if (!validateAuth(event)) { console.warn('Netlify 代理请求鉴权失败'); return { statusCode: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: '代理访问未授权:请检查密码配置或鉴权参数' }), }; } // --- Extract Target URL --- // Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat" // The :splat part should be available in event.path after the base path let encodedUrlPath = ''; const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml if (event.path && event.path.startsWith(proxyPrefix)) { encodedUrlPath = event.path.substring(proxyPrefix.length); logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`); } else { logDebug(`Could not extract encoded path from event.path: ${event.path}`); // Potentially handle direct calls too? Less likely needed. // const functionPath = '/.netlify/functions/proxy/'; // if (event.path && event.path.startsWith(functionPath)) { // encodedUrlPath = event.path.substring(functionPath.length); // } } const targetUrl = getTargetUrlFromPath(encodedUrlPath); logDebug(`Resolved target URL: ${targetUrl || 'null'}`); if (!targetUrl) { logDebug('Error: Invalid proxy request path.'); return { statusCode: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }), }; } logDebug(`Processing proxy request for target: ${targetUrl}`); try { // 验证鉴权 const isValidAuth = validateAuth(event); if (!isValidAuth) { return { statusCode: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: "Forbidden: Invalid auth credentials." }), }; } // Fetch Original Content (Pass Netlify event headers) const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers); // --- Process if M3U8 --- if (isM3u8Content(content, contentType)) { logDebug(`Processing M3U8 content: ${targetUrl}`); const processedM3u8 = await processM3u8Content(targetUrl, content); logDebug(`Successfully processed M3U8 for ${targetUrl}`); return { statusCode: 200, headers: { ...corsHeaders, // Include CORS headers 'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8', 'Cache-Control': `public, max-age=${CACHE_TTL}`, // Note: Do NOT include content-encoding or content-length from original response // as node-fetch likely decompressed it and length changed. }, body: processedM3u8, // Netlify expects body as string }; } else { // --- Return Original Content (Non-M3U8) --- logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`); // Prepare headers for Netlify response object const netlifyHeaders = { ...corsHeaders }; responseHeaders.forEach((value, key) => { const lowerKey = key.toLowerCase(); // Exclude problematic headers and CORS headers (already added) if (!lowerKey.startsWith('access-control-') && lowerKey !== 'content-encoding' && lowerKey !== 'content-length') { netlifyHeaders[key] = value; // Add other original headers } }); netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy return { statusCode: 200, headers: netlifyHeaders, body: content, // Body as string // isBase64Encoded: false, // Set true only if returning binary data as base64 }; } } catch (error) { logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`); console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack const statusCode = error.status || 500; // Get status from error if available return { statusCode: statusCode, headers: { ...corsHeaders, 'Content-Type': 'application/json' }, body: JSON.stringify({ success: false, error: `Proxy processing error: ${error.message}`, targetUrl: targetUrl }), }; } }; ================================================ FILE: netlify.toml ================================================ # netlify.toml [build] # 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish # publish = "." # 假设你的 HTML/CSS/JS 文件在根目录 functions = "netlify/functions" # 指定 Netlify 函数目录 # 配置 Edge Functions [[edge_functions]] function = "inject-env" path = "/*" # 配置重写规则,将 /proxy/* 的请求路由到 proxy 函数 # 这样前端的 PROXY_URL 仍然可以是 '/proxy/' [[redirects]] from = "/proxy/*" to = "/.netlify/functions/proxy/:splat" # 将路径参数传递给函数 status = 200 # 重要:这是代理,不是重定向 # 处理搜索路径格式 /s=* [[redirects]] from = "/s=*" to = "/index.html" status = 200 # (可选)为其他静态文件设置缓存头等 # [[headers]] # for = "/*" # [headers.values] # # Add any global headers here ================================================ FILE: nodemon.json ================================================ { "watch": [ "server.mjs", "*.html", ".env" ], "ext": "js,mjs,json,html,css", "ignore": [ "node_modules/**/*", ".git/**/*" ], "delay": "500", "env": { "NODE_ENV": "development" }, "execMap": { "mjs": "node" }, "verbose": true, "restartable": "rs" } ================================================ FILE: package.json ================================================ { "name": "libretv", "type": "module", "version": "1.1.0", "private": true, "description": "免费在线视频搜索与观看平台", "author": "bestZwei", "license": "Apache-2.0", "scripts": { "dev": "nodemon server.mjs", "start": "node server.mjs" }, "dependencies": { "axios": "^1.9.0", "cors": "^2.8.5", "dotenv": "^16.5.0", "express": "^5.1.0", "node-fetch": "^3.3.2" }, "devDependencies": { "nodemon": "^3.1.10" } } ================================================ FILE: player.html ================================================ LibreTV 播放器

上一页
正在加载视频...
⚠️
视频加载失败
请尝试其他视频源或稍后重试
加载中...
自动连播
加载中...
LibreTV

© 2025 LibreTV - 自由观影,畅享精彩

免责声明:本站仅为视频搜索工具,不存储、上传或分发任何视频内容。 所有视频均来自第三方API接口。如有侵权,请联系相关内容提供方。

================================================ FILE: render.yaml ================================================ services: - type: web name: libretv runtime: node plan: free buildCommand: 'npm install' startCommand: 'node server.mjs' autoDeploy: true ================================================ FILE: robots.txt ================================================ User-agent: * Disallow: / ================================================ FILE: server.mjs ================================================ import path from 'path'; import express from 'express'; import axios from 'axios'; import cors from 'cors'; import { fileURLToPath } from 'url'; import fs from 'fs'; import crypto from 'crypto'; import dotenv from 'dotenv'; dotenv.config(); const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const config = { port: process.env.PORT || 8080, password: process.env.PASSWORD || '', corsOrigin: process.env.CORS_ORIGIN || '*', timeout: parseInt(process.env.REQUEST_TIMEOUT || '5000'), maxRetries: parseInt(process.env.MAX_RETRIES || '2'), cacheMaxAge: process.env.CACHE_MAX_AGE || '1d', 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', debug: process.env.DEBUG === 'true' }; const log = (...args) => { if (config.debug) { console.log('[DEBUG]', ...args); } }; const app = express(); app.use(cors({ origin: config.corsOrigin, methods: ['GET', 'POST'], allowedHeaders: ['Content-Type', 'Authorization'] })); app.use((req, res, next) => { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'SAMEORIGIN'); res.setHeader('X-XSS-Protection', '1; mode=block'); next(); }); function sha256Hash(input) { return new Promise((resolve) => { const hash = crypto.createHash('sha256'); hash.update(input); resolve(hash.digest('hex')); }); } async function renderPage(filePath, password) { let content = fs.readFileSync(filePath, 'utf8'); if (password !== '') { const sha256 = await sha256Hash(password); content = content.replace('{{PASSWORD}}', sha256); } else { content = content.replace('{{PASSWORD}}', ''); } return content; } app.get(['/', '/index.html', '/player.html'], async (req, res) => { try { let filePath; switch (req.path) { case '/player.html': filePath = path.join(__dirname, 'player.html'); break; default: // '/' 和 '/index.html' filePath = path.join(__dirname, 'index.html'); break; } const content = await renderPage(filePath, config.password); res.send(content); } catch (error) { console.error('页面渲染错误:', error); res.status(500).send('读取静态页面失败'); } }); app.get('/s=:keyword', async (req, res) => { try { const filePath = path.join(__dirname, 'index.html'); const content = await renderPage(filePath, config.password); res.send(content); } catch (error) { console.error('搜索页面渲染错误:', error); res.status(500).send('读取静态页面失败'); } }); function isValidUrl(urlString) { try { const parsed = new URL(urlString); const allowedProtocols = ['http:', 'https:']; // 从环境变量获取阻止的主机名列表 const blockedHostnames = (process.env.BLOCKED_HOSTS || 'localhost,127.0.0.1,0.0.0.0,::1').split(','); // 从环境变量获取阻止的 IP 前缀 const blockedPrefixes = (process.env.BLOCKED_IP_PREFIXES || '192.168.,10.,172.').split(','); if (!allowedProtocols.includes(parsed.protocol)) return false; if (blockedHostnames.includes(parsed.hostname)) return false; for (const prefix of blockedPrefixes) { if (parsed.hostname.startsWith(prefix)) return false; } return true; } catch { return false; } } // 验证代理请求的鉴权 function validateProxyAuth(req) { const authHash = req.query.auth; const timestamp = req.query.t; // 获取服务器端密码哈希 const serverPassword = config.password; if (!serverPassword) { console.error('服务器未设置 PASSWORD 环境变量,代理访问被拒绝'); return false; } // 使用 crypto 模块计算 SHA-256 哈希 const serverPasswordHash = crypto.createHash('sha256').update(serverPassword).digest('hex'); if (!authHash || authHash !== serverPasswordHash) { console.warn('代理请求鉴权失败:密码哈希不匹配'); console.warn(`期望: ${serverPasswordHash}, 收到: ${authHash}`); return false; } // 验证时间戳(10分钟有效期) if (timestamp) { const now = Date.now(); const maxAge = 10 * 60 * 1000; // 10分钟 if (now - parseInt(timestamp) > maxAge) { console.warn('代理请求鉴权失败:时间戳过期'); return false; } } return true; } app.get('/proxy/:encodedUrl', async (req, res) => { try { // 验证鉴权 if (!validateProxyAuth(req)) { return res.status(401).json({ success: false, error: '代理访问未授权:请检查密码配置或鉴权参数' }); } const encodedUrl = req.params.encodedUrl; const targetUrl = decodeURIComponent(encodedUrl); // 安全验证 if (!isValidUrl(targetUrl)) { return res.status(400).send('无效的 URL'); } log(`代理请求: ${targetUrl}`); // 添加请求超时和重试逻辑 const maxRetries = config.maxRetries; let retries = 0; const makeRequest = async () => { try { return await axios({ method: 'get', url: targetUrl, responseType: 'stream', timeout: config.timeout, headers: { 'User-Agent': config.userAgent } }); } catch (error) { if (retries < maxRetries) { retries++; log(`重试请求 (${retries}/${maxRetries}): ${targetUrl}`); return makeRequest(); } throw error; } }; const response = await makeRequest(); // 转发响应头(过滤敏感头) const headers = { ...response.headers }; const sensitiveHeaders = ( process.env.FILTERED_HEADERS || 'content-security-policy,cookie,set-cookie,x-frame-options,access-control-allow-origin' ).split(','); sensitiveHeaders.forEach(header => delete headers[header]); res.set(headers); // 管道传输响应流 response.data.pipe(res); } catch (error) { console.error('代理请求错误:', error.message); if (error.response) { res.status(error.response.status || 500); error.response.data.pipe(res); } else { res.status(500).send(`请求失败: ${error.message}`); } } }); app.use(express.static(path.join(__dirname), { maxAge: config.cacheMaxAge })); app.use((err, req, res, next) => { console.error('服务器错误:', err); res.status(500).send('服务器内部错误'); }); app.use((req, res) => { res.status(404).send('页面未找到'); }); // 启动服务器 app.listen(config.port, () => { console.log(`服务器运行在 http://localhost:${config.port}`); if (config.password !== '') { console.log('用户登录密码已设置'); } else { console.log('警告: 未设置 PASSWORD 环境变量,用户将被要求设置密码'); } if (config.debug) { console.log('调试模式已启用'); console.log('配置:', { ...config, password: config.password ? '******' : '' }); } }); ================================================ FILE: service-worker.js ================================================ // 不使用缓存,直接通过网络获取资源 self.addEventListener('install', event => { self.skipWaiting(); }); self.addEventListener('activate', event => { event.waitUntil(self.clients.claim()); }); ================================================ FILE: vercel.json ================================================ { "rewrites": [ { "source": "/proxy/:path*", "destination": "/api/proxy/:path*" }, { "source": "/s=:query", "destination": "/index.html" }, { "source": "/player.html", "destination": "/player.html" }, { "source": "/player.html/:path*", "destination": "/player.html" }, { "source": "/:path*", "destination": "/:path*" } ] } ================================================ FILE: watch.html ================================================ 正在跳转到播放器...

LibreTV

正在加载播放器...
准备视频数据中,请稍候...

如果页面没有自动跳转,请点击这里