Showing preview only (430K chars total). Download the full file or copy to clipboard to get everything.
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
<!-- ✅ 推荐 -->
<main class="video-search">
<section class="search-form" role="search">
<input type="search" aria-label="搜索视频" placeholder="输入关键词">
<button type="submit" aria-label="搜索">搜索</button>
</section>
</main>
<!-- ❌ 不推荐 -->
<div class="search">
<input type="text" placeholder="搜索">
<div onclick="search()">搜索</div>
</div>
```
## 🎯 贡献重点领域
我们特别欢迎以下方面的贡献:
### 核心功能
- **搜索优化**: 改进搜索算法和用户体验
- **播放器增强**: 新的播放器功能和控制选项
- **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 - 免费在线视频搜索与观看平台
<div align="center">
<img src="image/logo.png" alt="LibreTV Logo" width="120">
<br>
<p><strong>自由观影,畅享精彩</strong></p>
</div>
## 📺 项目简介
LibreTV 是一个轻量级、免费的在线视频搜索与观看平台,提供来自多个视频源的内容搜索与播放服务。无需注册,即开即用,支持多种设备访问。项目结合了前端技术和后端代理功能,可部署在支持服务端功能的各类网站托管服务上。**项目门户**: [libretv.is-an.org](https://libretv.is-an.org)
本项目基于 [bestK/tv](https://github.com/bestK/tv) 进行重构与增强。
<details>
<summary>点击查看项目截图</summary>
<img src="https://github.com/user-attachments/assets/df485345-e83b-4564-adf7-0680be92d3c7" alt="项目截图" style="max-width:600px">
</details>
## 🚀 快速部署
选择以下任一平台,点击一键部署按钮,即可快速创建自己的 LibreTV 实例:
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FLibreSpark%2FLibreTV)
[](https://app.netlify.com/start/deploy?repository=https://github.com/LibreSpark/LibreTV)
[](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
================================================
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>关于我们 - LibreTV</title>
<script src="libs/tailwindcss.min.js"></script>
<link rel="stylesheet" href="css/styles.css">
<link rel="manifest" href="manifest.json">
<!-- Favicon -->
<link rel="icon" href="image/logo.png">
<link rel="apple-touch-icon" href="image/logo-black.png">
</head>
<body class="page-bg text-white flex flex-col min-h-screen">
<header class="border-b border-[#333] bg-[#0a0a0a] p-4">
<div class="container mx-auto flex items-center">
<div class="flex items-center">
<a href="/" class="flex items-center">
<svg class="w-8 h-8 mr-2 text-[#00ccff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<h1 class="text-xl font-bold gradient-text">LibreTV</h1>
</a>
</div>
<div class="flex-1 text-center">
<h2 class="text-xl font-semibold">关于LibreTV</h2>
</div>
<div class="flex items-center">
<a href="/" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors flex items-center">
<svg class="w-5 h-5 mr-1" viewBox="0 0 24 24" fill="#ffffff" xmlns="http://www.w3.org/2000/svg">
<path d="M10 19l-7-7m0 0l7-7m-7 7h18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
回到首页
</a>
</div>
</div>
</header> <main class="flex-grow container mx-auto px-4 sm:px-6 py-8 sm:py-16">
<div class="max-w-5xl mx-auto">
<!-- 主要内容区域 -->
<div class="bg-gradient-to-br from-[#111] to-[#0a0a0a] border border-[#333] rounded-xl sm:rounded-2xl p-4 sm:p-8 lg:p-12 shadow-2xl">
<!-- 项目介绍 -->
<div class="text-center mb-8 sm:mb-16">
<div class="inline-flex items-center justify-center w-12 h-12 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl mb-6 sm:mb-8">
<img src="image/logo-black.png" alt="LibreTV Logo" class="w-12 h-12 sm:w-16 sm:h-16 rounded-xl sm:rounded-2xl">
</div>
<p class="text-gray-300 text-lg sm:text-xl mb-6 sm:mb-8 leading-relaxed max-w-3xl mx-auto px-2">
LibreTV 是一个免费的在线视频搜索平台,提供视频搜索和播放服务,致力于为用户带来最佳体验。
</p>
<div class="bg-[#1a1a1a] border border-[#333] rounded-lg sm:rounded-xl p-4 sm:p-6 max-w-2xl mx-auto">
<p class="text-gray-300 text-base sm:text-lg mb-4 px-1">
本项目代码托管在 GitHub 上,欢迎访问我们的仓库:
</p>
<a href="https://github.com/LibreSpark/LibreTV" class="inline-flex items-center px-4 sm:px-6 py-2 sm:py-3 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 text-white text-sm sm:text-base font-medium rounded-lg transition-all duration-300 transform hover:scale-105" target="_blank" rel="noopener">
<svg class="w-4 h-4 sm:w-5 sm:h-5 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
<span class="break-words">访问 GitHub 仓库</span>
</a>
</div>
</div> <!-- 分割线 -->
<div class="w-full h-px bg-gradient-to-r from-transparent via-[#333] to-transparent mb-8 sm:mb-16"></div>
<!-- 隐私政策 -->
<div class="mb-8 sm:mb-16">
<h2 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-center bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">隐私政策</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-8">
<div class="bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8">
<div class="flex items-center mb-3 sm:mb-4">
<div class="w-3 h-3 bg-green-400 rounded-full mr-3"></div>
<h3 class="text-lg sm:text-xl font-semibold text-gray-200">数据保护</h3>
</div>
<p class="text-gray-300 text-base sm:text-lg leading-relaxed">
我们尊重并保护您的隐私。LibreTV 不收集任何个人数据,且不会限制访问或使用本网站。
</p>
</div>
<div class="bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8">
<div class="flex items-center mb-3 sm:mb-4">
<div class="w-3 h-3 bg-blue-400 rounded-full mr-3"></div>
<h3 class="text-lg sm:text-xl font-semibold text-gray-200">服务说明</h3>
</div>
<p class="text-gray-300 text-base sm:text-lg leading-relaxed">
本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供,我们不会存储或追踪用户信息。
</p>
</div>
</div>
</div>
<!-- 分割线 -->
<div class="w-full h-px bg-gradient-to-r from-transparent via-[#333] to-transparent mb-8 sm:mb-16"></div> <!-- 版权声明与投诉机制 -->
<div>
<h2 class="text-2xl sm:text-3xl font-bold mb-6 sm:mb-8 text-center bg-gradient-to-r from-blue-400 to-cyan-400 bg-clip-text text-transparent">版权声明与投诉机制</h2>
<div class="space-y-6 sm:space-y-8">
<div class="bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8">
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-yellow-500 rounded-lg flex items-center justify-center mr-3 sm:mr-4 mt-1">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
</div>
<div>
<h3 class="text-lg sm:text-xl font-semibold text-gray-200 mb-3 sm:mb-4">免责声明</h3>
<p class="text-gray-300 text-base sm:text-lg leading-relaxed">
LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。
</p>
</div>
</div>
</div>
<div class="bg-[#1a1a1a] border border-[#333] rounded-xl p-4 sm:p-8">
<div class="flex items-start">
<div class="flex-shrink-0 w-8 h-8 bg-red-500 rounded-lg flex items-center justify-center mr-3 sm:mr-4 mt-1">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-lg sm:text-xl font-semibold text-gray-200 mb-3 sm:mb-4">投诉反馈</h3>
<p class="text-gray-300 text-base sm:text-lg mb-4 sm:mb-6 leading-relaxed">
若您是版权方或相关权利人,发现本站搜索结果中存在侵犯您合法权益的内容,请通过以下渠道向我们反馈:
</p>
<div class="bg-gradient-to-r from-[#222] to-[#333] border border-[#444] rounded-lg p-4 sm:p-6 mb-4 sm:mb-6">
<div class="flex items-center flex-wrap">
<svg class="w-5 h-5 sm:w-6 sm:h-6 text-blue-400 mr-2 sm:mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
</svg>
<span class="text-gray-300 text-base sm:text-lg font-medium mr-2 sm:mr-3">投诉邮箱:</span>
<a href="mailto:troll@pissmail.com" class="text-blue-400 hover:text-blue-300 transition-colors text-base sm:text-lg font-medium break-all">troll@pissmail.com</a>
</div>
</div>
<p class="text-gray-300 text-base sm:text-lg leading-relaxed">
请在投诉邮件中提供:您的身份证明、权利证明、侵权内容的具体链接及相关说明。我们将在收到投诉后尽快处理,对于确认侵权的内容,将立即断开相关链接,停止展示侵权内容,并将处理结果反馈给您。
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="footer py-6 border-t border-[#333] bg-[#0a0a0a]">
<div class="container mx-auto px-4">
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0">
<div class="flex items-center justify-center md:justify-start">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<span class="gradient-text font-bold">LibreTV</span>
</div>
<p class="text-gray-500 text-sm mt-2 text-center md:text-left">© 2025 LibreTV - 自由观影,畅享精彩</p>
</div>
<div class="text-center md:text-right">
<p class="text-gray-500 text-sm max-w-md">
免责声明:本站仅为视频搜索工具,不存储、上传或分发任何视频内容。
所有视频均来自第三方API接口。如有侵权,请联系相关内容提供方。
</p>
<div class="mt-2 flex justify-center md:justify-end space-x-4">
<a href="/" class="text-gray-400 hover:text-white text-sm transition-colors">首页</a>
<a href="about.html" class="text-gray-400 hover:text-white text-sm transition-colors">关于我们</a>
<a href="https://www.msf.hk/zh-hant/donate/general?type=one-off" target="_blank" rel="noopener" class="text-blue-400 hover:text-blue-300 text-sm transition-colors">捐赠</a>
</div>
</div>
</div>
</div>
</footer>
</body>
</html>
================================================
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
================================================
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LibreTV - 免费在线视频搜索与观看平台</title>
<meta name="description" content="LibreTV是一个免费的在线视频搜索平台,无广告、安全,提供来自多个视频源的内容搜索与观看服务,无需注册即可使用。">
<meta name="keywords" content="在线视频,免费视频,视频搜索,电影,电视剧,LibreTV">
<meta name="author" content="LibreTV Team">
<!-- Favicon -->
<link rel="icon" href="image/logo.png">
<link rel="apple-touch-icon" href="image/logo-black.png">
<link rel="manifest" href="manifest.json">
<script src="libs/tailwindcss.min.js"></script>
<link rel="stylesheet" href="css/styles.css">
<link rel="stylesheet" href="css/index.css">
</head>
<body class="page-bg text-white">
<!-- 将历史记录按钮移到左上角,并缩小尺寸 -->
<div class="fixed top-4 left-4 z-10">
<button onclick="toggleHistory(event)" class="bg-[#222] hover:bg-[#333] border border-[#333] hover:border-white rounded-lg px-3 py-1.5 transition-colors" aria-label="观看历史">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
</div>
<!-- 设置按钮保留在右上角,并缩小尺寸 -->
<div class="fixed top-4 right-4 z-10">
<button onclick="toggleSettings(event)" class="bg-[#222] hover:bg-[#333] border border-[#333] hover:border-white rounded-lg px-3 py-1.5 transition-colors" aria-label="打开设置">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</button>
</div>
<!-- 历史记录面板 - 标题居中 -->
<div id="historyPanel" class="history-panel fixed left-0 top-0 h-full bg-[#111] border-r border-[#333] p-6 z-40 transform -translate-x-full transition-transform duration-300" aria-label="观看历史" aria-hidden="true">
<div class="flex justify-between items-center mb-6">
<button onclick="toggleHistory()" class="close-btn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<h3 class="text-xl font-bold gradient-text mx-auto">观看历史</h3>
<div class="w-4"></div> <!-- 添加一个占位元素以确保标题居中 -->
</div>
<div id="historyList" class="pb-4">
<!-- 历史记录将在这里动态显示 -->
<div class="text-center text-gray-500 py-8">暂无观看记录</div>
</div>
<div class="mt-4 text-center sticky bottom-0 pb-2 pt-2 bg-[#111]">
<button onclick="clearViewingHistory()" class="px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg">
清空历史记录
</button>
</div>
</div>
<!-- 设置面板 -->
<div id="settingsPanel" class="settings-panel fixed right-0 top-0 h-full w-80 bg-[#111] border-l border-[#333] p-6 z-40 overflow-y-auto" aria-label="设置面板" aria-hidden="true">
<div class="flex justify-between items-center mb-6">
<h3 class="text-xl font-bold gradient-text">设置</h3>
<button onclick="toggleSettings()" class="close-btn">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="space-y-5">
<!-- 数据源设置区域 -->
<div class="p-3 bg-[#151515] rounded-lg shadow-inner">
<label class="block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1">数据源设置</label>
<!-- 批量操作按钮 -->
<div class="flex space-x-2 mb-3">
<button onclick="selectAllAPIs(true)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全选</button>
<button onclick="selectAllAPIs(false)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全不选</button>
<button onclick="selectAllAPIs(true, true)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全选普通资源</button>
</div>
<!-- API选择区域 - 使用滚动区域 -->
<div class="max-h-40 overflow-y-auto bg-[#191919] p-2 rounded-lg mb-3">
<div id="apiCheckboxes">
<!-- 这里将动态插入API复选框 -->
</div>
</div>
<!-- API信息显示 -->
<div class="text-xs text-gray-500 flex justify-between items-center">
<span>已选API数量:<span id="selectedApiCount" class="text-white">0</span></span>
<span id="siteStatus" class="ml-2"></span>
</div>
</div>
<!-- 自定义API管理区域 -->
<div class="p-3 bg-[#151515] rounded-lg shadow-inner">
<div class="flex justify-between items-center mb-2">
<label class="block text-sm font-medium text-gray-400 border-b border-[#333] w-full pb-1">自定义API</label>
<button onclick="showAddCustomApiForm()" class="bg-[#333] hover:bg-[#444] text-white w-6 h-6 rounded-full text-center leading-none text-lg ml-1">+</button>
</div>
<div id="customApisList" class="max-h-32 overflow-y-auto mb-2">
<!-- 自定义API将显示在这里 -->
</div>
<!-- 添加自定义API表单 (默认隐藏) -->
<div id="addCustomApiForm" class="hidden mt-2 p-2 bg-[#191919] rounded-lg">
<input type="text" id="customApiName" placeholder="API名称" class="w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2" autocomplete="off">
<input type="text" id="customApiUrl" placeholder="https://abc.com" class="w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2" autocomplete="off">
<!-- 新增 detail 地址输入框 -->
<input type="text" id="customApiDetail" placeholder="detail地址(可选)" class="w-full bg-[#222] border border-[#333] text-white px-2 py-1 rounded mb-2" autocomplete="off">
<!-- 添加成人内容切换 -->
<div class="flex items-center mb-2">
<input type="checkbox" id="customApiIsAdult" class="form-checkbox h-4 w-4 text-pink-500 bg-[#222] border border-[#333]">
<label for="customApiIsAdult" class="ml-2 text-xs text-pink-400">黄色资源站</label>
</div>
<div class="flex space-x-2">
<button onclick="addCustomApi()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">添加</button>
<button onclick="cancelAddCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
</div>
</div>
</div>
<!-- 内容过滤设置区域 -->
<div class="p-3 bg-[#151515] rounded-lg shadow-inner">
<label class="block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1">功能开关</label>
<!-- 黄色内容过滤开关 -->
<div class="flex flex-col mb-3 pb-3 border-b border-[#222] relative">
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-400">黄色内容过滤</label>
<p class="text-xs text-gray-500 mt-1 filter-description">过滤"伦理片"等黄色内容</p>
</div>
<div class="relative inline-block w-12 align-middle select-none">
<input type="checkbox" id="yellowFilterToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
<div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
<div class="toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out"></div>
</div>
</div>
<!-- 警告提示将在这里动态插入 -->
</div>
<!-- 广告过滤开关 -->
<div class="flex items-center justify-between mb-3 pb-3 border-b border-[#222]">
<div>
<label class="text-sm font-medium text-gray-400">分片广告过滤</label>
<p class="text-xs text-gray-500 mt-1">关闭可减少旧版浏览器卡顿</p>
</div>
<div class="relative inline-block w-12 align-middle select-none">
<input type="checkbox" id="adFilterToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
<div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
<div class="toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out"></div>
</div>
</div>
<!-- 豆瓣热门开关 -->
<div class="flex items-center justify-between">
<div>
<label class="text-sm font-medium text-gray-400">豆瓣热门推荐</label>
<p class="text-xs text-gray-500 mt-1">首页显示豆瓣热门影视内容</p>
</div>
<div class="relative inline-block w-12 align-middle select-none">
<input type="checkbox" id="doubanToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
<div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
<div class="toggle-dot absolute w-5 h-5 bg-white rounded-full top-0.5 left-0.5 transition-transform duration-300 ease-in-out"></div>
</div>
</div>
</div>
<!-- 一般功能区域 -->
<div class="p-3 bg-[#151515] rounded-lg shadow-inner">
<label class="block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1">一般功能</label>
<button onclick="importConfig()" class="px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg mb-2">导入配置</button>
<button onclick="exportConfig()" class="px-4 py-2 mb-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg">导出配置</button>
<button onclick="clearLocalStorage()" class="px-4 py-2 w-full bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 hover:from-indigo-600 hover:via-purple-600 hover:to-pink-600 text-white rounded-lg text-sm transition-all duration-300 shadow-md hover:shadow-lg">清除Cookie</button>
</div>
</div>
</div>
<div class="container mx-auto px-4 py-8 flex flex-col h-screen">
<div class="flex-1 flex flex-col">
<!-- 网站标志和口号 -->
<header class="text-center mb-2">
<div class="flex justify-center items-center mb-4">
<a href="#" onclick="resetToHome(); return false;" class="flex items-center">
<svg class="w-10 h-10 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<h1 class="text-5xl font-bold gradient-text">LibreTV</h1>
</a>
</div>
<p class="text-gray-400 mb-8">自由观影,畅享精彩</p>
</header>
<div id="searchArea" class="flex-1 flex flex-col items-center justify-center">
<div class="w-full max-w-2xl">
<div class="flex items-stretch mb-3 h-14 shadow-lg rounded-lg overflow-hidden">
<!-- 首页按钮 -->
<button onclick="resetToHome()"
class="w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors"
aria-label="返回首页" title="返回首页">
首页
</button>
<!-- 搜索输入 -->
<input type="text"
id="searchInput"
class="flex-1 bg-[#111] border-y border-[#333] text-white px-6 py-0 focus:outline-none transition-colors min-w-0"
placeholder="搜索你喜欢的视频..."
autocomplete="off"
aria-label="视频搜索框"
oninput="toggleClearButton()">
<!-- 清空按钮 -->
<button id="clearSearchInput"
class="flex pr-2 bg-[#111] border-y border-[#333] items-center justify-center text-gray-400 hover:text-white hidden"
onclick="clearSearchInput()"
aria-label="清空搜索框"
title="清空搜索框">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<!-- 搜索按钮 -->
<button onclick="search()"
class="w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors"
aria-label="搜索按钮">
搜索
</button>
</div>
<!-- 添加最近搜索记录部分 -->
<div id="recentSearches" class="mt-4 flex flex-wrap gap-2" aria-label="最近搜索记录">
<!-- 这里会动态插入最近的搜索记录 -->
</div>
</div>
</div>
<!-- 豆瓣热门推荐区域: 默认隐藏,现在位于搜索区域下方,调整宽度 -->
<div id="doubanArea" class="w-full my-8 hidden">
<div class="mx-auto max-w-screen-xl px-2">
<!-- 改进标题和标签区域布局 -->
<div class="mb-4">
<!-- 标题和刷新按钮一行 -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center">
<h2 class="text-xl font-bold text-white mr-4">豆瓣热门</h2>
<!-- 添加电影/电视剧切换开关 -->
<div class="flex items-center bg-[#222] rounded-full p-1">
<button id="douban-movie-toggle" class="px-3 py-1 text-sm rounded-full bg-pink-600 text-white">电影</button>
<button id="douban-tv-toggle" class="px-3 py-1 text-sm rounded-full text-gray-300 hover:text-white">电视剧</button>
</div>
</div>
<button id="douban-refresh" class="text-sm px-3 py-1 bg-pink-600 hover:bg-pink-700 text-white rounded-lg flex items-center gap-1">
<span>换一批</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
<!-- 分类标签独立成行,添加滚动支持以适应移动设备 -->
<div class="overflow-x-auto pb-2">
<div id="douban-tags" class="flex space-x-2 min-w-max"></div>
</div>
</div>
<!-- 推荐内容 -->
<div id="douban-results" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-3"></div>
</div>
</div>
<!-- 搜索结果:初始隐藏 -->
<div id="resultsArea" class="w-full hidden">
<div class="mx-auto max-w-7xl px-2"> <!-- 添加最大宽度限制并居中 -->
<div class="flex justify-end items-center mb-4">
<div class="text-sm text-gray-400">
<span id="searchResultsCount">0</span> 个结果
</div>
</div>
<!-- 修改网格布局以适应大一些的横向卡片 -->
<div id="results" class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<!-- 结果将在这里动态生成 -->
</div>
</div>
</div>
</div>
</div>
<!-- 页脚区域 -->
<footer class="footer mt-8 py-6 border-t border-[#333] bg-[#0a0a0a]">
<div class="container mx-auto px-4">
<div class="flex flex-col md:flex-row justify-between items-center">
<div class="mb-4 md:mb-0">
<div class="flex items-center justify-center md:justify-start">
<svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
<span class="gradient-text font-bold">LibreTV</span>
</div>
<p class="text-gray-500 text-sm mt-2 text-center md:text-left">© 2025 LibreTV - 自由观影,畅享精彩</p>
</div>
<div class="text-center md:text-right">
<p class="text-gray-500 text-sm max-w-md">
免责声明:本站仅为视频搜索工具,不存储、上传或分发任何视频内容。
所有视频均来自第三方API接口。如有侵权,请联系相关内容提供方。
</p>
<div class="mt-2 flex justify-center md:justify-end space-x-4">
<a href="about.html" class="text-gray-400 hover:text-white text-sm transition-colors">关于我们</a>
<a href="about.html" class="text-gray-400 hover:text-white text-sm transition-colors">隐私政策</a>
<a href="https://www.msf.hk/zh-hant/donate/general?type=one-off" target="_blank" rel="noopener" class="text-blue-400 hover:text-blue-300 text-sm transition-colors">捐赠</a>
</div>
</div>
</div>
</div>
</footer>
<!-- 详情模态框 -->
<div id="modal" class="fixed inset-0 bg-black/95 hidden flex items-center justify-center transition-opacity duration-300 z-40">
<div class="bg-[#111] p-8 rounded-lg w-11/12 max-w-4xl border border-[#333] max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-6 flex-none">
<h2 id="modalTitle" class="text-2xl font-bold gradient-text break-words pr-4 max-w-[80%]"></h2>
<button onclick="closeModal()" class="text-gray-400 hover:text-white text-2xl transition-colors flex-shrink-0">×</button>
</div>
<div id="modalContent" class="overflow-auto flex-1 min-h-0">
<div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
</div>
</div>
</div>
</div>
<!-- 密码验证弹窗 -->
<div id="passwordModal" class="fixed inset-0 bg-black/95 hidden items-center justify-center z-[65] transition-opacity duration-300">
<div class="bg-[#111] p-8 rounded-lg w-11/12 max-w-md border border-[#333] max-h-[90vh] flex flex-col">
<div class="flex justify-between items-center mb-6 flex-none">
<h2 class="text-2xl font-bold gradient-text">访问验证</h2>
</div>
<div class="mb-6">
<p class="text-gray-300 mb-4">请输入密码继续访问</p>
<form id="passwordForm" onsubmit="handlePasswordSubmit(); return false;">
<input type="text" name="username" id="username" autocomplete="username" style="display:none" tabindex="-1" aria-hidden="true">
<input type="password" id="passwordInput" class="w-full bg-[#111] border border-[#333] text-white px-4 py-3 rounded-lg focus:outline-none focus:border-white transition-colors" placeholder="密码..." autocomplete="new-password">
<div class="mt-4 w-full flex space-x-4">
<button id="passwordSubmitBtn" type="submit" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">提交</button>
<button id="passwordCancelBtn" type="button" onclick="hidePasswordModal()" class="flex-1 bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">取消</button>
</div>
</form>
<p id="passwordError" class="text-red-500 mt-2 hidden">密码错误,请重试</p>
</div>
</div>
</div>
<!-- 版权声明弹窗 -->
<div id="disclaimerModal" class="fixed inset-0 bg-black/90 hidden items-center justify-center z-[60]">
<div class="bg-[#111] p-8 rounded-lg border border-[#333] w-11/12 max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 class="text-2xl font-bold gradient-text mb-6 text-center">使用声明</h2>
<div class="text-gray-300 space-y-4">
<p>
欢迎使用 LibreTV。在开始使用前,请您了解并同意以下条款:
</p>
<p>
<strong class="text-blue-400">服务性质:</strong> LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。
</p>
<p>
<strong class="text-blue-400">用户责任:</strong> 用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。
</p>
<p>
<strong class="text-blue-400">广告风险提示:</strong> 本站所有视频均来自第三方采集站,视频中出现的广告与本站无关,请勿相信或点击视频中的任何广告内容,谨防上当受骗。
</p>
</div>
<div class="mt-6 flex justify-center">
<button id="acceptDisclaimerBtn" class="px-6 py-3 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-semibold rounded-lg hover:shadow-lg transition-all duration-300">
我已了解并接受
</button>
</div>
</div>
</div>
<!-- 错误提示框 -->
<div id="toast" class="fixed top-4 left-1/2 -translate-x-1/2 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 opacity-0 -translate-y-full z-50">
<p id="toastMessage"></p>
</div>
<!-- 添加 loading 提示框 -->
<div id="loading" class="fixed inset-0 bg-black/80 hidden items-center justify-center z-50">
<div class="bg-[#111] p-8 rounded-lg border border-[#333] flex items-center space-x-4">
<div class="w-8 h-8 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
<p class="text-white text-lg">加载中...</p>
</div>
</div>
<!-- JSON-LD 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "LibreTV",
"url": "https://libretv.is-an.org/",
"description": "免费在线视频搜索与观看平台",
"potentialAction": {
"@type": "SearchAction",
"target": "https://libretv.is-an.org/?s={search_term_string}",
"query-input": "required name=search_term_string"
}
}
</script>
<!-- 引入纯 JS sha256(HTTP 下依然可用) -->
<script src="libs/sha256.min.js"></script>
<script>
// 保存原始 js‑sha256 实现,避免被 password.js 覆盖
window._jsSha256 = window.sha256;
</script>
<script src="js/config.js"></script>
<script src="js/proxy-auth.js"></script>
<script src="js/customer_site.js"></script>
<script src="js/ui.js"></script>
<script src="js/api.js"></script>
<script src="js/douban.js"></script>
<script src="js/password.js"></script>
<script src="js/search.js"></script>
<script src="js/app.js"></script>
<!-- PWA 注册 -->
<script src="js/pwa-register.js"></script>
<!-- 环境变量注入脚本 -->
<script>
// 创建全局环境变量对象
window.__ENV__ = window.__ENV__ || {};
// 注入服务器端环境变量 (将由服务器端替换)
// PASSWORD 变量将在这里被服务器端注入
window.__ENV__.PASSWORD = "{{PASSWORD}}";
</script>
<!-- 版本检测脚本 -->
<script src="js/version-check.js"></script>
<!-- 添加弹窗和URL搜索参数处理脚本 -->
<script src="js/index-page.js"></script>
</body>
</html>
================================================
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[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
const descMatch = html.match(/<div[^>]*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[^>]*>([^<]+)<\/h1>/);
const titleText = titleMatch ? titleMatch[1].trim() : '';
const descMatch = html.match(/<div[^>]*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 = `
<input type="checkbox" id="api_${apiKey}"
class="form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333]"
${checked ? 'checked' : ''}
data-api="${apiKey}">
<label for="api_${apiKey}" class="ml-1 text-xs text-gray-400 truncate">${api.name}</label>
`;
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 = `黄色资源采集站 <span class="adult-warning">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</span>`;
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 = `
<input type="checkbox" id="api_${apiKey}"
class="form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333] api-adult"
${checked ? 'checked' : ''}
data-api="${apiKey}">
<label for="api_${apiKey}" class="ml-1 text-xs text-pink-400 truncate">${api.name}</label>
`;
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 = '<strong class="text-pink-300">选中黄色资源站时无法启用此过滤</strong>';
}
// 移除提示信息(如果存在)
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 = '<p class="text-xs text-gray-500 text-center my-2">未添加自定义API</p>';
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 ? '<span class="text-xs text-pink-400 mr-1">(18+)</span>' : '';
// 新增 detail 地址显示
const detailLine = api.detail ? `<div class="text-xs text-gray-400 truncate">detail: ${api.detail}</div>` : '';
apiItem.innerHTML = `
<div class="flex items-center flex-1 min-w-0">
<input type="checkbox" id="custom_api_${index}"
class="form-checkbox h-3 w-3 text-blue-600 mr-1 ${api.isAdult ? 'api-adult' : ''}"
${selectedAPIs.includes('custom_' + index) ? 'checked' : ''}
data-custom-index="${index}">
<div class="flex-1 min-w-0">
<div class="text-xs font-medium ${textColorClass} truncate">
${adultTag}${api.name}
</div>
<div class="text-xs text-gray-500 truncate">${api.url}</div>
${detailLine}
</div>
</div>
<div class="flex items-center">
<button class="text-blue-500 hover:text-blue-700 text-xs px-1" onclick="editCustomApi(${index})">✎</button>
<button class="text-red-500 hover:text-red-700 text-xs px-1" onclick="removeCustomApi(${index})">✕</button>
</div>
`;
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 = `
<button onclick="updateCustomApi(${index})" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">更新</button>
<button onclick="cancelEditCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
`;
}
}
// 更新自定义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 = `
<button onclick="addCustomApi()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">添加</button>
<button onclick="cancelAddCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
`;
}
// 更新选中的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 = `
<div class="col-span-full text-center py-16">
<svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="mt-2 text-lg font-medium text-gray-400">没有找到匹配的结果</h3>
<p class="mt-1 text-sm text-gray-500">请尝试其他关键词或更换数据源</p>
</div>
`;
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, '>')
.replace(/"/g, '"');
const sourceInfo = item.source_name ?
`<span class="bg-[#222] text-xs px-1.5 py-0.5 rounded-full">${item.source_name}</span>` : '';
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 `
<div class="card-hover bg-[#111] rounded-lg overflow-hidden cursor-pointer transition-all hover:scale-[1.02] h-full shadow-sm hover:shadow-md"
onclick="showDetails('${safeId}','${safeName}','${sourceCode}')" ${apiUrlAttr}>
<div class="flex h-full">
${hasCover ? `
<div class="relative flex-shrink-0 search-card-img-container">
<img src="${item.vod_pic}" alt="${safeName}"
class="h-full w-full object-cover transition-transform hover:scale-110"
onerror="this.onerror=null; this.src='https://via.placeholder.com/300x450?text=无封面'; this.classList.add('object-contain');"
loading="lazy">
<div class="absolute inset-0 bg-gradient-to-r from-black/30 to-transparent"></div>
</div>` : ''}
<div class="p-2 flex flex-col flex-grow">
<div class="flex-grow">
<h3 class="font-semibold mb-2 break-words line-clamp-2 ${hasCover ? '' : 'text-center'}" title="${safeName}">${safeName}</h3>
<div class="flex flex-wrap ${hasCover ? '' : 'justify-center'} g
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
SYMBOL INDEX (210 symbols across 19 files)
FILE: api/proxy/[...path].mjs
constant DEBUG_ENABLED (line 8) | const DEBUG_ENABLED = process.env.DEBUG === 'true';
constant CACHE_TTL (line 9) | const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10);
constant MAX_RECURSION (line 10) | const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10);
constant USER_AGENTS (line 14) | let USER_AGENTS = [
constant FILTER_DISCONTINUITY (line 39) | const FILTER_DISCONTINUITY = false;
function logDebug (line 44) | function logDebug(message) {
function getTargetUrlFromPath (line 55) | function getTargetUrlFromPath(encodedPath) {
function getBaseUrl (line 81) | function getBaseUrl(urlStr) {
function resolveUrl (line 103) | function resolveUrl(baseUrl, relativeUrl) {
function rewriteUrlToProxy (line 129) | function rewriteUrlToProxy(targetUrl) {
function getRandomUserAgent (line 135) | function getRandomUserAgent() {
function fetchContentWithType (line 139) | async function fetchContentWithType(targetUrl, requestHeaders) {
function isM3u8Content (line 182) | function isM3u8Content(content, contentType) {
function processKeyLine (line 189) | function processKeyLine(line, baseUrl) {
function processMapLine (line 197) | function processMapLine(line, baseUrl) {
function processMediaPlaylist (line 205) | function processMediaPlaylist(url, content) {
function processM3u8Content (line 233) | async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
function processMasterPlaylist (line 243) | async function processMasterPlaylist(url, content, recursionDepth) {
function validateAuth (line 306) | async function validateAuth(req) {
function handler (line 339) | async function handler(req, res) {
FILE: functions/_middleware.js
function onRequest (line 3) | async function onRequest(context) {
FILE: functions/proxy/[[path]].js
constant MEDIA_FILE_EXTENSIONS (line 14) | const MEDIA_FILE_EXTENSIONS = [
constant MEDIA_CONTENT_TYPES (line 19) | const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/'];
function onRequest (line 27) | async function onRequest(context) {
function onOptions (line 582) | async function onOptions(context) {
FILE: js/api.js
function handleApiRequest (line 2) | async function handleApiRequest(url) {
function handleCustomApiSpecialDetail (line 213) | async function handleCustomApiSpecialDetail(id, customApi) {
function handleSpecialSourceDetail (line 280) | async function handleSpecialSourceDetail(id, sourceCode) {
function handleAggregatedSearch (line 359) | async function handleAggregatedSearch(searchQuery) {
function handleMultipleCustomSearch (line 472) | async function handleMultipleCustomSearch(searchQuery, customApiUrls) {
function testSiteAvailability (line 615) | async function testSiteAvailability(apiUrl) {
FILE: js/app.js
function initAPICheckboxes (line 65) | function initAPICheckboxes() {
function addAdultAPI (line 112) | function addAdultAPI() {
function checkAdultAPIsSelected (line 159) | function checkAdultAPIsSelected() {
function renderCustomAPIsList (line 210) | function renderCustomAPIsList() {
function editCustomApi (line 255) | function editCustomApi(index) {
function updateCustomApi (line 275) | function updateCustomApi(index) {
function cancelEditCustomApi (line 309) | function cancelEditCustomApi() {
function restoreAddCustomApiButtons (line 325) | function restoreAddCustomApiButtons() {
function updateSelectedAPIs (line 335) | function updateSelectedAPIs() {
function updateSelectedApiCount (line 357) | function updateSelectedApiCount() {
function selectAllAPIs (line 365) | function selectAllAPIs(selectAll = true, excludeAdult = false) {
function showAddCustomApiForm (line 381) | function showAddCustomApiForm() {
function cancelAddCustomApi (line 389) | function cancelAddCustomApi() {
function addCustomApi (line 405) | function addCustomApi() {
function removeCustomApi (line 445) | function removeCustomApi(index) {
function toggleSettings (line 483) | function toggleSettings(e) {
function setupEventListeners (line 500) | function setupEventListeners() {
function resetSearchArea (line 562) | function resetSearchArea() {
function getCustomApiInfo (line 598) | function getCustomApiInfo(customApiIndex) {
function search (line 607) | async function search() {
function toggleClearButton (line 814) | function toggleClearButton() {
function clearSearchInput (line 825) | function clearSearchInput() {
function hookInput (line 833) | function hookInput() {
function showDetails (line 858) | async function showDetails(id, vod_name, sourceCode) {
function playVideo (line 990) | function playVideo(url, vod_name, sourceCode, episodeIndex = 0, vodId = ...
function showVideoPlayer (line 1028) | function showVideoPlayer(url) {
function closeVideoPlayer (line 1048) | function closeVideoPlayer(home = false) {
function playPreviousEpisode (line 1071) | function playPreviousEpisode(sourceCode) {
function playNextEpisode (line 1080) | function playNextEpisode(sourceCode) {
function handlePlayerError (line 1089) | function handlePlayerError() {
function renderEpisodes (line 1095) | function renderEpisodes(vodName, sourceCode, vodId) {
function copyLinks (line 1110) | function copyLinks() {
function toggleEpisodeOrder (line 1121) | function toggleEpisodeOrder(sourceCode, vodId) {
function importConfigFromUrl (line 1141) | async function importConfigFromUrl() {
function importConfig (line 1253) | async function importConfig() {
function exportConfig (line 1295) | async function exportConfig() {
function saveStringAsFile (line 1340) | function saveStringAsFile(content, fileName) {
FILE: js/config.js
constant PROXY_URL (line 2) | const PROXY_URL = '/proxy/';
constant SEARCH_HISTORY_KEY (line 4) | const SEARCH_HISTORY_KEY = 'videoSearchHistory';
constant MAX_HISTORY_ITEMS (line 5) | const MAX_HISTORY_ITEMS = 5;
constant PASSWORD_CONFIG (line 9) | const PASSWORD_CONFIG = {
constant SITE_CONFIG (line 15) | const SITE_CONFIG = {
constant API_SITES (line 24) | const API_SITES = {
function extendAPISites (line 34) | function extendAPISites(newSites) {
constant AGGREGATED_SEARCH_CONFIG (line 44) | const AGGREGATED_SEARCH_CONFIG = {
constant API_CONFIG (line 53) | const API_CONFIG = {
constant M3U8_PATTERN (line 75) | const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g;
constant CUSTOM_PLAYER_URL (line 78) | const CUSTOM_PLAYER_URL = 'player.html';
constant PLAYER_CONFIG (line 81) | const PLAYER_CONFIG = {
constant ERROR_MESSAGES (line 94) | const ERROR_MESSAGES = {
constant SECURITY_CONFIG (line 103) | const SECURITY_CONFIG = {
constant CUSTOM_API_CONFIG (line 111) | const CUSTOM_API_CONFIG = {
constant HIDE_BUILTIN_ADULT_APIS (line 123) | const HIDE_BUILTIN_ADULT_APIS = false;
FILE: js/customer_site.js
constant CUSTOMER_SITES (line 1) | const CUSTOMER_SITES = {
FILE: js/douban.js
function loadUserTags (line 12) | function loadUserTags() {
function saveUserTags (line 41) | function saveUserTags() {
function initDouban (line 57) | function initDouban() {
function updateDoubanVisibility (line 116) | function updateDoubanVisibility() {
function fillSearchInput (line 137) | function fillSearchInput(title) {
function fillAndSearch (line 159) | function fillAndSearch(title) {
function fillAndSearchWithDouban (line 192) | async function fillAndSearchWithDouban(title) {
function renderDoubanMovieTvSwitch (line 259) | function renderDoubanMovieTvSwitch() {
function renderDoubanTags (line 319) | function renderDoubanTags(tags) {
function setupDoubanRefreshBtn (line 369) | function setupDoubanRefreshBtn() {
function fetchDoubanTags (line 384) | function fetchDoubanTags() {
function renderRecommend (line 410) | function renderRecommend(tag, pageLimit, pageStart) {
function fetchDoubanData (line 444) | async function fetchDoubanData(url) {
function renderDoubanCards (line 503) | function renderDoubanCards(data, container) {
function resetToHome (line 574) | function resetToHome() {
function showTagManageModal (line 583) | function showTagManageModal() {
function addTag (line 696) | function addTag(tag) {
function deleteTag (line 734) | function deleteTag(tag) {
function resetTagsToDefault (line 770) | function resetTagsToDefault() {
FILE: js/password.js
function isPasswordProtected (line 7) | function isPasswordProtected() {
function isPasswordRequired (line 20) | function isPasswordRequired() {
function ensurePasswordProtection (line 28) | function ensurePasswordProtection() {
function verifyPassword (line 46) | async function verifyPassword(password) {
function isPasswordVerified (line 69) | function isPasswordVerified() {
function sha256 (line 95) | async function sha256(message) {
function showPasswordModal (line 112) | function showPasswordModal() {
function hidePasswordModal (line 165) | function hidePasswordModal() {
function showPasswordError (line 188) | function showPasswordError() {
function hidePasswordError (line 198) | function hidePasswordError() {
function handlePasswordSubmit (line 208) | async function handlePasswordSubmit() {
function initPasswordProtection (line 228) | function initPasswordProtection() {
FILE: js/player.js
function goBack (line 5) | function goBack(event) {
function initializePageContent (line 117) | function initializePageContent() {
function handleKeyboardShortcuts (line 285) | function handleKeyboardShortcuts(e) {
function showShortcutHint (line 363) | function showShortcutHint(text, direction) {
function initPlayer (line 400) | function initPlayer(videoUrl) {
class CustomHlsJsLoader (line 762) | class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
method constructor (line 763) | constructor(config) {
function filterAdsFromM3U8 (line 786) | function filterAdsFromM3U8(m3u8Content, strictMode = false) {
function showError (line 807) | function showError(message) {
function updateEpisodeInfo (line 821) | function updateEpisodeInfo() {
function updateButtonStates (line 830) | function updateButtonStates() {
function renderEpisodes (line 858) | function renderEpisodes() {
function playEpisode (line 888) | function playEpisode(index) {
function playPreviousEpisode (line 954) | function playPreviousEpisode() {
function playNextEpisode (line 961) | function playNextEpisode() {
function copyLinks (line 968) | function copyLinks() {
function toggleEpisodeOrder (line 982) | function toggleEpisodeOrder() {
function updateOrderButton (line 996) | function updateOrderButton() {
function setupProgressBarPreciseClicks (line 1007) | function setupProgressBarPreciseClicks() {
function saveToHistory (line 1076) | function saveToHistory() {
function showPositionRestoreHint (line 1175) | function showPositionRestoreHint(position) {
function formatTime (line 1208) | function formatTime(seconds) {
function startProgressSaveInterval (line 1218) | function startProgressSaveInterval() {
function saveCurrentProgress (line 1229) | function saveCurrentProgress() {
function setupLongPressSpeedControl (line 1274) | function setupLongPressSpeedControl() {
function clearVideoProgress (line 1389) | function clearVideoProgress() {
function getVideoId (line 1398) | function getVideoId() {
function toggleControlsLock (line 1408) | function toggleControlsLock() {
function closeEmbeddedPlayer (line 1420) | function closeEmbeddedPlayer() {
function renderResourceInfoBar (line 1435) | function renderResourceInfoBar() {
function testVideoSourceSpeed (line 1489) | async function testVideoSourceSpeed(sourceKey, vodId) {
function formatSpeedDisplay (line 1575) | function formatSpeedDisplay(speedResult) {
function showSwitchResourceModal (line 1596) | async function showSwitchResourceModal() {
function switchToResource (line 1715) | async function switchToResource(sourceKey, vodId) {
FILE: js/proxy-auth.js
function getPasswordHash (line 12) | async function getPasswordHash() {
function addAuthToProxyUrl (line 60) | async function addAuthToProxyUrl(url) {
function validateProxyAuth (line 84) | function validateProxyAuth(authHash, serverPasswordHash, timestamp) {
function clearAuthCache (line 109) | function clearAuthCache() {
FILE: js/search.js
function searchByAPIAndKeyWord (line 1) | async function searchByAPIAndKeyWord(apiId, query) {
FILE: js/sha256.js
function sha256 (line 1) | async function sha256(message) {
FILE: js/ui.js
function toggleSettings (line 2) | function toggleSettings(e) {
function showToast (line 30) | function showToast(message, type = 'error') {
function showNextToast (line 57) | function showNextToast() {
function showLoading (line 99) | function showLoading(message = '加载中...') {
function hideLoading (line 117) | function hideLoading() {
function updateSiteStatus (line 128) | function updateSiteStatus(isAvailable) {
function closeModal (line 137) | function closeModal() {
function getSearchHistory (line 144) | function getSearchHistory() {
function saveSearchHistory (line 168) | function saveSearchHistory(query) {
function renderSearchHistory (line 217) | function renderSearchHistory() {
function deleteSingleSearchHistory (line 275) | function deleteSingleSearchHistory(query) {
function clearSearchHistory (line 290) | function clearSearchHistory() {
function toggleHistory (line 309) | function toggleHistory(e) {
function formatTimestamp (line 337) | function formatTimestamp(timestamp) {
function getViewingHistory (line 371) | function getViewingHistory() {
function loadViewingHistory (line 382) | function loadViewingHistory() {
function formatPlaybackTime (line 471) | function formatPlaybackTime(seconds) {
function deleteHistoryItem (line 481) | function deleteHistoryItem(encodedUrl) {
function playFromHistory (line 507) | async function playFromHistory(url, title, episodeIndex, playbackPositio...
function addToViewingHistory (line 684) | function addToViewingHistory(videoInfo) {
function clearViewingHistory (line 769) | function clearViewingHistory() {
function clearLocalStorage (line 811) | function clearLocalStorage() {
function showImportBox (line 900) | function showImportBox(fun) {
FILE: js/version-check.js
function fetchVersion (line 21) | async function fetchVersion(url, errorMessage, options = {}) {
function checkForUpdates (line 30) | async function checkForUpdates() {
function formatVersion (line 88) | function formatVersion(versionString) {
function createErrorVersionElement (line 112) | function createErrorVersionElement(errorMessage) {
function addVersionInfoToFooter (line 121) | function addVersionInfoToFooter() {
function displayVersionElement (line 171) | function displayVersionElement(element) {
FILE: middleware.js
function middleware (line 4) | async function middleware(request) {
FILE: netlify/edge-functions/inject-env.js
function sha256 (line 24) | async function sha256(message) {
FILE: netlify/functions/proxy.mjs
constant DEBUG_ENABLED (line 8) | const DEBUG_ENABLED = process.env.DEBUG === 'true';
constant CACHE_TTL (line 9) | const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10);
constant MAX_RECURSION (line 10) | const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10);
constant USER_AGENTS (line 13) | let USER_AGENTS = [
constant FILTER_DISCONTINUITY (line 33) | const FILTER_DISCONTINUITY = false;
function logDebug (line 37) | function logDebug(message) {
function getTargetUrlFromPath (line 43) | function getTargetUrlFromPath(encodedPath) {
function getBaseUrl (line 56) | function getBaseUrl(urlStr) {
function resolveUrl (line 71) | function resolveUrl(baseUrl, relativeUrl) {
function rewriteUrlToProxy (line 82) | function rewriteUrlToProxy(targetUrl) {
function getRandomUserAgent (line 88) | function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.rando...
function validateAuth (line 93) | function validateAuth(event) {
function fetchContentWithType (line 126) | async function fetchContentWithType(targetUrl, requestHeaders) {
function isM3u8Content (line 153) | function isM3u8Content(content, contentType) {
function processKeyLine (line 158) | function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]...
function processMapLine (line 159) | function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]...
function processMediaPlaylist (line 160) | function processMediaPlaylist(url, content) {
function processM3u8Content (line 172) | async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
function processMasterPlaylist (line 176) | async function processMasterPlaylist(url, content, recursionDepth) {
FILE: server.mjs
function sha256Hash (line 47) | function sha256Hash(input) {
function renderPage (line 55) | async function renderPage(filePath, password) {
function isValidUrl (line 97) | function isValidUrl(urlString) {
function validateProxyAuth (line 122) | function validateProxyAuth(req) {
Condensed preview — 55 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (441K chars).
[
{
"path": ".dockerignore",
"chars": 128,
"preview": ".github/\nnode_modules/\nnetlify*\n.gitignore\n.dockerignore\n.env*\nnodemon.json\nvercel.json\nDockerfile*\ndocker-compose*.yml\n"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.yml",
"chars": 1860,
"preview": "name: BUG Report / BUG 报告\ndescription: \"Create a report to help us improve\"\ntitle: \"[BUG]\"\nlabels: [\"bug\"]\nbody:\n - typ"
},
{
"path": ".github/ISSUE_TEMPLATE/config.yml",
"chars": 200,
"preview": "blank_issues_enabled: false\ncontact_links:\n - name: How To Ask Questions The Smart Way / 提问的智慧\n about: Read it befor"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.yml",
"chars": 1039,
"preview": "name: Feature request / 功能请求\ndescription: \"Suggest an idea for this project\"\ntitle: \"[Feature Request]\"\nlabels: [\"enhanc"
},
{
"path": ".github/workflows/docker-build.yml",
"chars": 1811,
"preview": "name: Build LibreTV image\n\non:\n workflow_run:\n workflows: [\"Bump version\"]\n types:\n - completed\n workflow_d"
},
{
"path": ".github/workflows/nomore-spam.yml",
"chars": 732,
"preview": "name: NoMore Spam\n\non:\n issues:\n types: [opened]\n pull_request_target:\n types: [opened]\n \npermissions:\n cont"
},
{
"path": ".github/workflows/sync.yml",
"chars": 1030,
"preview": "name: Upstream Sync\n\npermissions:\n contents: write\n\non:\n schedule:\n - cron: \"0 4 * * *\" # At 12PM UTC+8\n workflow_"
},
{
"path": ".github/workflows/version.yml",
"chars": 911,
"preview": "name: Bump version\n\non:\n push:\n branches:\n - main\n workflow_dispatch:\n\njobs:\n bump-version:\n if: github.re"
},
{
"path": ".gitignore",
"chars": 262,
"preview": "# Logs\n*.log\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\nlerna-debug.log*\n\nnode_modules\n.DS_Store\ndis"
},
{
"path": "CONTRIBUTING.md",
"chars": 3932,
"preview": "# 贡献指南\n\n感谢您对 LibreTV 项目的关注!我们欢迎所有形式的贡献,包括但不限于代码提交、问题报告、功能建议、文档改进等。\n\n## 🚀 快速开始\n\n### 开发环境要求\n\n- Node.js 16.0 或更高版本\n- Git\n- "
},
{
"path": "Dockerfile",
"chars": 679,
"preview": "FROM node:lts-alpine\n\nLABEL maintainer=\"LibreTV Team\"\nLABEL description=\"LibreTV - 免费在线视频搜索与观看平台\"\n\n# 设置环境变量\nENV PORT=808"
},
{
"path": "LICENSE",
"chars": 11342,
"preview": " Apache License\n Version 2.0, January 2004\n "
},
{
"path": "README.md",
"chars": 4042,
"preview": "# LibreTV - 免费在线视频搜索与观看平台\n\n<div align=\"center\">\n <img src=\"image/logo.png\" alt=\"LibreTV Logo\" width=\"120\">\n <br>\n <p>"
},
{
"path": "VERSION.txt",
"chars": 13,
"preview": "202508060117\n"
},
{
"path": "about.html",
"chars": 10518,
"preview": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n\t<meta charset=\"UTF-8\">\n\t<meta name=\"viewport\" content=\"width=device-width, init"
},
{
"path": "api/proxy/[...path].mjs",
"chars": 18566,
"preview": "// /api/proxy/[...path].mjs - Vercel Serverless Function (ES Module)\n\nimport fetch from 'node-fetch';\nimport { URL } fro"
},
{
"path": "css/index.css",
"chars": 3815,
"preview": "/* 主页特定样式 */\n\n/* 历史记录和设置按钮定位样式 */\n.top-corner-button {\n position: fixed;\n z-index: 10;\n background: #222;\n b"
},
{
"path": "css/modals.css",
"chars": 4596,
"preview": "/* 模态框通用样式 */\n.modal-overlay {\n position: fixed;\n inset: 0;\n background-color: rgba(0, 0, 0, 0.95);\n display"
},
{
"path": "css/player.css",
"chars": 8090,
"preview": "body, html {\n margin: 0;\n padding: 0;\n width: 100%;\n height: 100%;\n background-color: #0f1622;\n color:"
},
{
"path": "css/styles.css",
"chars": 20420,
"preview": "/* \nLibreTV 全局样式\n包含多个页面共享的基础样式\n对于特定页面的样式,请参考:\n- index.css: 首页特定样式\n- player.css: 播放器页面特定样式\n- watch.css: 重定向页面特定样式\n- modal"
},
{
"path": "css/watch.css",
"chars": 2418,
"preview": "/* 添加重定向页面的基本样式 */\nbody {\n font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;\n background-color: #0f162"
},
{
"path": "docker-compose.yml",
"chars": 499,
"preview": "services:\n libretv:\n image: bestzwei/libretv:latest\n container_name: libretv\n ports:\n - \"8899:8080\" # 将内部"
},
{
"path": "functions/_middleware.js",
"chars": 769,
"preview": "import { sha256 } from '../js/sha256.js';\n\nexport async function onRequest(context) {\n const { request, env, next } = c"
},
{
"path": "functions/proxy/[[path]].js",
"chars": 22508,
"preview": "// functions/proxy/[[path]].js\n\n// --- 配置 (现在从 Cloudflare 环境变量读取) ---\n// 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量:\n"
},
{
"path": "index.html",
"chars": 26656,
"preview": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "js/api.js",
"chars": 22280,
"preview": "// 改进的API请求处理函数\nasync function handleApiRequest(url) {\n const customApi = url.searchParams.get('customApi') || '';\n "
},
{
"path": "js/app.js",
"chars": 50353,
"preview": "// 全局变量\nlet selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[\"tyyszy\",\"dyttzy\", \"bfzy\", \"ruyi\"]'); //"
},
{
"path": "js/config.js",
"chars": 3359,
"preview": "// 全局常量配置\nconst PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写)\n// const HOPLAYER_URL = 'https:"
},
{
"path": "js/customer_site.js",
"chars": 265,
"preview": "const CUSTOMER_SITES = {\n qiqi: {\n api: 'https://www.qiqidys.com/api.php/provide/vod',\n name: '七七资源',\n "
},
{
"path": "js/douban.js",
"chars": 26220,
"preview": "// 豆瓣热门电影电视剧推荐功能\n\n// 豆瓣标签列表 - 修改为默认标签\nlet defaultMovieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', "
},
{
"path": "js/index-page.js",
"chars": 2532,
"preview": "// 页面加载后显示弹窗脚本\ndocument.addEventListener('DOMContentLoaded', function() {\n // 弹窗显示脚本\n // 检查用户是否已经看过声明\n const ha"
},
{
"path": "js/password.js",
"chars": 6916,
"preview": "// 密码保护功能\n\n/**\n * 检查是否设置了密码保护\n * 通过读取页面上嵌入的环境变量来检查\n */\nfunction isPasswordProtected() {\n // 只检查普通密码\n const pwd = w"
},
{
"path": "js/player.js",
"chars": 59134,
"preview": "const selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '[]');\nconst customAPIs = JSON.parse(localStorag"
},
{
"path": "js/proxy-auth.js",
"chars": 3069,
"preview": "/**\n * 代理请求鉴权模块\n * 为代理请求添加基于 PASSWORD 的鉴权机制\n */\n\n// 从全局配置获取密码哈希(如果存在)\nlet cachedPasswordHash = null;\n\n/**\n * 获取当前会话的密码哈希"
},
{
"path": "js/pwa-register.js",
"chars": 164,
"preview": "// PWA 注册\nif ('serviceWorker' in navigator) {\n window.addEventListener('load', () => {\n navigator.serviceWorke"
},
{
"path": "js/search.js",
"chars": 4982,
"preview": "async function searchByAPIAndKeyWord(apiId, query) {\n try {\n let apiUrl, apiName, apiBaseUrl;\n \n "
},
{
"path": "js/sha256.js",
"chars": 307,
"preview": "export async function sha256(message) {\n const msgBuffer = new TextEncoder().encode(message);\n const hashBuffer = "
},
{
"path": "js/ui.js",
"chars": 41186,
"preview": "// UI相关函数\nfunction toggleSettings(e) {\n // 强化的密码保护校验 - 防止绕过\n try {\n if (window.ensurePasswordProtection) {\n"
},
{
"path": "js/version-check.js",
"chars": 6329,
"preview": "// 添加动画样式\n(function() {\n const style = document.createElement('style');\n style.textContent = `\n @keyframes "
},
{
"path": "js/watch.js",
"chars": 2804,
"preview": "// 获取当前URL的参数,并将它们传递给player.html\nwindow.onload = function() {\n // 获取当前URL的查询参数\n const currentParams = new URLSearc"
},
{
"path": "manifest.json",
"chars": 439,
"preview": "{\n \"name\": \"LibreTV\",\n \"short_name\": \"LibreTV\",\n \"description\": \"免费在线视频搜索与观看平台\",\n \"start_url\": \".\",\n \"dis"
},
{
"path": "middleware.js",
"chars": 1488,
"preview": "import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现\n\n// Vercel Middleware to inject environment variables\nexport"
},
{
"path": "netlify/edge-functions/inject-env.js",
"chars": 1684,
"preview": "// Netlify Edge Function to inject environment variables into HTML\nexport default async (request, context) => {\n const "
},
{
"path": "netlify/functions/proxy.mjs",
"chars": 16997,
"preview": "// /netlify/functions/proxy.mjs - Netlify Function (ES Module)\n\nimport fetch from 'node-fetch';\nimport { URL } from 'url"
},
{
"path": "netlify.toml",
"chars": 630,
"preview": "# netlify.toml\n\n[build]\n # 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish\n # publish = \".\" # 假设你的 HTML/CSS/JS 文件在根目录\n f"
},
{
"path": "nodemon.json",
"chars": 301,
"preview": "{\n \"watch\": [\n \"server.mjs\",\n \"*.html\",\n \".env\"\n ],\n \"ext\": \"js,mjs,json,html,css\",\n \"ignore\": [\n \"node_"
},
{
"path": "package.json",
"chars": 477,
"preview": "{\r\n \"name\": \"libretv\",\r\n \"type\": \"module\",\r\n \"version\": \"1.1.0\",\r\n \"private\": true,\r\n \"description\": \"免费在线视频搜索与观看平台"
},
{
"path": "player.html",
"chars": 15051,
"preview": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
},
{
"path": "render.yaml",
"chars": 164,
"preview": "services:\n - type: web\n name: libretv\n runtime: node\n plan: free\n buildCommand: 'npm install'\n startComm"
},
{
"path": "robots.txt",
"chars": 25,
"preview": "User-agent: *\nDisallow: /"
},
{
"path": "server.mjs",
"chars": 6503,
"preview": "import path from 'path';\nimport express from 'express';\nimport axios from 'axios';\nimport cors from 'cors';\nimport { fil"
},
{
"path": "service-worker.js",
"chars": 181,
"preview": "// 不使用缓存,直接通过网络获取资源\nself.addEventListener('install', event => {\n self.skipWaiting();\n});\n\nself.addEventListener('activa"
},
{
"path": "vercel.json",
"chars": 427,
"preview": "{\n \"rewrites\": [\n {\n \"source\": \"/proxy/:path*\",\n \"destination\": \"/api/proxy/:path*\"\n },\n {\n \"so"
},
{
"path": "watch.html",
"chars": 1143,
"preview": "<!DOCTYPE html>\n<html lang=\"zh\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width"
}
]
// ... and 1 more files (download for full content)
About this extraction
This page contains the full source code of the LibreSpark/LibreTV GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 55 files (412.3 KB), approximately 116.3k tokens, and a symbol index with 210 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.