samlax12 commited on
Commit
7eff83b
·
verified ·
1 Parent(s): 9bac436

Upload 30 files

Browse files
Files changed (30) hide show
  1. Dockerfile +25 -0
  2. LICENSE +201 -0
  3. about.html +47 -0
  4. api/proxy/[...path].mjs +451 -0
  5. css/styles.css +784 -0
  6. docker-compose.yml +10 -0
  7. docker-entrypoint.sh +31 -0
  8. functions/_middleware.js +37 -0
  9. functions/proxy/[[path]].js +519 -0
  10. index.html +431 -0
  11. js/api.js +613 -0
  12. js/app.js +1011 -0
  13. js/config.js +205 -0
  14. js/douban.js +361 -0
  15. js/password.js +179 -0
  16. js/sha256.js +6 -0
  17. js/ui.js +621 -0
  18. middleware.js +48 -0
  19. netlify.toml +19 -0
  20. netlify/functions/proxy.mjs +274 -0
  21. nginx.conf +86 -0
  22. package-lock.json +106 -0
  23. package.json +15 -0
  24. player.html +1403 -0
  25. privacy.html +28 -0
  26. proxy.lua +38 -0
  27. robots.txt +6 -0
  28. sitemap.xml +21 -0
  29. vercel.json +8 -0
  30. watch.html +11 -0
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM fabiocicerchia/nginx-lua:1.27.5-alpine3.21.3
2
+ LABEL maintainer="LibreTV Team"
3
+ LABEL description="LibreTV - 免费在线视频搜索与观看平台"
4
+
5
+ # 复制应用文件
6
+ COPY . /usr/share/nginx/html
7
+
8
+ # 复制Nginx配置文件
9
+ COPY nginx.conf /etc/nginx/conf.d/default.conf
10
+
11
+ # 添加执行权限并设置为入口点脚本
12
+ COPY docker-entrypoint.sh /
13
+ RUN chmod +x /docker-entrypoint.sh
14
+
15
+ # 暴露端口
16
+ EXPOSE 80
17
+
18
+ # 设置入口点
19
+ ENTRYPOINT ["/docker-entrypoint.sh"]
20
+
21
+ # 启动nginx
22
+ CMD ["nginx", "-g", "daemon off;"]
23
+
24
+ # 健康检查
25
+ HEALTHCHECK --interval=30s --timeout=3s CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2025 LibreTV Team
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
about.html ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>关于我们 - LibreTV</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="css/styles.css">
9
+
10
+ </head>
11
+ <body class="page-bg text-white">
12
+ <div class="container mx-auto px-4 py-8">
13
+ <header class="text-center mb-8">
14
+ <h1 class="text-5xl font-bold gradient-text">关于我们</h1>
15
+ </header>
16
+ <main class="text-center">
17
+ <p class="text-gray-300 mb-4">
18
+ 本项目代码托管在 GitHub 上,欢迎访问我们的仓库:
19
+ <a href="https://github.com/LibreSpark/LibreTV" class="text-blue-400 hover:underline" target="_blank" rel="noopener">https://github.com/LibreSpark/LibreTV</a>
20
+ </p>
21
+ <p class="text-gray-300 mb-8">
22
+ LibreTV 是一个免费的在线视频搜索平台,提供视频搜索和播放服务,致力于为用户带来最佳体验。
23
+ </p>
24
+
25
+ <!-- 版权声明与投诉机制 -->
26
+ <div class="mt-12 mb-8 max-w-2xl mx-auto border border-gray-700 rounded-lg p-6 bg-[#111]">
27
+ <h2 class="text-xl font-semibold mb-4 text-blue-400">版权声明与投诉机制</h2>
28
+ <p class="text-gray-300 mb-4 text-left">
29
+ LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。
30
+ </p>
31
+ <p class="text-gray-300 mb-4 text-left">
32
+ 若您是版权方或相关权利人,发现本站搜索结果中存在侵犯您合法权益的内容,请通过以下渠道向我们反馈:
33
+ </p>
34
+ <div class="bg-[#1a1a1a] p-4 rounded-md mb-4 text-left">
35
+ <p class="text-gray-300"><span class="font-semibold">投诉邮箱:</span><a href="mailto:troll@pissmail.com" class="text-blue-400 hover:underline">troll@pissmail.com</a></p>
36
+ </div>
37
+ <p class="text-gray-300 text-left">
38
+ 请在投诉邮件中提供:您的身份证明、权利证明、侵权内容的具体链接及相关说明。我们将在收到投诉后尽快处理,对于确认侵权的内容,将立即断开相关链接,停止展示侵权内容,并将处理结果反馈给您。
39
+ </p>
40
+ </div>
41
+ </main>
42
+ <footer class="mt-12 text-center">
43
+ <a href="index.html" class="text-gray-400 hover:text-white transition-colors">回到首页</a>
44
+ </footer>
45
+ </div>
46
+ </body>
47
+ </html>
api/proxy/[...path].mjs ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // /api/proxy/[...path].mjs - Vercel Serverless Function (ES Module)
2
+
3
+ import fetch from 'node-fetch';
4
+ import { URL } from 'url'; // 使用 Node.js 内置 URL 处理
5
+
6
+ // --- 配置 (从环境变量读取) ---
7
+ const DEBUG_ENABLED = process.env.DEBUG === 'true';
8
+ const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // 默认 24 小时
9
+ const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // 默认 5 层
10
+
11
+ // --- User Agent 处理 ---
12
+ // 默认 User Agent 列表
13
+ let USER_AGENTS = [
14
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
15
+ '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'
16
+ ];
17
+ // 尝试从环境变量读取并解析 USER_AGENTS_JSON
18
+ try {
19
+ const agentsJsonString = process.env.USER_AGENTS_JSON;
20
+ if (agentsJsonString) {
21
+ const parsedAgents = JSON.parse(agentsJsonString);
22
+ // 检查解析结果是否为非空数组
23
+ if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
24
+ USER_AGENTS = parsedAgents; // 使用环境变量中的数组
25
+ console.log(`[代理日志] 已从环境变量加载 ${USER_AGENTS.length} 个 User Agent。`);
26
+ } else {
27
+ console.warn("[代理日志] 环境变量 USER_AGENTS_JSON 不是有效的非空数组,使用默认值。");
28
+ }
29
+ } else {
30
+ console.log("[代理日志] 未设置环境变量 USER_AGENTS_JSON,使用默认 User Agent。");
31
+ }
32
+ } catch (e) {
33
+ // 如果 JSON 解析失败,记录错误并使用默认值
34
+ console.error(`[代理日志] 解析环境变量 USER_AGENTS_JSON 出错: ${e.message}。使用默认 User Agent。`);
35
+ }
36
+
37
+ // 广告过滤在代理中禁用,由播放器处理
38
+ const FILTER_DISCONTINUITY = false;
39
+
40
+
41
+ // --- 辅助函数 ---
42
+
43
+ function logDebug(message) {
44
+ if (DEBUG_ENABLED) {
45
+ console.log(`[代理日志] ${message}`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * 从代理请求路径中提取编码后的目标 URL。
51
+ * @param {string} encodedPath - URL 编码后的路径部分 (例如 "https%3A%2F%2F...")
52
+ * @returns {string|null} 解码后的目标 URL,如果无效则返回 null。
53
+ */
54
+ function getTargetUrlFromPath(encodedPath) {
55
+ if (!encodedPath) {
56
+ logDebug("getTargetUrlFromPath 收到空路径。");
57
+ return null;
58
+ }
59
+ try {
60
+ const decodedUrl = decodeURIComponent(encodedPath);
61
+ // 基础检查,看是否像一个 HTTP/HTTPS URL
62
+ if (decodedUrl.match(/^https?:\/\/.+/i)) {
63
+ return decodedUrl;
64
+ } else {
65
+ logDebug(`无效的解码 URL 格式: ${decodedUrl}`);
66
+ // 备选检查:原始路径是否未编码但看起来像 URL?
67
+ if (encodedPath.match(/^https?:\/\/.+/i)) {
68
+ logDebug(`警告: 路径未编码但看起来像 URL: ${encodedPath}`);
69
+ return encodedPath;
70
+ }
71
+ return null;
72
+ }
73
+ } catch (e) {
74
+ // 捕获解码错误 (例如格式错误的 URI)
75
+ logDebug(`解码目标 URL 出错: ${encodedPath} - ${e.message}`);
76
+ return null;
77
+ }
78
+ }
79
+
80
+ function getBaseUrl(urlStr) {
81
+ if (!urlStr) return '';
82
+ try {
83
+ const parsedUrl = new URL(urlStr);
84
+ // 处理根目录或只有文件名的情况
85
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean); // 移除空字符串
86
+ if (pathSegments.length <= 1) {
87
+ return `${parsedUrl.origin}/`;
88
+ }
89
+ pathSegments.pop(); // 移除最后一段
90
+ return `${parsedUrl.origin}/${pathSegments.join('/')}/`;
91
+ } catch (e) {
92
+ logDebug(`获取 BaseUrl 失败: "${urlStr}": ${e.message}`);
93
+ // 备用方法:查找最后一个斜杠
94
+ const lastSlashIndex = urlStr.lastIndexOf('/');
95
+ if (lastSlashIndex > urlStr.indexOf('://') + 2) { // 确保不是协议部分的斜杠
96
+ return urlStr.substring(0, lastSlashIndex + 1);
97
+ }
98
+ return urlStr + '/'; // 如果没有路径,添加斜杠
99
+ }
100
+ }
101
+
102
+ function resolveUrl(baseUrl, relativeUrl) {
103
+ if (!relativeUrl) return ''; // 处理空的 relativeUrl
104
+ if (relativeUrl.match(/^https?:\/\/.+/i)) {
105
+ return relativeUrl; // 已经是绝对 URL
106
+ }
107
+ if (!baseUrl) return relativeUrl; // 没有基础 URL 无法解析
108
+
109
+ try {
110
+ // 使用 Node.js 的 URL 构造函数处理相对路径
111
+ return new URL(relativeUrl, baseUrl).toString();
112
+ } catch (e) {
113
+ logDebug(`URL 解析失败: base="${baseUrl}", relative="${relativeUrl}". 错误: ${e.message}`);
114
+ // 简单的备用逻辑
115
+ if (relativeUrl.startsWith('/')) {
116
+ try {
117
+ const baseOrigin = new URL(baseUrl).origin;
118
+ return `${baseOrigin}${relativeUrl}`;
119
+ } catch { return relativeUrl; } // 如果 baseUrl 也无效,返回原始相对路径
120
+ } else {
121
+ // 假设相对于包含基础 URL ���源的目录
122
+ return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`;
123
+ }
124
+ }
125
+ }
126
+
127
+ // ** 已修正:确保生成 /proxy/ 前缀的链接 **
128
+ function rewriteUrlToProxy(targetUrl) {
129
+ if (!targetUrl || typeof targetUrl !== 'string') return '';
130
+ // 返回与 vercel.json 的 "source" 和前端 PROXY_URL 一致的路径
131
+ return `/proxy/${encodeURIComponent(targetUrl)}`;
132
+ }
133
+
134
+ function getRandomUserAgent() {
135
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
136
+ }
137
+
138
+ async function fetchContentWithType(targetUrl, requestHeaders) {
139
+ // 准备请求头
140
+ const headers = {
141
+ 'User-Agent': getRandomUserAgent(),
142
+ 'Accept': requestHeaders['accept'] || '*/*', // 传递原始 Accept 头(如果有)
143
+ 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',
144
+ // 尝试设置一个合理的 Referer
145
+ 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,
146
+ };
147
+ // 清理空值的头
148
+ Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});
149
+
150
+ logDebug(`准备请求目标: ${targetUrl},请求头: ${JSON.stringify(headers)}`);
151
+
152
+ try {
153
+ // 发起 fetch 请求
154
+ const response = await fetch(targetUrl, { headers, redirect: 'follow' });
155
+
156
+ // 检查响应是否成功
157
+ if (!response.ok) {
158
+ const errorBody = await response.text().catch(() => ''); // 尝试获取错误响应体
159
+ logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);
160
+ // 创建一个包含状态码的错误对象
161
+ const err = new Error(`HTTP 错误 ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);
162
+ err.status = response.status; // 将状态码附加到错误对象
163
+ throw err; // 抛出错误
164
+ }
165
+
166
+ // 读取响应内容
167
+ const content = await response.text();
168
+ const contentType = response.headers.get('content-type') || '';
169
+ logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);
170
+ // 返回结果
171
+ return { content, contentType, responseHeaders: response.headers };
172
+
173
+ } catch (error) {
174
+ // 捕获 fetch 本身的错误(网络、超时等)或上面抛出的 HTTP 错误
175
+ logDebug(`请求异常 ${targetUrl}: ${error.message}`);
176
+ // 重新抛出,确保包含原始错误信息
177
+ throw new Error(`请求目标 URL 失败 ${targetUrl}: ${error.message}`);
178
+ }
179
+ }
180
+
181
+ function isM3u8Content(content, contentType) {
182
+ if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {
183
+ return true;
184
+ }
185
+ return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
186
+ }
187
+
188
+ function processKeyLine(line, baseUrl) {
189
+ return line.replace(/URI="([^"]+)"/, (match, uri) => {
190
+ const absoluteUri = resolveUrl(baseUrl, uri);
191
+ logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);
192
+ return `URI="${rewriteUrlToProxy(absoluteUri)}"`;
193
+ });
194
+ }
195
+
196
+ function processMapLine(line, baseUrl) {
197
+ return line.replace(/URI="([^"]+)"/, (match, uri) => {
198
+ const absoluteUri = resolveUrl(baseUrl, uri);
199
+ logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);
200
+ return `URI="${rewriteUrlToProxy(absoluteUri)}"`;
201
+ });
202
+ }
203
+
204
+ function processMediaPlaylist(url, content) {
205
+ const baseUrl = getBaseUrl(url);
206
+ if (!baseUrl) {
207
+ logDebug(`无法确定媒体列表的 Base URL: ${url},相对路径可能无法处理。`);
208
+ }
209
+ const lines = content.split('\n');
210
+ const output = [];
211
+ for (let i = 0; i < lines.length; i++) {
212
+ const line = lines[i].trim();
213
+ // 保留最后一个空行
214
+ if (!line && i === lines.length - 1) { output.push(line); continue; }
215
+ if (!line) continue; // 跳过中间空行
216
+ // 广告过滤已禁用
217
+ if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }
218
+ if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }
219
+ if (line.startsWith('#EXTINF')) { output.push(line); continue; }
220
+ // 处理 URL 行
221
+ if (!line.startsWith('#')) {
222
+ const absoluteUrl = resolveUrl(baseUrl, line);
223
+ logDebug(`重写媒体片段: 原始='${line}', 解析后='${absoluteUrl}'`);
224
+ output.push(rewriteUrlToProxy(absoluteUrl)); continue;
225
+ }
226
+ // 保留其他 M3U8 标签
227
+ output.push(line);
228
+ }
229
+ return output.join('\n');
230
+ }
231
+
232
+ async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
233
+ // 判断是主列表还是媒体列表
234
+ if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {
235
+ logDebug(`检测到主播放列表: ${targetUrl} (深度: ${recursionDepth})`);
236
+ return await processMasterPlaylist(targetUrl, content, recursionDepth);
237
+ }
238
+ logDebug(`检测到媒体播放列表: ${targetUrl} (深度: ${recursionDepth})`);
239
+ return processMediaPlaylist(targetUrl, content);
240
+ }
241
+
242
+ async function processMasterPlaylist(url, content, recursionDepth) {
243
+ // 检查递归深度
244
+ if (recursionDepth > MAX_RECURSION) {
245
+ throw new Error(`处理主播放列表时,递归深度超过最大限制 (${MAX_RECURSION}): ${url}`);
246
+ }
247
+ const baseUrl = getBaseUrl(url);
248
+ const lines = content.split('\n');
249
+ let highestBandwidth = -1;
250
+ let bestVariantUrl = '';
251
+
252
+ // 查找最高带宽的流
253
+ for (let i = 0; i < lines.length; i++) {
254
+ if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
255
+ const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/);
256
+ const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
257
+ let variantUriLine = '';
258
+ // 找到下一行的 URI
259
+ for (let j = i + 1; j < lines.length; j++) {
260
+ const line = lines[j].trim();
261
+ if (line && !line.startsWith('#')) { variantUriLine = line; i = j; break; }
262
+ }
263
+ if (variantUriLine && currentBandwidth >= highestBandwidth) {
264
+ highestBandwidth = currentBandwidth;
265
+ bestVariantUrl = resolveUrl(baseUrl, variantUriLine);
266
+ }
267
+ }
268
+ }
269
+ // 如果没有找到带宽信息,尝试查找第一个 .m3u8 链接
270
+ if (!bestVariantUrl) {
271
+ logDebug(`主播放列表中未找到 BANDWIDTH 信息,尝试查找第一个 URI: ${url}`);
272
+ for (let i = 0; i < lines.length; i++) {
273
+ const line = lines[i].trim();
274
+ // 更可靠地匹配 .m3u8 链接
275
+ if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) {
276
+ bestVariantUrl = resolveUrl(baseUrl, line);
277
+ logDebug(`备选方案: 找到第一个子播放列表 URI: ${bestVariantUrl}`);
278
+ break;
279
+ }
280
+ }
281
+ }
282
+ // 如果仍然没有找到子列表 URL
283
+ if (!bestVariantUrl) {
284
+ logDebug(`在主播放列表 ${url} 中未找到有效的子列表 URI,将其作为媒体列表处理。`);
285
+ return processMediaPlaylist(url, content);
286
+ }
287
+
288
+ logDebug(`选择的子播放列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);
289
+ // 请求选定的子播放列表内容 (注意:这里传递 {} 作为请求头,不传递客户端的原始请求头)
290
+ const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});
291
+
292
+ // 检查获取的内容是否是 M3U8
293
+ if (!isM3u8Content(variantContent, variantContentType)) {
294
+ logDebug(`获取的子播放列表 ${bestVariantUrl} 不是 M3U8 (类型: ${variantContentType}),将其作为媒体列表处理。`);
295
+ return processMediaPlaylist(bestVariantUrl, variantContent);
296
+ }
297
+
298
+ // 递归处理获取到的子 M3U8 内容
299
+ return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);
300
+ }
301
+
302
+
303
+ // --- Vercel Handler 函数 ---
304
+ export default async function handler(req, res) {
305
+ // --- 记录请求开始 ---
306
+ console.info('--- Vercel 代理请求开始 ---');
307
+ console.info('时间:', new Date().toISOString());
308
+ console.info('方法:', req.method);
309
+ console.info('URL:', req.url); // 原始请求 URL (例如 /proxy/...)
310
+ console.info('查询参数:', JSON.stringify(req.query)); // Vercel 解析的查询参数
311
+
312
+ // --- 提前设置 CORS 头 ---
313
+ res.setHeader('Access-Control-Allow-Origin', '*');
314
+ res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
315
+ res.setHeader('Access-Control-Allow-Headers', '*'); // 允许所有请求头
316
+
317
+ // --- 处理 OPTIONS 预检请求 ---
318
+ if (req.method === 'OPTIONS') {
319
+ console.info("处理 OPTIONS 预检请求");
320
+ res.status(204).setHeader('Access-Control-Max-Age', '86400').end(); // 缓存预检结果 24 小时
321
+ return;
322
+ }
323
+
324
+ let targetUrl = null; // 初始化目标 URL
325
+
326
+ try { // ---- 开始主处理逻辑的 try 块 ----
327
+
328
+ // --- 提取目标 URL (主要依赖 req.query["...path"]) ---
329
+ // Vercel 将 :path* 捕获的内容(可能包含斜杠)放入 req.query["...path"] 数组
330
+ const pathData = req.query["...path"]; // 使用正确的键名
331
+ let encodedUrlPath = '';
332
+
333
+ if (pathData) {
334
+ if (Array.isArray(pathData)) {
335
+ encodedUrlPath = pathData.join('/'); // 重新组合
336
+ console.info(`从 req.query["...path"] (数组) 组合的编码路径: ${encodedUrlPath}`);
337
+ } else if (typeof pathData === 'string') {
338
+ encodedUrlPath = pathData; // 也处理 Vercel 可能只返回字符串的情况
339
+ console.info(`从 req.query["...path"] (字符串) 获取的编码路径: ${encodedUrlPath}`);
340
+ } else {
341
+ console.warn(`[代理警告] req.query["...path"] 类型未知: ${typeof pathData}`);
342
+ }
343
+ } else {
344
+ console.warn(`[代理警告] req.query["...path"] 为空或未定义。`);
345
+ // 备选:尝试从 req.url 提取(如果需要)
346
+ if (req.url && req.url.startsWith('/proxy/')) {
347
+ encodedUrlPath = req.url.substring('/proxy/'.length);
348
+ console.info(`使用备选方法从 req.url 提取的编码路径: ${encodedUrlPath}`);
349
+ }
350
+ }
351
+
352
+ // 如果仍然为空,则无法继续
353
+ if (!encodedUrlPath) {
354
+ throw new Error("无法从请求中确定编码后的目标路径。");
355
+ }
356
+
357
+ // 解析目标 URL
358
+ targetUrl = getTargetUrlFromPath(encodedUrlPath);
359
+ console.info(`解析出的目标 URL: ${targetUrl || 'null'}`); // 记录解析结果
360
+
361
+ // 检查目标 URL 是否有效
362
+ if (!targetUrl) {
363
+ // 抛出包含更多上下文的错误
364
+ throw new Error(`无效的代理请求路径。无法从组合路径 "${encodedUrlPath}" 中提取有效的目标 URL。`);
365
+ }
366
+
367
+ console.info(`开始处理目标 URL 的代理请求: ${targetUrl}`);
368
+
369
+ // --- 获取并处理目标内容 ---
370
+ const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, req.headers);
371
+
372
+ // --- 如果是 M3U8,处理并返回 ---
373
+ if (isM3u8Content(content, contentType)) {
374
+ console.info(`正在处理 M3U8 内容: ${targetUrl}`);
375
+ const processedM3u8 = await processM3u8Content(targetUrl, content);
376
+
377
+ console.info(`成功处理 M3U8: ${targetUrl}`);
378
+ // 发送处理后的 M3U8 响应
379
+ res.status(200)
380
+ .setHeader('Content-Type', 'application/vnd.apple.mpegurl;charset=utf-8')
381
+ .setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`)
382
+ // 移除可能导致问题的原始响应头
383
+ .removeHeader('content-encoding') // 很重要!node-fetch 已解压
384
+ .removeHeader('content-length') // 长度已改变
385
+ .send(processedM3u8); // 发送 M3U8 文本
386
+
387
+ } else {
388
+ // --- 如果不是 M3U8,直接返回原始内容 ---
389
+ console.info(`直接返回非 M3U8 内容: ${targetUrl}, 类型: ${contentType}`);
390
+
391
+ // 设置原始响应头,但排除有问题的头和 CORS 头(已设置)
392
+ responseHeaders.forEach((value, key) => {
393
+ const lowerKey = key.toLowerCase();
394
+ if (!lowerKey.startsWith('access-control-') &&
395
+ lowerKey !== 'content-encoding' && // 很重要!
396
+ lowerKey !== 'content-length') { // 很重要!
397
+ res.setHeader(key, value); // 设置其他原始头
398
+ }
399
+ });
400
+ // 设置我们自己的缓存策略
401
+ res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL}`);
402
+
403
+ // 发送原始(已解压)内容
404
+ res.status(200).send(content);
405
+ }
406
+
407
+ // ---- 结束主处理逻辑的 try 块 ----
408
+ } catch (error) { // ---- 捕获处理过程中的任何错误 ----
409
+ // **检查这个错误是否是 "Assignment to constant variable"**
410
+ console.error(`[代理错误处理 V3] 捕获错误!目标: ${targetUrl || '解析失败'} | 错误类型: ${error.constructor.name} | 错误消息: ${error.message}`);
411
+ console.error(`[代理错误堆栈 V3] ${error.stack}`); // 记录完整的错误堆栈信息
412
+
413
+ // 特别标记 "Assignment to constant variable" 错误
414
+ if (error instanceof TypeError && error.message.includes("Assignment to constant variable")) {
415
+ console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
416
+ console.error("捕获到 'Assignment to constant variable' 错误!");
417
+ console.error("请再次检查函数代码及所有辅助函数中,是否有 const 声明的变量被重新赋值。");
418
+ console.error("错误堆栈指向:", error.stack);
419
+ console.error("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
420
+ }
421
+
422
+ // 尝试从错误对象获取状态码,否则默认为 500
423
+ const statusCode = error.status || 500;
424
+
425
+ // 确保在发送错误响应前没有发送过响应头
426
+ if (!res.headersSent) {
427
+ res.setHeader('Content-Type', 'application/json');
428
+ // CORS 头应该已经在前面设置好了
429
+ res.status(statusCode).json({
430
+ success: false,
431
+ error: `代理处理错误: ${error.message}`, // 返回错误消息给前端
432
+ targetUrl: targetUrl // 包含目标 URL 以便调试
433
+ });
434
+ } else {
435
+ // 如果响应头已发送,无法��发送 JSON 错误
436
+ console.error("[代理错误处理 V3] 响应头已发送,无法发送 JSON 错误响应。");
437
+ // 尝试结束响应
438
+ if (!res.writableEnded) {
439
+ res.end();
440
+ }
441
+ }
442
+ } finally {
443
+ // 记录请求处理结束
444
+ console.info('--- Vercel 代理请求结束 ---');
445
+ }
446
+ }
447
+
448
+ // --- [确保所有辅助函数定义都在这里] ---
449
+ // getTargetUrlFromPath, getBaseUrl, resolveUrl, rewriteUrlToProxy, getRandomUserAgent,
450
+ // fetchContentWithType, isM3u8Content, processKeyLine, processMapLine,
451
+ // processMediaPlaylist, processM3u8Content, processMasterPlaylist
css/styles.css ADDED
@@ -0,0 +1,784 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ /* 赛博影视主题配色方案 - 柔和版 */
3
+ --primary-color: #00ccff; /* 霓虹蓝主色调 */
4
+ --primary-light: #33d6ff; /* 浅霓虹蓝变体 */
5
+ --secondary-color: #0f1622; /* 深蓝黑背景色 */
6
+ --accent-color: #ff3c78; /* 霓虹粉强调色 */
7
+ --text-color: #e6f2ff; /* 柔和的蓝白色文本 */
8
+ --text-muted: #8599b2; /* 淡蓝灰色次级文本 */
9
+ --border-color: rgba(0, 204, 255, 0.15);
10
+ --page-gradient-start: #0f1622; /* 深蓝黑起始色 */
11
+ --page-gradient-end: #192231; /* 深靛蓝结束色 */
12
+ --card-gradient-start: #121b29; /* 卡片起始色 */
13
+ --card-gradient-end: #1c2939; /* 卡片结束色 */
14
+ --card-accent: rgba(0, 204, 255, 0.12); /* 霓虹蓝卡片强调色 */
15
+ --card-hover-border: rgba(0, 204, 255, 0.5); /* 悬停边框颜色 */
16
+ }
17
+
18
+ .page-bg {
19
+ background: linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end));
20
+ min-height: 100vh;
21
+ /* 柔和赛博点状背景 */
22
+ background-image:
23
+ linear-gradient(180deg, var(--page-gradient-start), var(--page-gradient-end)),
24
+ radial-gradient(circle at 25px 25px, rgba(0, 204, 255, 0.04) 2px, transparent 3px),
25
+ radial-gradient(circle at 75px 75px, rgba(255, 60, 120, 0.02) 1px, transparent 2px),
26
+ radial-gradient(circle at 50px 50px, rgba(150, 255, 250, 0.015) 1px, transparent 2px);
27
+ background-blend-mode: normal;
28
+ background-size: cover, 100px 100px, 50px 50px, 75px 75px;
29
+ }
30
+
31
+ button, .card-hover {
32
+ transition: all 0.3s ease;
33
+ }
34
+
35
+ /* 改进卡片适应不同内容长度 */
36
+ .card-hover {
37
+ border: 1px solid var(--border-color);
38
+ background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));
39
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
40
+ position: relative;
41
+ overflow: hidden;
42
+ border-radius: 6px;
43
+ display: flex;
44
+ flex-direction: column;
45
+ height: 100%;
46
+ }
47
+
48
+ /* 确保卡片内容区域高度一致性 */
49
+ .card-hover .flex-grow {
50
+ min-height: 90px;
51
+ display: flex;
52
+ flex-direction: column;
53
+ }
54
+
55
+ /* 针对不同长度的标题优化显示 */
56
+ .card-hover h3 {
57
+ min-height: 3rem;
58
+ display: -webkit-box;
59
+ -webkit-box-orient: vertical;
60
+ overflow: hidden;
61
+ text-overflow: ellipsis;
62
+ -webkit-line-clamp: 2;
63
+ max-height: 3rem; /* 确保最多显示两行 */
64
+ line-height: 1.5rem; /* 设定行高以确保两行文本的一致性 */
65
+ word-break: break-word; /* 允许在任何字符间断行 */
66
+ hyphens: auto; /* 允许断词 */
67
+ }
68
+
69
+ .card-hover::before {
70
+ content: "";
71
+ position: absolute;
72
+ top: 0;
73
+ left: -100%;
74
+ width: 100%;
75
+ height: 100%;
76
+ background: linear-gradient(90deg, transparent, var(--card-accent), transparent);
77
+ transition: left 0.6s ease;
78
+ }
79
+
80
+ .card-hover:hover {
81
+ border-color: var(--card-hover-border);
82
+ transform: translateY(-3px);
83
+ box-shadow: 0 6px 12px rgba(0, 0, 0, 0.5);
84
+ }
85
+
86
+ .card-hover:hover::before {
87
+ left: 100%;
88
+ }
89
+
90
+ .gradient-text {
91
+ background: linear-gradient(to right, var(--primary-color), var(--accent-color));
92
+ -webkit-background-clip: text;
93
+ -webkit-text-fill-color: transparent;
94
+ }
95
+
96
+ /* 改进设置面板样式 */
97
+ .settings-panel {
98
+ scrollbar-width: thin;
99
+ scrollbar-color: #444 #222;
100
+ transform: translateX(100%);
101
+ transition: transform 0.3s ease;
102
+ background: linear-gradient(135deg, var(--page-gradient-end), var(--page-gradient-start));
103
+ border-left: 1px solid var(--primary-color);
104
+ }
105
+
106
+ .settings-panel.show {
107
+ transform: translateX(0);
108
+ }
109
+
110
+ .settings-panel::-webkit-scrollbar {
111
+ width: 6px;
112
+ }
113
+
114
+ .settings-panel::-webkit-scrollbar-track {
115
+ background: transparent;
116
+ }
117
+
118
+ .settings-panel::-webkit-scrollbar-thumb {
119
+ background-color: #444;
120
+ border-radius: 4px;
121
+ }
122
+
123
+ /* 设置面板区块样式 */
124
+ .settings-panel .shadow-inner {
125
+ box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);
126
+ transition: all 0.2s ease-in-out;
127
+ }
128
+
129
+ .settings-panel .shadow-inner:hover {
130
+ box-shadow: inset 0 2px 8px rgba(0,0,0,0.4);
131
+ }
132
+
133
+ .search-button {
134
+ background: var(--primary-color);
135
+ color: var(--text-color);
136
+ }
137
+
138
+ .search-button:hover {
139
+ background: var(--primary-light);
140
+ }
141
+
142
+ ::-webkit-scrollbar {
143
+ width: 8px;
144
+ height: 8px;
145
+ }
146
+
147
+ ::-webkit-scrollbar-track {
148
+ background: #111;
149
+ border-radius: 4px;
150
+ }
151
+
152
+ ::-webkit-scrollbar-thumb {
153
+ background: #333;
154
+ border-radius: 4px;
155
+ transition: all 0.3s ease;
156
+ }
157
+
158
+ ::-webkit-scrollbar-thumb:hover {
159
+ background: #444;
160
+ }
161
+
162
+ * {
163
+ scrollbar-width: thin;
164
+ scrollbar-color: #333 #111;
165
+ }
166
+
167
+ .search-tag {
168
+ background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));
169
+ color: var(--text-color);
170
+ padding: 0.5rem 1rem;
171
+ border-radius: 0.5rem;
172
+ font-size: 0.875rem;
173
+ border: 1px solid var(--border-color);
174
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
175
+ }
176
+
177
+ .search-tag:hover {
178
+ background: linear-gradient(135deg, var(--card-gradient-end), var(--card-gradient-start));
179
+ border-color: var(--primary-color);
180
+ }
181
+
182
+ .footer {
183
+ width: 100%;
184
+ transition: all 0.3s ease;
185
+ margin-top: auto;
186
+ background: linear-gradient(to bottom, transparent, var(--page-gradient-start));
187
+ border-top: 1px solid var(--border-color);
188
+ }
189
+
190
+ .footer a:hover {
191
+ text-decoration: underline;
192
+ }
193
+
194
+ body {
195
+ display: flex;
196
+ flex-direction: column;
197
+ min-height: 100vh;
198
+ }
199
+
200
+ .container {
201
+ flex: 1;
202
+ }
203
+
204
+ @media screen and (min-height: 800px) {
205
+ body {
206
+ display: flex;
207
+ flex-direction: column;
208
+ min-height: 100vh;
209
+ }
210
+
211
+ .container {
212
+ flex: 1;
213
+ }
214
+
215
+ .footer {
216
+ margin-top: auto;
217
+ }
218
+ }
219
+
220
+ @media screen and (max-width: 640px) {
221
+ .footer {
222
+ padding-bottom: 2rem;
223
+ }
224
+ }
225
+
226
+ /* 移动端布局优化 */
227
+ @media screen and (max-width: 768px) {
228
+ .card-hover h3 {
229
+ min-height: 2.5rem;
230
+ }
231
+
232
+ .card-hover .flex-grow {
233
+ min-height: 80px;
234
+ }
235
+ }
236
+
237
+ @keyframes fadeIn {
238
+ from { opacity: 0; }
239
+ to { opacity: 1; }
240
+ }
241
+
242
+ @keyframes fadeOut {
243
+ from { opacity: 1; }
244
+ to { opacity: 0; }
245
+ }
246
+
247
+ #modal.show {
248
+ animation: fadeIn 0.3s forwards;
249
+ }
250
+
251
+ #modal.hide {
252
+ animation: fadeOut 0.3s forwards;
253
+ }
254
+
255
+ #modal > div {
256
+ background: linear-gradient(135deg, var(--card-gradient-start), var(--card-gradient-end));
257
+ border: 1px solid var(--primary-color);
258
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.7), 0 0 15px rgba(0, 204, 255, 0.1);
259
+ border-radius: 8px;
260
+ }
261
+
262
+ #modalContent button {
263
+ background: rgba(0, 204, 255, 0.08);
264
+ border: 1px solid rgba(0, 204, 255, 0.2);
265
+ transition: all 0.2s ease;
266
+ }
267
+
268
+ #modalContent button:hover {
269
+ background: rgba(0, 204, 255, 0.15);
270
+ border-color: var(--primary-color);
271
+ box-shadow: 0 0 8px rgba(0, 204, 255, 0.3);
272
+ }
273
+
274
+ #yellowFilterToggle:checked + .toggle-bg {
275
+ background-color: var(--primary-color);
276
+ }
277
+
278
+ #yellowFilterToggle:checked ~ .toggle-dot {
279
+ transform: translateX(1.5rem);
280
+ }
281
+
282
+ #yellowFilterToggle:focus + .toggle-bg,
283
+ #yellowFilterToggle:hover + .toggle-bg {
284
+ box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3);
285
+ }
286
+
287
+ /* 添加广告过滤开关的CSS */
288
+ #adFilterToggle:checked + .toggle-bg {
289
+ background-color: var(--primary-color);
290
+ }
291
+
292
+ #adFilterToggle:checked ~ .toggle-dot {
293
+ transform: translateX(1.5rem);
294
+ }
295
+
296
+ #adFilterToggle:focus + .toggle-bg,
297
+ #adFilterToggle:hover + .toggle-bg {
298
+ box-shadow: 0 0 0 2px rgba(0, 204, 255, 0.3);
299
+ }
300
+
301
+ .toggle-dot {
302
+ transition: transform 0.3s ease-in-out;
303
+ box-shadow: 0 2px 4px rgba(0,0,0,0.2);
304
+ }
305
+
306
+ .toggle-bg {
307
+ transition: background-color 0.3s ease-in-out;
308
+ }
309
+
310
+ #yellowFilterToggle:checked ~ .toggle-dot {
311
+ box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3);
312
+ }
313
+
314
+ #adFilterToggle:checked ~ .toggle-dot {
315
+ box-shadow: 0 2px 4px rgba(0, 204, 255, 0.3);
316
+ }
317
+
318
+ /* 添加API复选框样式 */
319
+ .form-checkbox {
320
+ appearance: none;
321
+ -webkit-appearance: none;
322
+ -moz-appearance: none;
323
+ height: 14px;
324
+ width: 14px;
325
+ background-color: #222;
326
+ border: 1px solid #333;
327
+ border-radius: 3px;
328
+ cursor: pointer;
329
+ position: relative;
330
+ outline: none;
331
+ }
332
+
333
+ .form-checkbox:checked {
334
+ background-color: var(--primary-color);
335
+ border-color: var(--primary-color);
336
+ }
337
+
338
+ .form-checkbox:checked::after {
339
+ content: '';
340
+ position: absolute;
341
+ left: 4px;
342
+ top: 1px;
343
+ width: 4px;
344
+ height: 8px;
345
+ border: solid white;
346
+ border-width: 0 2px 2px 0;
347
+ transform: rotate(45deg);
348
+ }
349
+
350
+ /* API滚动区域美化 */
351
+ #apiCheckboxes {
352
+ scrollbar-width: thin;
353
+ scrollbar-color: #444 #222;
354
+ }
355
+
356
+ #apiCheckboxes::-webkit-scrollbar {
357
+ width: 6px;
358
+ }
359
+
360
+ #apiCheckboxes::-webkit-scrollbar-track {
361
+ background: #222;
362
+ border-radius: 4px;
363
+ }
364
+
365
+ #apiCheckboxes::-webkit-scrollbar-thumb {
366
+ background-color: #444;
367
+ border-radius: 4px;
368
+ }
369
+
370
+ /* 自定义API列表样式 */
371
+ #customApisList {
372
+ scrollbar-width: thin;
373
+ scrollbar-color: #444 #222;
374
+ }
375
+
376
+ #customApisList::-webkit-scrollbar {
377
+ width: 6px;
378
+ }
379
+
380
+ #customApisList::-webkit-scrollbar-track {
381
+ background: transparent;
382
+ }
383
+
384
+ #customApisList::-webkit-scrollbar-thumb {
385
+ background-color: #444;
386
+ border-radius: 4px;
387
+ }
388
+
389
+ /* 设置面板滚动样式 */
390
+ .settings-panel {
391
+ scrollbar-width: thin;
392
+ scrollbar-color: #444 #222;
393
+ }
394
+
395
+ .settings-panel::-webkit-scrollbar {
396
+ width: 6px;
397
+ }
398
+
399
+ .settings-panel::-webkit-scrollbar-track {
400
+ background: transparent;
401
+ }
402
+
403
+ .settings-panel::-webkit-scrollbar-thumb {
404
+ background-color: #444;
405
+ border-radius: 4px;
406
+ }
407
+
408
+ /* 添加自定义API表单动画 */
409
+ #addCustomApiForm {
410
+ transition: all 0.3s ease;
411
+ max-height: 0;
412
+ opacity: 0;
413
+ overflow: hidden;
414
+ }
415
+
416
+ #addCustomApiForm.hidden {
417
+ max-height: 0;
418
+ padding: 0;
419
+ opacity: 0;
420
+ }
421
+
422
+ #addCustomApiForm:not(.hidden) {
423
+ max-height: 230px;
424
+ opacity: 1;
425
+ }
426
+
427
+ /* 成人内容API标记样式 */
428
+ .api-adult + label {
429
+ color: #ff6b8b !important;
430
+ }
431
+
432
+ /* 添加警告图标和标签样式 */
433
+ .adult-warning {
434
+ display: inline-flex;
435
+ align-items: center;
436
+ margin-left: 0.25rem;
437
+ color: #ff6b8b;
438
+ }
439
+
440
+ .adult-warning svg {
441
+ width: 12px;
442
+ height: 12px;
443
+ margin-right: 4px;
444
+ }
445
+
446
+ /* 过滤器禁用样式 */
447
+ .filter-disabled {
448
+ opacity: 0.5;
449
+ pointer-events: none;
450
+ cursor: not-allowed;
451
+ }
452
+
453
+ /* API组标题样式 */
454
+ .api-group-title {
455
+ grid-column: span 2;
456
+ padding: 0.25rem 0;
457
+ margin-top: 0.5rem;
458
+ border-top: 1px solid #333;
459
+ color: #8599b2;
460
+ font-size: 0.75rem;
461
+ text-transform: uppercase;
462
+ letter-spacing: 0.05em;
463
+ }
464
+
465
+ .api-group-title.adult {
466
+ color: #ff6b8b;
467
+ }
468
+
469
+ /* 过滤器禁用样式 - 改进版本 */
470
+ .filter-disabled {
471
+ position: relative;
472
+ }
473
+
474
+ .filter-disabled::after {
475
+ content: '';
476
+ position: absolute;
477
+ top: 0;
478
+ left: 0;
479
+ width: 100%;
480
+ height: 100%;
481
+ background-color: rgba(0,0,0,0.4);
482
+ border-radius: 0.5rem;
483
+ z-index: 5;
484
+ }
485
+
486
+ .filter-disabled > * {
487
+ opacity: 0.7;
488
+ }
489
+
490
+ .filter-disabled .toggle-bg {
491
+ background-color: #333 !important;
492
+ }
493
+
494
+ .filter-disabled .toggle-dot {
495
+ transform: translateX(0) !important;
496
+ background-color: #666 !important;
497
+ }
498
+
499
+ /* 改进过滤器禁用样式 */
500
+ .filter-disabled .filter-description {
501
+ color: #ff6b8b !important;
502
+ font-style: italic;
503
+ font-weight: 500;
504
+ }
505
+
506
+ /* 修改过滤器禁用样式,确保文字清晰可见 */
507
+ .filter-disabled {
508
+ position: relative;
509
+ }
510
+
511
+ .filter-disabled::after {
512
+ content: '';
513
+ position: absolute;
514
+ top: 0;
515
+ left: 0;
516
+ width: 100%;
517
+ height: 100%;
518
+ background-color: rgba(0,0,0,0.3);
519
+ border-radius: 0.5rem;
520
+ z-index: 5;
521
+ }
522
+
523
+ .filter-disabled > * {
524
+ opacity: 1; /* 提高子元素不透明度,保证可见性 */
525
+ z-index: 6; /* 确保内容在遮罩上方 */
526
+ position: relative;
527
+ }
528
+
529
+ /* 改进过滤器禁用状态下的描述样式 */
530
+ .filter-disabled .filter-description {
531
+ color: #ff7b9d !important; /* 更亮的粉色 */
532
+ font-style: italic;
533
+ font-weight: 500;
534
+ text-shadow: 0 0 2px rgba(0,0,0,0.8); /* 添加文字阴影提高对比度 */
535
+ }
536
+
537
+ /* 开关的禁用样式 */
538
+ .filter-disabled .toggle-bg {
539
+ background-color: #444 !important;
540
+ opacity: 0.8;
541
+ }
542
+
543
+ .filter-disabled .toggle-dot {
544
+ transform: translateX(0) ;
545
+ background-color: #777 ;
546
+ opacity: 0.9;
547
+ }
548
+
549
+ /* 警告提示样式改进 */
550
+ .filter-tooltip {
551
+ background-color: rgba(255, 61, 87, 0.1);
552
+ border: 1px solid rgba(255, 61, 87, 0.2);
553
+ border-radius: 0.25rem;
554
+ padding: 0.5rem;
555
+ margin-top: 0.5rem;
556
+ display: flex;
557
+ align-items: center;
558
+ font-size: 0.75rem;
559
+ line-height: 1.25;
560
+ position: relative;
561
+ z-index: 10;
562
+ }
563
+
564
+ .filter-tooltip svg {
565
+ flex-shrink: 0;
566
+ width: 14px;
567
+ height: 14px;
568
+ margin-right: 0.35rem;
569
+ }
570
+
571
+ /* 编辑按钮样式 */
572
+ .custom-api-edit {
573
+ color: #3b82f6;
574
+ transition: color 0.2s ease;
575
+ }
576
+
577
+ .custom-api-edit:hover {
578
+ color: #2563eb;
579
+ }
580
+
581
+ /* 自定义API条目样式改进 */
582
+ #customApisList .api-item {
583
+ display: flex;
584
+ align-items: center;
585
+ justify-content: space-between;
586
+ padding: 0.25rem 0.5rem;
587
+ margin-bottom: 0.25rem;
588
+ background-color: #222;
589
+ border-radius: 0.25rem;
590
+ transition: background-color 0.2s ease;
591
+ }
592
+
593
+ #customApisList .api-item:hover {
594
+ background-color: #2a2a2a;
595
+ }
596
+
597
+ /* 成人内容标签样式 */
598
+ .adult-tag {
599
+ display: inline-flex;
600
+ align-items: center;
601
+ color: #ff6b8b;
602
+ font-size: 0.7rem;
603
+ font-weight: 500;
604
+ margin-right: 0.35rem;
605
+ }
606
+
607
+ /* 历史记录面板样式 */
608
+ .history-panel {
609
+ box-shadow: 2px 0 10px rgba(0,0,0,0.5);
610
+ transition: transform 0.3s ease-in-out;
611
+ overflow-y: scroll; /* 始终显示滚动条,防止宽度变化 */
612
+ overflow-x: hidden; /* 防止水平滚动 */
613
+ width: 320px; /* 固定宽度 */
614
+ box-sizing: border-box; /* 确保padding不影响总宽度 */
615
+ scrollbar-gutter: stable; /* 现代浏览器:为滚动条预留空间 */
616
+ }
617
+
618
+ .history-panel.show {
619
+ transform: translateX(0);
620
+ }
621
+
622
+ #historyList {
623
+ padding-right: 6px; /* 为滚动条预留空间,确保内容不被挤压 */
624
+ }
625
+
626
+ /* 历史记录项样式优化 */
627
+ .history-item {
628
+ background: #1a1a1a;
629
+ border-radius: 6px; /* 减小圆角 */
630
+ border: 1px solid #333;
631
+ overflow: hidden;
632
+ transition: all 0.2s ease;
633
+ padding: 10px 14px;
634
+ position: relative;
635
+ margin-bottom: 8px; /* 减小底部间距 */
636
+ width: 100%; /* 确保宽度一致 */
637
+ }
638
+
639
+ .history-item:hover {
640
+ transform: translateY(-2px);
641
+ border-color: #444;
642
+ box-shadow: 0 4px 8px rgba(0,0,0,0.2);
643
+ }
644
+
645
+ /* 添加组悬停效果,使删除按钮在悬停时显示 */
646
+ .history-item .delete-btn {
647
+ opacity: 0;
648
+ transition: opacity 0.2s ease;
649
+ }
650
+
651
+ .history-item:hover .delete-btn {
652
+ opacity: 1;
653
+ }
654
+
655
+ .history-info {
656
+ padding: 0; /* 移除额外的内边距 */
657
+ min-height: 70px;
658
+ }
659
+
660
+ .history-title {
661
+ font-weight: 500;
662
+ font-size: 0.95rem; /* 减小字体大小 */
663
+ margin-bottom: 2px; /* 减小底部边距 */
664
+ overflow: hidden;
665
+ text-overflow: ellipsis;
666
+ white-space: nowrap;
667
+ }
668
+
669
+ .history-meta {
670
+ color: #bbb;
671
+ font-size: 0.75rem; /* 减小字体大小 */
672
+ display: flex;
673
+ flex-wrap: wrap;
674
+ margin-bottom: 1px; /* 减小边距 */
675
+ }
676
+
677
+ .history-episode {
678
+ color: #3b82f6;
679
+ }
680
+
681
+ .history-source {
682
+ color: #10b981;
683
+ }
684
+
685
+ .history-time {
686
+ color: #888;
687
+ font-size: 0.7rem; /* 减小字体大小 */
688
+ margin-top: 1px; /* 减小顶部边距 */
689
+ }
690
+
691
+ .history-separator {
692
+ color: #666;
693
+ }
694
+
695
+ .history-thumbnail {
696
+ width: 100%;
697
+ height: 90px;
698
+ background-color: #222;
699
+ overflow: hidden;
700
+ }
701
+
702
+ .history-thumbnail img {
703
+ width: 100%;
704
+ height: 100%;
705
+ object-fit: cover;
706
+ }
707
+
708
+ .history-info {
709
+ padding: 10px;
710
+ }
711
+
712
+ .history-time {
713
+ color: #888;
714
+ font-size: 0.8rem;
715
+ margin-top: 4px;
716
+ }
717
+
718
+ .history-title {
719
+ font-weight: 500;
720
+ white-space: nowrap;
721
+ overflow: hidden;
722
+ text-overflow: ellipsis;
723
+ }
724
+
725
+ /* 添加播放进度条样式 */
726
+ .history-progress {
727
+ margin: 5px 0;
728
+ }
729
+
730
+ .progress-bar {
731
+ height: 3px;
732
+ background-color: rgba(255, 255, 255, 0.1);
733
+ border-radius: 2px;
734
+ overflow: hidden;
735
+ margin-bottom: 2px;
736
+ }
737
+
738
+ .progress-filled {
739
+ height: 100%;
740
+ background: linear-gradient(to right, #00ccff, #3b82f6);
741
+ border-radius: 2px;
742
+ }
743
+
744
+ .progress-text {
745
+ font-size: 10px;
746
+ color: #888;
747
+ text-align: right;
748
+ }
749
+
750
+ /* 添加恢复播放提示样式 */
751
+ .position-restore-hint {
752
+ position: absolute;
753
+ bottom: 60px;
754
+ left: 50%;
755
+ transform: translateX(-50%) translateY(20px);
756
+ background-color: rgba(0, 0, 0, 0.7);
757
+ color: white;
758
+ padding: 8px 16px;
759
+ border-radius: 4px;
760
+ font-size: 14px;
761
+ z-index: 100;
762
+ opacity: 0;
763
+ transition: all 0.3s ease;
764
+ }
765
+
766
+ .position-restore-hint.show {
767
+ opacity: 1;
768
+ transform: translateX(-50%) translateY(0);
769
+ }
770
+
771
+ /* 锁定控制时屏蔽交互 */
772
+ .player-container.controls-locked .dplayer-controller,
773
+ .player-container.controls-locked .dplayer-mask,
774
+ .player-container.controls-locked .dplayer-bar-wrap,
775
+ .player-container.controls-locked .dplayer-statusbar,
776
+ .player-container.controls-locked .shortcut-hint {
777
+ opacity: 0 !important;
778
+ pointer-events: none !important;
779
+ }
780
+ /* 保留锁按钮可见可点 */
781
+ .player-container.controls-locked #lockToggle {
782
+ opacity: 1 !important;
783
+ pointer-events: auto !important;
784
+ }
docker-compose.yml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3'
2
+ services:
3
+ libretv:
4
+ image: bestzwei/libretv:latest
5
+ container_name: libretv
6
+ ports:
7
+ - "8899:80"
8
+ environment:
9
+ - PASSWORD=111111
10
+ restart: unless-stopped
docker-entrypoint.sh ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ # Function to hash a string with SHA-256
5
+ hash_password() {
6
+ if [ -z "$1" ]; then
7
+ echo ""
8
+ else
9
+ echo -n "$1" | sha256sum | cut -d ' ' -f 1
10
+ fi
11
+ }
12
+
13
+ # Function to replace environment variables in HTML files
14
+ replace_env_vars() {
15
+ # Hash the password if it exists
16
+ local password_hash=""
17
+ if [ -n "$PASSWORD" ]; then
18
+ password_hash=$(hash_password "$PASSWORD")
19
+ fi
20
+
21
+ # Replace the password placeholder in all HTML files with the hashed password
22
+ find /usr/share/nginx/html -type f -name "*.html" -exec sed -i "s/window.__ENV__.PASSWORD = \"{{PASSWORD}}\";/window.__ENV__.PASSWORD = \"${password_hash}\";/g" {} \;
23
+
24
+ echo "Environment variables have been injected into HTML files."
25
+ }
26
+
27
+ # Replace environment variables in HTML files
28
+ replace_env_vars
29
+
30
+ # Execute the command provided as arguments
31
+ exec "$@"
functions/_middleware.js ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sha256 } from '../js/sha256.js'; // 需新建或引入SHA-256实现
2
+
3
+ // Cloudflare Pages Middleware to inject environment variables
4
+ export async function onRequest(context) {
5
+ const { request, env, next } = context;
6
+
7
+ // Proceed to the next middleware or route handler
8
+ const response = await next();
9
+
10
+ // Check if the response is HTML
11
+ const contentType = response.headers.get("content-type") || "";
12
+
13
+ if (contentType.includes("text/html")) {
14
+ // Get the original HTML content
15
+ let html = await response.text();
16
+
17
+ // Replace the placeholder with actual environment variable value
18
+ // If PASSWORD is not set, replace with empty string
19
+ const password = env.PASSWORD || "";
20
+ let passwordHash = "";
21
+ if (password) {
22
+ passwordHash = await sha256(password);
23
+ }
24
+ html = html.replace('window.__ENV__.PASSWORD = "{{PASSWORD}}";',
25
+ `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`);
26
+
27
+ // Create a new response with the modified HTML
28
+ return new Response(html, {
29
+ headers: response.headers,
30
+ status: response.status,
31
+ statusText: response.statusText,
32
+ });
33
+ }
34
+
35
+ // Return the original response for non-HTML content
36
+ return response;
37
+ }
functions/proxy/[[path]].js ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // functions/proxy/[[path]].js
2
+
3
+ // --- 配置 (现在从 Cloudflare 环境变量读取) ---
4
+ // 在 Cloudflare Pages 设置 -> 函数 -> 环境变量绑定 中设置以下变量:
5
+ // CACHE_TTL (例如 86400)
6
+ // MAX_RECURSION (例如 5)
7
+ // FILTER_DISCONTINUITY (不再需要,设为 false 或移除)
8
+ // USER_AGENTS_JSON (例如 ["UA1", "UA2"]) - JSON 字符串数组
9
+ // DEBUG (例如 false 或 true)
10
+ // --- 配置结束 ---
11
+
12
+ // --- 常量 (之前在 config.js 中,现在移到这里,因为它们与代理逻辑相关) ---
13
+ const MEDIA_FILE_EXTENSIONS = [
14
+ '.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.f4v', '.m4v', '.3gp', '.3g2', '.ts', '.mts', '.m2ts',
15
+ '.mp3', '.wav', '.ogg', '.aac', '.m4a', '.flac', '.wma', '.alac', '.aiff', '.opus',
16
+ '.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.tiff', '.svg', '.avif', '.heic'
17
+ ];
18
+ const MEDIA_CONTENT_TYPES = ['video/', 'audio/', 'image/'];
19
+ // --- 常量结束 ---
20
+
21
+
22
+ /**
23
+ * 主要的 Pages Function 处理函数
24
+ * 拦截发往 /proxy/* 的请求
25
+ */
26
+ export async function onRequest(context) {
27
+ const { request, env, next, waitUntil } = context; // next 和 waitUntil 可能需要
28
+ const url = new URL(request.url);
29
+
30
+ // --- 从环境变量读取配置 ---
31
+ const DEBUG_ENABLED = (env.DEBUG === 'true');
32
+ const CACHE_TTL = parseInt(env.CACHE_TTL || '86400'); // 默认 24 小时
33
+ const MAX_RECURSION = parseInt(env.MAX_RECURSION || '5'); // 默认 5 层
34
+ // 广告过滤已移至播放器处理,代理不再执行
35
+ let USER_AGENTS = [ // 提供一个基础的默认值
36
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
37
+ ];
38
+ try {
39
+ // 尝试从环境变量解析 USER_AGENTS_JSON
40
+ const agentsJson = env.USER_AGENTS_JSON;
41
+ if (agentsJson) {
42
+ const parsedAgents = JSON.parse(agentsJson);
43
+ if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
44
+ USER_AGENTS = parsedAgents;
45
+ } else {
46
+ logDebug("环境变量 USER_AGENTS_JSON 格式无效或为空,使用默认值");
47
+ }
48
+ }
49
+ } catch (e) {
50
+ logDebug(`解析环境变量 USER_AGENTS_JSON 失败: ${e.message},使用默认值`);
51
+ }
52
+ // --- 配置读取结束 ---
53
+
54
+
55
+ // --- 辅助函数 ---
56
+
57
+ // 输出调试日志 (需要设置 DEBUG: true 环境变量)
58
+ function logDebug(message) {
59
+ if (DEBUG_ENABLED) {
60
+ console.log(`[Proxy Func] ${message}`);
61
+ }
62
+ }
63
+
64
+ // 从请求路径中提取目标 URL
65
+ function getTargetUrlFromPath(pathname) {
66
+ // 路径格式: /proxy/经过编码的URL
67
+ // 例如: /proxy/https%3A%2F%2Fexample.com%2Fplaylist.m3u8
68
+ const encodedUrl = pathname.replace(/^\/proxy\//, '');
69
+ if (!encodedUrl) return null;
70
+ try {
71
+ // 解码
72
+ let decodedUrl = decodeURIComponent(encodedUrl);
73
+
74
+ // 简单检查解码后是否是有效的 http/https URL
75
+ if (!decodedUrl.match(/^https?:\/\//i)) {
76
+ // 也许原始路径就没有编码?如果看起来像URL就直接用
77
+ if (encodedUrl.match(/^https?:\/\//i)) {
78
+ decodedUrl = encodedUrl;
79
+ logDebug(`Warning: Path was not encoded but looks like URL: ${decodedUrl}`);
80
+ } else {
81
+ logDebug(`无效的目标URL格式 (解码后): ${decodedUrl}`);
82
+ return null;
83
+ }
84
+ }
85
+ return decodedUrl;
86
+
87
+ } catch (e) {
88
+ logDebug(`解码目标URL时出错: ${encodedUrl} - ${e.message}`);
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // 创建标准化的响应
94
+ function createResponse(body, status = 200, headers = {}) {
95
+ const responseHeaders = new Headers(headers);
96
+ // 关键:添加 CORS 跨域头,允许前端 JS 访问代理后的响应
97
+ responseHeaders.set("Access-Control-Allow-Origin", "*"); // 允许任何来源访问
98
+ responseHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS"); // 允许的方法
99
+ responseHeaders.set("Access-Control-Allow-Headers", "*"); // 允许所有请求头
100
+
101
+ // 处理 CORS 预检请求 (OPTIONS) - 放在这里确保所有响应都处理
102
+ if (request.method === "OPTIONS") {
103
+ // 使用下面的 onOptions 函数可以更规范,但在这里处理也可以
104
+ return new Response(null, {
105
+ status: 204, // No Content
106
+ headers: responseHeaders // 包含上面设置的 CORS 头
107
+ });
108
+ }
109
+
110
+ return new Response(body, { status, headers: responseHeaders });
111
+ }
112
+
113
+ // 创建 M3U8 类型的响应
114
+ function createM3u8Response(content) {
115
+ return createResponse(content, 200, {
116
+ "Content-Type": "application/vnd.apple.mpegurl", // M3U8 的标准 MIME 类型
117
+ "Cache-Control": `public, max-age=${CACHE_TTL}` // 允许浏览器和CDN缓存
118
+ });
119
+ }
120
+
121
+ // 获取随机 User-Agent
122
+ function getRandomUserAgent() {
123
+ return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
124
+ }
125
+
126
+ // 获取 URL 的基础路径 (用于解析相对路径)
127
+ function getBaseUrl(urlStr) {
128
+ try {
129
+ const parsedUrl = new URL(urlStr);
130
+ // 如果路径是根目录,或者没有斜杠,直接返回 origin + /
131
+ if (!parsedUrl.pathname || parsedUrl.pathname === '/') {
132
+ return `${parsedUrl.origin}/`;
133
+ }
134
+ const pathParts = parsedUrl.pathname.split('/');
135
+ pathParts.pop(); // 移除文件名或最后一个路径段
136
+ return `${parsedUrl.origin}${pathParts.join('/')}/`;
137
+ } catch (e) {
138
+ logDebug(`获取 BaseUrl 时出错: ${urlStr} - ${e.message}`);
139
+ // 备用方法:找到最后一个斜杠
140
+ const lastSlashIndex = urlStr.lastIndexOf('/');
141
+ // 确保不是协议部分的斜杠 (http://)
142
+ return lastSlashIndex > urlStr.indexOf('://') + 2 ? urlStr.substring(0, lastSlashIndex + 1) : urlStr + '/';
143
+ }
144
+ }
145
+
146
+
147
+ // 将相对 URL 转换为绝对 URL
148
+ function resolveUrl(baseUrl, relativeUrl) {
149
+ // 如果已经是绝对 URL,直接返回
150
+ if (relativeUrl.match(/^https?:\/\//i)) {
151
+ return relativeUrl;
152
+ }
153
+ try {
154
+ // 使用 URL 对象来处理相对路径
155
+ return new URL(relativeUrl, baseUrl).toString();
156
+ } catch (e) {
157
+ logDebug(`解析 URL 失败: baseUrl=${baseUrl}, relativeUrl=${relativeUrl}, error=${e.message}`);
158
+ // 简单的备用方法
159
+ if (relativeUrl.startsWith('/')) {
160
+ // 处理根路径相对 URL
161
+ const urlObj = new URL(baseUrl);
162
+ return `${urlObj.origin}${relativeUrl}`;
163
+ }
164
+ // 处理同级目录相对 URL
165
+ return `${baseUrl.replace(/\/[^/]*$/, '/')}${relativeUrl}`; // 确保baseUrl以 / 结尾
166
+ }
167
+ }
168
+
169
+ // 将目标 URL 重写为内部代理路径 (/proxy/...)
170
+ function rewriteUrlToProxy(targetUrl) {
171
+ // 确保目标URL被正确编码,以便作为路径的一部分
172
+ return `/proxy/${encodeURIComponent(targetUrl)}`;
173
+ }
174
+
175
+ // 获取远程内容及其类型
176
+ async function fetchContentWithType(targetUrl) {
177
+ const headers = new Headers({
178
+ 'User-Agent': getRandomUserAgent(),
179
+ 'Accept': '*/*',
180
+ // 尝试传递一些原始请求的头信息
181
+ 'Accept-Language': request.headers.get('Accept-Language') || 'zh-CN,zh;q=0.9,en;q=0.8',
182
+ // 尝试设置 Referer 为目标网站的域名,或者传递原始 Referer
183
+ 'Referer': request.headers.get('Referer') || new URL(targetUrl).origin
184
+ });
185
+
186
+ try {
187
+ // 直接请求目标 URL
188
+ logDebug(`开始直接请求: ${targetUrl}`);
189
+ // Cloudflare Functions 的 fetch 默认支持重定向
190
+ const response = await fetch(targetUrl, { headers, redirect: 'follow' });
191
+
192
+ if (!response.ok) {
193
+ const errorBody = await response.text().catch(() => '');
194
+ logDebug(`请求失败: ${response.status} ${response.statusText} - ${targetUrl}`);
195
+ throw new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 150)}`);
196
+ }
197
+
198
+ // 读取响应内容为文本
199
+ const content = await response.text();
200
+ const contentType = response.headers.get('Content-Type') || '';
201
+ logDebug(`请求成功: ${targetUrl}, Content-Type: ${contentType}, 内容长度: ${content.length}`);
202
+ return { content, contentType, responseHeaders: response.headers }; // 同时返回原始响应头
203
+
204
+ } catch (error) {
205
+ logDebug(`请求彻底失败: ${targetUrl}: ${error.message}`);
206
+ // 抛出更详细的错误
207
+ throw new Error(`请求目标URL失败 ${targetUrl}: ${error.message}`);
208
+ }
209
+ }
210
+
211
+ // 判断是否是 M3U8 内容
212
+ function isM3u8Content(content, contentType) {
213
+ // 检查 Content-Type
214
+ if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) {
215
+ return true;
216
+ }
217
+ // 检查内容本身是否以 #EXTM3U 开头
218
+ return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
219
+ }
220
+
221
+ // 判断是否是媒体文件 (根据扩展名和 Content-Type) - 这部分在此代理中似乎未使用,但保留
222
+ function isMediaFile(url, contentType) {
223
+ if (contentType) {
224
+ for (const mediaType of MEDIA_CONTENT_TYPES) {
225
+ if (contentType.toLowerCase().startsWith(mediaType)) {
226
+ return true;
227
+ }
228
+ }
229
+ }
230
+ const urlLower = url.toLowerCase();
231
+ for (const ext of MEDIA_FILE_EXTENSIONS) {
232
+ if (urlLower.endsWith(ext) || urlLower.includes(`${ext}?`)) {
233
+ return true;
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+
239
+ // 处理 M3U8 中的 #EXT-X-KEY 行 (加密密钥)
240
+ function processKeyLine(line, baseUrl) {
241
+ return line.replace(/URI="([^"]+)"/, (match, uri) => {
242
+ const absoluteUri = resolveUrl(baseUrl, uri);
243
+ logDebug(`处理 KEY URI: 原始='${uri}', 绝对='${absoluteUri}'`);
244
+ return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径
245
+ });
246
+ }
247
+
248
+ // 处理 M3U8 中的 #EXT-X-MAP 行 (初始化片段)
249
+ function processMapLine(line, baseUrl) {
250
+ return line.replace(/URI="([^"]+)"/, (match, uri) => {
251
+ const absoluteUri = resolveUrl(baseUrl, uri);
252
+ logDebug(`处理 MAP URI: 原始='${uri}', 绝对='${absoluteUri}'`);
253
+ return `URI="${rewriteUrlToProxy(absoluteUri)}"`; // 重写为代理路径
254
+ });
255
+ }
256
+
257
+ // 处理媒体 M3U8 播放列表 (包含视频/音频片段)
258
+ function processMediaPlaylist(url, content) {
259
+ const baseUrl = getBaseUrl(url);
260
+ const lines = content.split('\n');
261
+ const output = [];
262
+
263
+ for (let i = 0; i < lines.length; i++) {
264
+ const line = lines[i].trim();
265
+ // 保留最后的空行
266
+ if (!line && i === lines.length - 1) {
267
+ output.push(line);
268
+ continue;
269
+ }
270
+ if (!line) continue; // 跳过中间的空行
271
+
272
+ if (line.startsWith('#EXT-X-KEY')) {
273
+ output.push(processKeyLine(line, baseUrl));
274
+ continue;
275
+ }
276
+ if (line.startsWith('#EXT-X-MAP')) {
277
+ output.push(processMapLine(line, baseUrl));
278
+ continue;
279
+ }
280
+ if (line.startsWith('#EXTINF')) {
281
+ output.push(line);
282
+ continue;
283
+ }
284
+ if (!line.startsWith('#')) {
285
+ const absoluteUrl = resolveUrl(baseUrl, line);
286
+ logDebug(`重写媒体片段: 原始='${line}', 绝对='${absoluteUrl}'`);
287
+ output.push(rewriteUrlToProxy(absoluteUrl));
288
+ continue;
289
+ }
290
+ // 其他 M3U8 标签直接添加
291
+ output.push(line);
292
+ }
293
+ return output.join('\n');
294
+ }
295
+
296
+ // 递归处理 M3U8 内容
297
+ async function processM3u8Content(targetUrl, content, recursionDepth = 0, env) {
298
+ if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) {
299
+ logDebug(`检测到主播放列表: ${targetUrl}`);
300
+ return await processMasterPlaylist(targetUrl, content, recursionDepth, env);
301
+ }
302
+ logDebug(`检测到媒体播放列表: ${targetUrl}`);
303
+ return processMediaPlaylist(targetUrl, content);
304
+ }
305
+
306
+ // 处理主 M3U8 播放列表
307
+ async function processMasterPlaylist(url, content, recursionDepth, env) {
308
+ if (recursionDepth > MAX_RECURSION) {
309
+ throw new Error(`处理主列表时递归层数过多 (${MAX_RECURSION}): ${url}`);
310
+ }
311
+
312
+ const baseUrl = getBaseUrl(url);
313
+ const lines = content.split('\n');
314
+ let highestBandwidth = -1;
315
+ let bestVariantUrl = '';
316
+
317
+ for (let i = 0; i < lines.length; i++) {
318
+ if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
319
+ const bandwidthMatch = lines[i].match(/BANDWIDTH=(\d+)/);
320
+ const currentBandwidth = bandwidthMatch ? parseInt(bandwidthMatch[1], 10) : 0;
321
+
322
+ let variantUriLine = '';
323
+ for (let j = i + 1; j < lines.length; j++) {
324
+ const line = lines[j].trim();
325
+ if (line && !line.startsWith('#')) {
326
+ variantUriLine = line;
327
+ i = j;
328
+ break;
329
+ }
330
+ }
331
+
332
+ if (variantUriLine && currentBandwidth >= highestBandwidth) {
333
+ highestBandwidth = currentBandwidth;
334
+ bestVariantUrl = resolveUrl(baseUrl, variantUriLine);
335
+ }
336
+ }
337
+ }
338
+
339
+ if (!bestVariantUrl) {
340
+ logDebug(`主列表中未找到 BANDWIDTH 或 STREAM-INF,尝试查找第一个子列表引用: ${url}`);
341
+ for (let i = 0; i < lines.length; i++) {
342
+ const line = lines[i].trim();
343
+ if (line && !line.startsWith('#') && (line.endsWith('.m3u8') || line.includes('.m3u8?'))) { // 修复:检查是否包含 .m3u8?
344
+ bestVariantUrl = resolveUrl(baseUrl, line);
345
+ logDebug(`备选方案:找到第一个子列表引用: ${bestVariantUrl}`);
346
+ break;
347
+ }
348
+ }
349
+ }
350
+
351
+ if (!bestVariantUrl) {
352
+ logDebug(`在主列表 ${url} 中未找到任何有效的子播放列表 URL。可能格式有问题或仅包含音频/字幕。将尝试按媒体列表处理原始内容。`);
353
+ return processMediaPlaylist(url, content);
354
+ }
355
+
356
+ // --- 获取并处理选中的子 M3U8 ---
357
+
358
+ const cacheKey = `m3u8_processed:${bestVariantUrl}`; // 使用处理后的缓存键
359
+
360
+ let kvNamespace = null;
361
+ try {
362
+ kvNamespace = env.LIBRETV_PROXY_KV; // 从环境获取 KV 命名空间 (变量名在 Cloudflare 设置)
363
+ if (!kvNamespace) throw new Error("KV 命名空间未绑定");
364
+ } catch (e) {
365
+ logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);
366
+ kvNamespace = null; // 确保设为 null
367
+ }
368
+
369
+ if (kvNamespace) {
370
+ try {
371
+ const cachedContent = await kvNamespace.get(cacheKey);
372
+ if (cachedContent) {
373
+ logDebug(`[缓存命中] 主列表的子列表: ${bestVariantUrl}`);
374
+ return cachedContent;
375
+ } else {
376
+ logDebug(`[缓存未命中] 主列表的子列表: ${bestVariantUrl}`);
377
+ }
378
+ } catch (kvError) {
379
+ logDebug(`从 KV 读取缓存失败 (${cacheKey}): ${kvError.message}`);
380
+ // 出错则继续执行,不影响功能
381
+ }
382
+ }
383
+
384
+ logDebug(`选择的子列表 (带宽: ${highestBandwidth}): ${bestVariantUrl}`);
385
+ const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl);
386
+
387
+ if (!isM3u8Content(variantContent, variantContentType)) {
388
+ logDebug(`获取到的子列表 ${bestVariantUrl} 不是 M3U8 内容 (类型: ${variantContentType})。可能直接是媒体文件,返回原始内容。`);
389
+ // 如果不是M3U8,但看起来像媒体内容,直接返回代理后的内容
390
+ // 注意:这里可能需要决定是否直接代理这个非 M3U8 的 URL
391
+ // 为了简化,我们假设如果不是 M3U8,则流程中断或按原样处理
392
+ // 或者,尝试将其作为媒体列表处理?(当前行为)
393
+ // return createResponse(variantContent, 200, { 'Content-Type': variantContentType || 'application/octet-stream' });
394
+ // 尝试按媒体列表处理,以防万一
395
+ return processMediaPlaylist(bestVariantUrl, variantContent);
396
+
397
+ }
398
+
399
+ const processedVariant = await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1, env);
400
+
401
+ if (kvNamespace) {
402
+ try {
403
+ // 使用 waitUntil 异步写入缓存,不阻塞响应返回
404
+ // 注意 KV 的写入限制 (免费版每天 1000 次)
405
+ waitUntil(kvNamespace.put(cacheKey, processedVariant, { expirationTtl: CACHE_TTL }));
406
+ logDebug(`已将处理后的子列表写入缓存: ${bestVariantUrl}`);
407
+ } catch (kvError) {
408
+ logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);
409
+ // 写入失败不影响返回结果
410
+ }
411
+ }
412
+
413
+ return processedVariant;
414
+ }
415
+
416
+ // --- 主要请求处理逻辑 ---
417
+
418
+ try {
419
+ const targetUrl = getTargetUrlFromPath(url.pathname);
420
+
421
+ if (!targetUrl) {
422
+ logDebug(`无效的代理请求路径: ${url.pathname}`);
423
+ return createResponse("无效的代理请求。路径应为 /proxy/<经过编码的URL>", 400);
424
+ }
425
+
426
+ logDebug(`收到代理请求: ${targetUrl}`);
427
+
428
+ // --- 缓存检查 (KV) ---
429
+ const cacheKey = `proxy_raw:${targetUrl}`; // 使用原始内容的缓存键
430
+ let kvNamespace = null;
431
+ try {
432
+ kvNamespace = env.LIBRETV_PROXY_KV;
433
+ if (!kvNamespace) throw new Error("KV 命名空间未绑定");
434
+ } catch (e) {
435
+ logDebug(`KV 命名空间 'LIBRETV_PROXY_KV' 访问出错或未绑定: ${e.message}`);
436
+ kvNamespace = null;
437
+ }
438
+
439
+ if (kvNamespace) {
440
+ try {
441
+ const cachedDataJson = await kvNamespace.get(cacheKey); // 直接获取字符串
442
+ if (cachedDataJson) {
443
+ logDebug(`[缓存命中] 原始内容: ${targetUrl}`);
444
+ const cachedData = JSON.parse(cachedDataJson); // 解析 JSON
445
+ const content = cachedData.body;
446
+ let headers = {};
447
+ try { headers = JSON.parse(cachedData.headers); } catch(e){} // 解析头部
448
+ const contentType = headers['content-type'] || headers['Content-Type'] || '';
449
+
450
+ if (isM3u8Content(content, contentType)) {
451
+ logDebug(`缓存内容是 M3U8,重新处理: ${targetUrl}`);
452
+ const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);
453
+ return createM3u8Response(processedM3u8);
454
+ } else {
455
+ logDebug(`从缓存返回非 M3U8 内容: ${targetUrl}`);
456
+ return createResponse(content, 200, new Headers(headers));
457
+ }
458
+ } else {
459
+ logDebug(`[缓存未命中] 原始内容: ${targetUrl}`);
460
+ }
461
+ } catch (kvError) {
462
+ logDebug(`从 KV 读取或解析缓存失败 (${cacheKey}): ${kvError.message}`);
463
+ // 出错则继续执行,不影响功能
464
+ }
465
+ }
466
+
467
+ // --- 实际请求 ---
468
+ const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl);
469
+
470
+ // --- 写入缓存 (KV) ---
471
+ if (kvNamespace) {
472
+ try {
473
+ const headersToCache = {};
474
+ responseHeaders.forEach((value, key) => { headersToCache[key.toLowerCase()] = value; });
475
+ const cacheValue = { body: content, headers: JSON.stringify(headersToCache) };
476
+ // 注意 KV 写入限制
477
+ waitUntil(kvNamespace.put(cacheKey, JSON.stringify(cacheValue), { expirationTtl: CACHE_TTL }));
478
+ logDebug(`已将原始内容写入缓存: ${targetUrl}`);
479
+ } catch (kvError) {
480
+ logDebug(`向 KV 写入缓存失败 (${cacheKey}): ${kvError.message}`);
481
+ // 写入失败不影响返回结果
482
+ }
483
+ }
484
+
485
+ // --- 处理响应 ---
486
+ if (isM3u8Content(content, contentType)) {
487
+ logDebug(`内容是 M3U8,开始处理: ${targetUrl}`);
488
+ const processedM3u8 = await processM3u8Content(targetUrl, content, 0, env);
489
+ return createM3u8Response(processedM3u8);
490
+ } else {
491
+ logDebug(`内容不是 M3U8 (类型: ${contentType}),直接返回: ${targetUrl}`);
492
+ const finalHeaders = new Headers(responseHeaders);
493
+ finalHeaders.set('Cache-Control', `public, max-age=${CACHE_TTL}`);
494
+ // 添加 CORS 头,确保非 M3U8 内容也能跨域访问(例如图片、字幕文件等)
495
+ finalHeaders.set("Access-Control-Allow-Origin", "*");
496
+ finalHeaders.set("Access-Control-Allow-Methods", "GET, HEAD, POST, OPTIONS");
497
+ finalHeaders.set("Access-Control-Allow-Headers", "*");
498
+ return createResponse(content, 200, finalHeaders);
499
+ }
500
+
501
+ } catch (error) {
502
+ logDebug(`处理代理请求时发生严重错误: ${error.message} \n ${error.stack}`);
503
+ return createResponse(`代理处理错误: ${error.message}`, 500);
504
+ }
505
+ }
506
+
507
+ // 处理 OPTIONS 预检请求的函数
508
+ export async function onOptions(context) {
509
+ // 直接返回允许跨域的头信息
510
+ return new Response(null, {
511
+ status: 204, // No Content
512
+ headers: {
513
+ "Access-Control-Allow-Origin": "*",
514
+ "Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
515
+ "Access-Control-Allow-Headers": "*", // 允许所有请求头
516
+ "Access-Control-Max-Age": "86400", // 预检请求结果缓存一天
517
+ },
518
+ });
519
+ }
index.html ADDED
@@ -0,0 +1,431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LibreTV - 免费在线视频搜索与观看平台</title>
7
+ <meta name="description" content="LibreTV是一个免费的在线视频搜索平台,无广告、安全,提供来自多个视频源的内容搜索与观看服务,无需注册即可使用。">
8
+ <meta name="keywords" content="在线视频,免费视频,视频搜索,电影,电视剧,LibreTV">
9
+ <meta name="author" content="LibreTV Team">
10
+ <meta name="robots" content="index, follow">
11
+
12
+ <!-- Open Graph / Facebook -->
13
+ <meta property="og:type" content="website">
14
+ <meta property="og:url" content="https://libretv.is-an.org/">
15
+ <meta property="og:title" content="LibreTV - 免费在线视频搜索与观看平台">
16
+ <meta property="og:description" content="搜索并观看来自多个视频源的内容,支持多种设备,无需注册即可使用。">
17
+ <meta property="og:image" content="https://images.icon-icons.com/38/PNG/512/retrotv_5520.png">
18
+
19
+ <!-- Twitter -->
20
+ <meta property="twitter:card" content="summary_large_image">
21
+ <meta property="twitter:url" content="https://libretv.is-an.org/">
22
+ <meta property="twitter:title" content="LibreTV - 免费在线视频搜索与观看平台">
23
+ <meta property="twitter:description" content="搜索并观看来自多个视频源的内容,支持多种设备,无需注册即可使用。">
24
+ <meta property="twitter:image" content="https://images.icon-icons.com/38/PNG/512/retrotv_5520.png">
25
+
26
+ <!-- Favicon -->
27
+ <link rel="icon" href="https://images.icon-icons.com/38/PNG/512/retrotv_5520.png">
28
+ <link rel="apple-touch-icon" href="https://images.icon-icons.com/38/PNG/512/retrotv_5520.png">
29
+
30
+ <!-- Canonical URL -->
31
+ <link rel="canonical" href="https://libretv.is-an.org/">
32
+
33
+ <script src="https://cdn.tailwindcss.com"></script>
34
+ <link rel="stylesheet" href="css/styles.css">
35
+ </head>
36
+ <body class="page-bg text-white">
37
+ <!-- 将历史记录按钮移到左上角,并缩小尺寸 -->
38
+ <div class="fixed top-4 left-4 z-50">
39
+ <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="观看历史">
40
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
41
+ <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>
42
+ </svg>
43
+ </button>
44
+ </div>
45
+
46
+ <!-- 设置按钮保留在右上角,并缩小尺寸 -->
47
+ <div class="fixed top-4 right-4 z-50">
48
+ <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="打开设置">
49
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
50
+ <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>
51
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
52
+ </svg>
53
+ </button>
54
+ </div>
55
+
56
+ <!-- 历史记录面板 - 标题居中 -->
57
+ <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">
58
+ <div class="flex justify-between items-center mb-6">
59
+ <button onclick="toggleHistory()" class="text-gray-400 hover:text-white" aria-label="关闭历史">&times;</button>
60
+ <h3 class="text-xl font-bold gradient-text mx-auto">观看历史</h3>
61
+ <div class="w-4"></div> <!-- 添加一个占位元素以确保标题居中 -->
62
+ </div>
63
+ <div id="historyList" class="pb-4">
64
+ <!-- 历史记录将在这里动态显示 -->
65
+ <div class="text-center text-gray-500 py-8">暂无观看记录</div>
66
+ </div>
67
+ <div class="mt-4 text-center sticky bottom-0 pb-2 pt-2 bg-[#111]">
68
+ <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">
69
+ 清空历史记录
70
+ </button>
71
+ </div>
72
+ </div>
73
+
74
+ <!-- 设置面板 -->
75
+ <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">
76
+ <div class="flex justify-between items-center mb-6">
77
+ <h3 class="text-xl font-bold gradient-text">设置</h3>
78
+ <button onclick="toggleSettings()" class="text-gray-400 hover:text-white" aria-label="关闭设置">&times;</button>
79
+ </div>
80
+ <div class="space-y-5">
81
+ <!-- 数据源设置区域 -->
82
+ <div class="p-3 bg-[#151515] rounded-lg shadow-inner">
83
+ <label class="block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1">数据源设置</label>
84
+
85
+ <!-- 批量操作按钮 -->
86
+ <div class="flex space-x-2 mb-3">
87
+ <button onclick="selectAllAPIs(true)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全选</button>
88
+ <button onclick="selectAllAPIs(false)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全不选</button>
89
+ <button onclick="selectAllAPIs(true, true)" class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white text-xs rounded">全选普通资源</button>
90
+ </div>
91
+
92
+ <!-- API选择区域 - 使用滚动区域 -->
93
+ <div class="max-h-40 overflow-y-auto bg-[#191919] p-2 rounded-lg mb-3">
94
+ <div id="apiCheckboxes" class="grid grid-cols-2 gap-2">
95
+ <!-- 这里将动态插入API复选框 -->
96
+ </div>
97
+ </div>
98
+
99
+ <!-- API信息显示 -->
100
+ <div class="text-xs text-gray-500 flex justify-between items-center">
101
+ <span>已选API数量:<span id="selectedApiCount" class="text-white">0</span></span>
102
+ <span id="siteStatus" class="ml-2"></span>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- 自定义API管理区域 -->
107
+ <div class="p-3 bg-[#151515] rounded-lg shadow-inner">
108
+ <div class="flex justify-between items-center mb-2">
109
+ <label class="block text-sm font-medium text-gray-400 border-b border-[#333] w-full pb-1">自定义API</label>
110
+ <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>
111
+ </div>
112
+ <div id="customApisList" class="max-h-32 overflow-y-auto mb-2">
113
+ <!-- 自定义API将显示在这里 -->
114
+ </div>
115
+
116
+ <!-- 添加自定义API表单 (默认隐藏) -->
117
+ <div id="addCustomApiForm" class="hidden mt-2 p-2 bg-[#191919] rounded-lg">
118
+ <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">
119
+ <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">
120
+ <!-- 添加成人内容切换 -->
121
+ <div class="flex items-center mb-2">
122
+ <input type="checkbox" id="customApiIsAdult" class="form-checkbox h-4 w-4 text-pink-500 bg-[#222] border border-[#333]">
123
+ <label for="customApiIsAdult" class="ml-2 text-xs text-pink-400">黄色资源站</label>
124
+ </div>
125
+ <div class="flex space-x-2">
126
+ <button onclick="addCustomApi()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">添加</button>
127
+ <button onclick="cancelAddCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- 内容过滤设置区域 -->
133
+ <div class="p-3 bg-[#151515] rounded-lg shadow-inner">
134
+ <label class="block text-sm font-medium text-gray-400 mb-3 border-b border-[#333] pb-1">功能开关</label>
135
+
136
+ <!-- 黄色内容过滤开关 -->
137
+ <div class="flex flex-col mb-3 pb-3 border-b border-[#222] relative">
138
+ <div class="flex items-center justify-between">
139
+ <div>
140
+ <label class="text-sm font-medium text-gray-400">黄色内容过滤</label>
141
+ <p class="text-xs text-gray-500 mt-1 filter-description">过滤"伦理片"等黄色内容</p>
142
+ </div>
143
+ <div class="relative inline-block w-12 align-middle select-none">
144
+ <input type="checkbox" id="yellowFilterToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
145
+ <div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
146
+ <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>
147
+ </div>
148
+ </div>
149
+ <!-- 警告提示将在这里动态插入 -->
150
+ </div>
151
+
152
+ <!-- 广告过滤开关 -->
153
+ <div class="flex items-center justify-between mb-3 pb-3 border-b border-[#222]">
154
+ <div>
155
+ <label class="text-sm font-medium text-gray-400">分片广告过滤</label>
156
+ <p class="text-xs text-gray-500 mt-1">关闭可减少旧版浏览器卡顿</p>
157
+ </div>
158
+ <div class="relative inline-block w-12 align-middle select-none">
159
+ <input type="checkbox" id="adFilterToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
160
+ <div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
161
+ <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>
162
+ </div>
163
+ </div>
164
+
165
+ <!-- 豆瓣热门开关 -->
166
+ <div class="flex items-center justify-between">
167
+ <div>
168
+ <label class="text-sm font-medium text-gray-400">豆瓣热门推荐</label>
169
+ <p class="text-xs text-gray-500 mt-1">首页显示豆瓣热门影视内容</p>
170
+ </div>
171
+ <div class="relative inline-block w-12 align-middle select-none">
172
+ <input type="checkbox" id="doubanToggle" class="opacity-0 absolute w-full h-full cursor-pointer z-10">
173
+ <div class="toggle-bg bg-[#333] w-12 h-6 rounded-full transition-colors duration-300 ease-in-out"></div>
174
+ <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>
175
+ </div>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </div>
180
+
181
+ <div class="container mx-auto px-4 py-8 flex flex-col h-screen">
182
+ <div class="flex-1 flex flex-col">
183
+ <!-- 网站标志和口号 -->
184
+ <header class="text-center mb-2">
185
+ <div class="flex justify-center items-center mb-4">
186
+ <a href="#" onclick="resetToHome(); return false;" class="flex items-center">
187
+ <svg class="w-10 h-10 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
188
+ <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>
189
+ </svg>
190
+ <h1 class="text-5xl font-bold gradient-text">LibreTV</h1>
191
+ </a>
192
+ </div>
193
+ <p class="text-gray-400 mb-8">自由观影,畅享精彩</p>
194
+ </header>
195
+
196
+
197
+ <div id="searchArea" class="flex-1 flex flex-col items-center justify-center">
198
+ <div class="w-full max-w-2xl">
199
+ <div class="flex items-stretch mb-3 h-14 shadow-lg rounded-lg overflow-hidden">
200
+ <!-- 首页按钮 -->
201
+ <button onclick="resetToHome()"
202
+ class="w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors"
203
+ aria-label="返回首页" title="返回首页">
204
+ 首页
205
+ </button>
206
+ <!-- 搜索输入 -->
207
+ <input type="text"
208
+ id="searchInput"
209
+ class="flex-1 bg-[#111] border-y border-[#333] text-white px-6 py-0 focus:outline-none transition-colors"
210
+ placeholder="搜索你喜欢的视频..."
211
+ autocomplete="off"
212
+ aria-label="视频搜索框">
213
+ <!-- 搜索按钮 -->
214
+ <button onclick="search()"
215
+ class="w-20 sm:w-24 flex items-center justify-center bg-white text-black font-medium hover:bg-gray-200 transition-colors"
216
+ aria-label="搜索按钮">
217
+ 搜索
218
+ </button>
219
+ </div>
220
+
221
+ <!-- 添加最近搜索记录部分 -->
222
+ <div id="recentSearches" class="mt-4 flex flex-wrap gap-2" aria-label="最近搜索记录">
223
+ <!-- 这里会动态插入最近的搜索记录 -->
224
+ </div>
225
+ </div>
226
+ </div>
227
+
228
+ <!-- 豆瓣热门推荐区域: 默认隐藏,现在位于搜索区域下方,调整宽度 -->
229
+ <div id="doubanArea" class="w-full my-8 hidden">
230
+ <div class="mx-auto max-w-screen-xl px-2">
231
+ <!-- 改进标题和标签区域布局 -->
232
+ <div class="mb-4">
233
+ <!-- 标题和刷新按钮一行 -->
234
+ <div class="flex items-center justify-between mb-3">
235
+ <h2 class="text-xl font-bold text-white">豆瓣热门</h2>
236
+ <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">
237
+ <span>换一批</span>
238
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
239
+ <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" />
240
+ </svg>
241
+ </button>
242
+ </div>
243
+ <!-- 分类标签独立成行,添加滚动支持以适应移动设备 -->
244
+ <div class="overflow-x-auto pb-2">
245
+ <div id="douban-tags" class="flex space-x-2 min-w-max"></div>
246
+ </div>
247
+ </div>
248
+
249
+ <!-- 推荐内容 -->
250
+ <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>
251
+ </div>
252
+ </div>
253
+
254
+ <!-- 搜索结果:初始隐藏 -->
255
+ <div id="resultsArea" class="w-full hidden">
256
+ <div class="mx-auto max-w-7xl px-2"> <!-- 添加最大宽度限制并居中 -->
257
+ <div class="flex justify-end items-center mb-4">
258
+ <div class="text-sm text-gray-400">
259
+ <span id="searchResultsCount">0</span> 个结果
260
+ </div>
261
+ </div>
262
+ <div id="results" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
263
+ <!-- 结果将在这里动态生成 -->
264
+ </div>
265
+ </div>
266
+ </div>
267
+ </div>
268
+ </div>
269
+
270
+ <!-- 页脚区域 -->
271
+ <footer class="footer mt-8 py-6 border-t border-[#333] bg-[#0a0a0a]">
272
+ <div class="container mx-auto px-4">
273
+ <div class="flex flex-col md:flex-row justify-between items-center">
274
+ <div class="mb-4 md:mb-0">
275
+ <div class="flex items-center justify-center md:justify-start">
276
+ <svg class="w-6 h-6 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
277
+ <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>
278
+ </svg>
279
+ <span class="gradient-text font-bold">LibreTV</span>
280
+ </div>
281
+ <p class="text-gray-500 text-sm mt-2 text-center md:text-left">© 2025 LibreTV - 自由观影,畅享精彩</p>
282
+ </div>
283
+
284
+ <div class="text-center md:text-right">
285
+ <p class="text-gray-500 text-sm max-w-md">
286
+ 免责声明:本站仅为视频搜索工具,不存储、上传或分发任何视频内容。
287
+ 所有视频均来自第三方API接口。如有侵权,请联系相关内容提供方。
288
+ </p>
289
+ <div class="mt-2 flex justify-center md:justify-end space-x-4">
290
+ <a href="about.html" class="text-gray-400 hover:text-white text-sm transition-colors">关于我们</a>
291
+ <a href="privacy.html" class="text-gray-400 hover:text-white text-sm transition-colors">隐私政策</a>
292
+ </div>
293
+ </div>
294
+ </div>
295
+ </div>
296
+ </footer>
297
+
298
+ <!-- 详情模态框 -->
299
+ <div id="modal" class="fixed inset-0 bg-black/95 hidden flex items-center justify-center transition-opacity duration-300">
300
+ <div class="bg-[#111] p-8 rounded-lg w-11/12 max-w-4xl border border-[#333] max-h-[90vh] flex flex-col">
301
+ <div class="flex justify-between items-center mb-6 flex-none">
302
+ <h2 id="modalTitle" class="text-2xl font-bold gradient-text break-words pr-4 max-w-[80%]"></h2>
303
+ <button onclick="closeModal()" class="text-gray-400 hover:text-white text-2xl transition-colors flex-shrink-0">&times;</button>
304
+ </div>
305
+ <div id="modalContent" class="overflow-auto flex-1 min-h-0">
306
+ <div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
307
+ </div>
308
+ </div>
309
+ </div>
310
+ </div>
311
+
312
+ <!-- 密码验证弹窗 -->
313
+ <div id="passwordModal" class="fixed inset-0 bg-black/95 hidden items-center justify-center z-[65] transition-opacity duration-300">
314
+ <div class="bg-[#111] p-8 rounded-lg w-11/12 max-w-md border border-[#333] max-h-[90vh] flex flex-col">
315
+ <div class="flex justify-between items-center mb-6 flex-none">
316
+ <h2 class="text-2xl font-bold gradient-text">访问验证</h2>
317
+ </div>
318
+ <div class="mb-6">
319
+ <p class="text-gray-300 mb-4">请输入密码继续访问</p>
320
+ <form id="passwordForm" onsubmit="handlePasswordSubmit(); return false;">
321
+ <input type="text" name="username" id="username" autocomplete="username" style="display:none" tabindex="-1" aria-hidden="true">
322
+ <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">
323
+ <button id="passwordSubmitBtn" type="submit" class="mt-4 w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded">提交</button>
324
+ </form>
325
+ <p id="passwordError" class="text-red-500 mt-2 hidden">密码错误,请重试</p>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ <!-- 版权声明弹窗 -->
331
+ <div id="disclaimerModal" class="fixed inset-0 bg-black/90 hidden items-center justify-center z-[60]">
332
+ <div class="bg-[#111] p-8 rounded-lg border border-[#333] w-11/12 max-w-2xl max-h-[90vh] overflow-y-auto">
333
+ <h2 class="text-2xl font-bold gradient-text mb-6 text-center">使用声明</h2>
334
+ <div class="text-gray-300 space-y-4">
335
+ <p>
336
+ 欢迎使用 LibreTV。在开始使用前,请您了解并同意以下条款:
337
+ </p>
338
+ <p>
339
+ <strong class="text-blue-400">服务性质:</strong> LibreTV 仅提供视频搜索服务,不直接提供、存储或上传任何视频内容。所有搜索结果均来自第三方公开接口。
340
+ </p>
341
+ <p>
342
+ <strong class="text-blue-400">用户责任:</strong> 用户在使用本站服务时,须遵守相关法律法规,不得利用搜索结果从事侵权行为,如下载、传播未经授权的作品等。
343
+ </p>
344
+ <p>
345
+ <strong class="text-blue-400">侵权投诉:</strong> 若您是版权方或相关权利人,发现搜索结果中存在侵犯您合法权益的内容,请通过 <a href="mailto:troll@pissmail.com" class="text-blue-400 hover:underline">troll@pissmail.com</a> 向我们反馈。我们将在收到投诉后尽快核实处理。
346
+ </p>
347
+ </div>
348
+ <div class="mt-6 flex justify-center">
349
+ <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">
350
+ 我已了解并接受
351
+ </button>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
+ <!-- 错误提示框 -->
357
+ <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">
358
+ <p id="toastMessage"></p>
359
+ </div>
360
+
361
+ <!-- 添加 loading 提示框 -->
362
+ <div id="loading" class="fixed inset-0 bg-black/80 hidden items-center justify-center z-50">
363
+ <div class="bg-[#111] p-8 rounded-lg border border-[#333] flex items-center space-x-4">
364
+ <div class="w-8 h-8 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
365
+ <p class="text-white text-lg">加载中...</p>
366
+ </div>
367
+ </div>
368
+
369
+ <!-- JSON-LD 结构化数据 -->
370
+ <script type="application/ld+json">
371
+ {
372
+ "@context": "https://schema.org",
373
+ "@type": "WebSite",
374
+ "name": "LibreTV",
375
+ "url": "https://libretv.is-an.org/",
376
+ "description": "免费在线视频搜索与观看平台",
377
+ "potentialAction": {
378
+ "@type": "SearchAction",
379
+ "target": "https://libretv.is-an.org/?s={search_term_string}",
380
+ "query-input": "required name=search_term_string"
381
+ }
382
+ }
383
+ </script>
384
+
385
+ <!-- 引入纯 JS sha256(HTTP 下依然可用) -->
386
+ <script src="https://cdn.jsdelivr.net/npm/js-sha256@0.9.0/build/sha256.min.js"></script>
387
+ <script>
388
+ // 保存原始 js‑sha256 实现,避免被 password.js 覆盖
389
+ window._jsSha256 = window.sha256;
390
+ </script>
391
+ <script src="js/config.js"></script>
392
+ <script src="js/ui.js"></script>
393
+ <script src="js/api.js"></script>
394
+ <script src="js/douban.js"></script>
395
+ <script src="js/password.js"></script>
396
+ <script src="js/app.js"></script>
397
+
398
+ <!-- 环境变量注入脚本 -->
399
+ <script>
400
+ // 创建全局环境变量对象
401
+ window.__ENV__ = window.__ENV__ || {};
402
+
403
+ // 注入服务器端环境变量 (将由服务器端替换)
404
+ // PASSWORD 变量将在这里被服务器端注入
405
+ window.__ENV__.PASSWORD = "{{PASSWORD}}";
406
+ </script>
407
+
408
+ <!-- 弹窗显示脚本 -->
409
+ <script>
410
+ // 在页面加载完成后检查用户是否首次访问
411
+ document.addEventListener('DOMContentLoaded', function() {
412
+ // 检查用户是否已经看过声明
413
+ const hasSeenDisclaimer = localStorage.getItem('hasSeenDisclaimer');
414
+
415
+ if (!hasSeenDisclaimer) {
416
+ // 显示弹窗
417
+ const disclaimerModal = document.getElementById('disclaimerModal');
418
+ disclaimerModal.style.display = 'flex';
419
+
420
+ // 添加接受按钮事件
421
+ document.getElementById('acceptDisclaimerBtn').addEventListener('click', function() {
422
+ // 保存用户已看过声明的状态
423
+ localStorage.setItem('hasSeenDisclaimer', 'true');
424
+ // 隐藏弹窗
425
+ disclaimerModal.style.display = 'none';
426
+ });
427
+ }
428
+ });
429
+ </script>
430
+ </body>
431
+ </html>
js/api.js ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 改进的API请求处理函数
2
+ async function handleApiRequest(url) {
3
+ const customApi = url.searchParams.get('customApi') || '';
4
+ const source = url.searchParams.get('source') || 'heimuer';
5
+
6
+ try {
7
+ if (url.pathname === '/api/search') {
8
+ const searchQuery = url.searchParams.get('wd');
9
+ if (!searchQuery) {
10
+ throw new Error('缺少搜索参数');
11
+ }
12
+
13
+ // 验证API和source的有效性
14
+ if (source === 'custom' && !customApi) {
15
+ throw new Error('使用自定义API时必须提供API地址');
16
+ }
17
+
18
+ if (!API_SITES[source] && source !== 'custom') {
19
+ throw new Error('无效的API来源');
20
+ }
21
+
22
+ const apiUrl = customApi
23
+ ? `${customApi}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`
24
+ : `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
25
+
26
+ // 添加超时处理
27
+ const controller = new AbortController();
28
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
29
+
30
+ try {
31
+ const response = await fetch(PROXY_URL + encodeURIComponent(apiUrl), {
32
+ headers: API_CONFIG.search.headers,
33
+ signal: controller.signal
34
+ });
35
+
36
+ clearTimeout(timeoutId);
37
+
38
+ if (!response.ok) {
39
+ throw new Error(`API请求失败: ${response.status}`);
40
+ }
41
+
42
+ const data = await response.json();
43
+
44
+ // 检查JSON格式的有效性
45
+ if (!data || !Array.isArray(data.list)) {
46
+ throw new Error('API返回的数据格式无效');
47
+ }
48
+
49
+ // 添加源信息到每个结果
50
+ data.list.forEach(item => {
51
+ item.source_name = source === 'custom' ? '自定义源' : API_SITES[source].name;
52
+ item.source_code = source;
53
+ // 对于自定义源,添加API URL信息
54
+ if (source === 'custom') {
55
+ item.api_url = customApi;
56
+ }
57
+ });
58
+
59
+ return JSON.stringify({
60
+ code: 200,
61
+ list: data.list || [],
62
+ });
63
+ } catch (fetchError) {
64
+ clearTimeout(timeoutId);
65
+ throw fetchError;
66
+ }
67
+ }
68
+
69
+ // 详情处理
70
+ if (url.pathname === '/api/detail') {
71
+ const id = url.searchParams.get('id');
72
+ const sourceCode = url.searchParams.get('source') || 'heimuer'; // 获取源代码
73
+
74
+ if (!id) {
75
+ throw new Error('缺少视频ID参数');
76
+ }
77
+
78
+ // 验证ID格式 - 只允许数字和有限的特殊字符
79
+ if (!/^[\w-]+$/.test(id)) {
80
+ throw new Error('无效的视频ID格式');
81
+ }
82
+
83
+ // 验证API和source的有效性
84
+ if (sourceCode === 'custom' && !customApi) {
85
+ throw new Error('使用自定义API时必须提供API地址');
86
+ }
87
+
88
+ if (!API_SITES[sourceCode] && sourceCode !== 'custom') {
89
+ throw new Error('无效的API来源');
90
+ }
91
+
92
+ // 对于特殊源,使用特殊处理方式
93
+ if ((sourceCode === 'ffzy' || sourceCode === 'jisu' || sourceCode === 'huangcang') && API_SITES[sourceCode].detail) {
94
+ return await handleSpecialSourceDetail(id, sourceCode);
95
+ }
96
+
97
+ // 如果是自定义API,并且传递了detail参数,尝试特殊处理
98
+ if (sourceCode === 'custom' && url.searchParams.get('useDetail') === 'true') {
99
+ return await handleCustomApiSpecialDetail(id, customApi);
100
+ }
101
+
102
+ const detailUrl = customApi
103
+ ? `${customApi}${API_CONFIG.detail.path}${id}`
104
+ : `${API_SITES[sourceCode].api}${API_CONFIG.detail.path}${id}`;
105
+
106
+ // 添加超时处理
107
+ const controller = new AbortController();
108
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
109
+
110
+ try {
111
+ const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), {
112
+ headers: API_CONFIG.detail.headers,
113
+ signal: controller.signal
114
+ });
115
+
116
+ clearTimeout(timeoutId);
117
+
118
+ if (!response.ok) {
119
+ throw new Error(`详情请求失败: ${response.status}`);
120
+ }
121
+
122
+ // 解析JSON
123
+ const data = await response.json();
124
+
125
+ // 检查返回的数据是否有效
126
+ if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
127
+ throw new Error('获取到的详情内容无效');
128
+ }
129
+
130
+ // 获取第一个匹配的视频详情
131
+ const videoDetail = data.list[0];
132
+
133
+ // 提取播放地址
134
+ let episodes = [];
135
+
136
+ if (videoDetail.vod_play_url) {
137
+ // 分割不同播放源
138
+ const playSources = videoDetail.vod_play_url.split('$$$');
139
+
140
+ // 提取第一个播放源的集数(通常为主要源)
141
+ if (playSources.length > 0) {
142
+ const mainSource = playSources[0];
143
+ const episodeList = mainSource.split('#');
144
+
145
+ // 从每个集数中提取URL
146
+ episodes = episodeList.map(ep => {
147
+ const parts = ep.split('$');
148
+ // 返回URL部分(通常是第二部分,如果有的话)
149
+ return parts.length > 1 ? parts[1] : '';
150
+ }).filter(url => url && (url.startsWith('http://') || url.startsWith('https://')));
151
+ }
152
+ }
153
+
154
+ // 如果没有找到播放地址,尝试使用正则表达式查找m3u8链接
155
+ if (episodes.length === 0 && videoDetail.vod_content) {
156
+ const matches = videoDetail.vod_content.match(M3U8_PATTERN) || [];
157
+ episodes = matches.map(link => link.replace(/^\$/, ''));
158
+ }
159
+
160
+ return JSON.stringify({
161
+ code: 200,
162
+ episodes: episodes,
163
+ detailUrl: detailUrl,
164
+ videoInfo: {
165
+ title: videoDetail.vod_name,
166
+ cover: videoDetail.vod_pic,
167
+ desc: videoDetail.vod_content,
168
+ type: videoDetail.type_name,
169
+ year: videoDetail.vod_year,
170
+ area: videoDetail.vod_area,
171
+ director: videoDetail.vod_director,
172
+ actor: videoDetail.vod_actor,
173
+ remarks: videoDetail.vod_remarks,
174
+ // 添加源信息
175
+ source_name: sourceCode === 'custom' ? '自定义源' : API_SITES[sourceCode].name,
176
+ source_code: sourceCode
177
+ }
178
+ });
179
+ } catch (fetchError) {
180
+ clearTimeout(timeoutId);
181
+ throw fetchError;
182
+ }
183
+ }
184
+
185
+ throw new Error('未知的API路径');
186
+ } catch (error) {
187
+ console.error('API处理错误:', error);
188
+ return JSON.stringify({
189
+ code: 400,
190
+ msg: error.message || '请求处理失败',
191
+ list: [],
192
+ episodes: [],
193
+ });
194
+ }
195
+ }
196
+
197
+ // 处理自定义API的特殊详情页
198
+ async function handleCustomApiSpecialDetail(id, customApi) {
199
+ try {
200
+ // 构建详情页URL
201
+ const detailUrl = `${customApi}/index.php/vod/detail/id/${id}.html`;
202
+
203
+ // 添加超时处理
204
+ const controller = new AbortController();
205
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
206
+
207
+ // 获取详情页HTML
208
+ const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), {
209
+ headers: {
210
+ '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',
211
+ },
212
+ signal: controller.signal
213
+ });
214
+
215
+ clearTimeout(timeoutId);
216
+
217
+ if (!response.ok) {
218
+ throw new Error(`自定义API详情页请求失败: ${response.status}`);
219
+ }
220
+
221
+ // 获取HTML内容
222
+ const html = await response.text();
223
+
224
+ // 使用通用模式提取m3u8链接
225
+ const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
226
+ let matches = html.match(generalPattern) || [];
227
+
228
+ // 处理链接
229
+ matches = matches.map(link => {
230
+ link = link.substring(1, link.length);
231
+ const parenIndex = link.indexOf('(');
232
+ return parenIndex > 0 ? link.substring(0, parenIndex) : link;
233
+ });
234
+
235
+ // 提取基本信息
236
+ const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
237
+ const titleText = titleMatch ? titleMatch[1].trim() : '';
238
+
239
+ const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
240
+ const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';
241
+
242
+ return JSON.stringify({
243
+ code: 200,
244
+ episodes: matches,
245
+ detailUrl: detailUrl,
246
+ videoInfo: {
247
+ title: titleText,
248
+ desc: descText,
249
+ source_name: '自定义源',
250
+ source_code: 'custom'
251
+ }
252
+ });
253
+ } catch (error) {
254
+ console.error(`自定义API详情获取失败:`, error);
255
+ throw error;
256
+ }
257
+ }
258
+
259
+ // 处理极速资源详情的特殊函数
260
+ async function handleJisuDetail(id, sourceCode) {
261
+ // 直接复用通用的特殊源处理函数,传入相应参数
262
+ return await handleSpecialSourceDetail(id, sourceCode);
263
+ }
264
+
265
+ // 处理非凡影视详情的特殊函数
266
+ async function handleFFZYDetail(id, sourceCode) {
267
+ // 直接复用通用的特殊源处理函数,传入相应参数
268
+ return await handleSpecialSourceDetail(id, sourceCode);
269
+ }
270
+
271
+ // 通用特殊源详情处理函数
272
+ async function handleSpecialSourceDetail(id, sourceCode) {
273
+ try {
274
+ // 构建详情页URL(使用配置中的detail URL而不是api URL)
275
+ const detailUrl = `${API_SITES[sourceCode].detail}/index.php/vod/detail/id/${id}.html`;
276
+
277
+ // 添加超时处理
278
+ const controller = new AbortController();
279
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
280
+
281
+ // 获取详情页HTML
282
+ const response = await fetch(PROXY_URL + encodeURIComponent(detailUrl), {
283
+ headers: {
284
+ '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',
285
+ },
286
+ signal: controller.signal
287
+ });
288
+
289
+ clearTimeout(timeoutId);
290
+
291
+ if (!response.ok) {
292
+ throw new Error(`详情页请求失败: ${response.status}`);
293
+ }
294
+
295
+ // 获取HTML内容
296
+ const html = await response.text();
297
+
298
+ // 根据不同源类型使用不同的正则表达式
299
+ let matches = [];
300
+
301
+ if (sourceCode === 'ffzy') {
302
+ // 非凡影视使用特定的正则表达式
303
+ const ffzyPattern = /\$(https?:\/\/[^"'\s]+?\/\d{8}\/\d+_[a-f0-9]+\/index\.m3u8)/g;
304
+ matches = html.match(ffzyPattern) || [];
305
+ }
306
+
307
+ // 如果没有找到链接或者是其他源类型,尝试一个更通用的模式
308
+ if (matches.length === 0) {
309
+ const generalPattern = /\$(https?:\/\/[^"'\s]+?\.m3u8)/g;
310
+ matches = html.match(generalPattern) || [];
311
+ }
312
+ // 去重处理,避免一个播放源多集显示
313
+ matches = [...new Set(matches)];
314
+ // 处理链接
315
+ matches = matches.map(link => {
316
+ link = link.substring(1, link.length);
317
+ const parenIndex = link.indexOf('(');
318
+ return parenIndex > 0 ? link.substring(0, parenIndex) : link;
319
+ });
320
+
321
+ // 提取可能存在的标题、简介等基本信息
322
+ const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/);
323
+ const titleText = titleMatch ? titleMatch[1].trim() : '';
324
+
325
+ const descMatch = html.match(/<div[^>]*class=["']sketch["'][^>]*>([\s\S]*?)<\/div>/);
326
+ const descText = descMatch ? descMatch[1].replace(/<[^>]+>/g, ' ').trim() : '';
327
+
328
+ return JSON.stringify({
329
+ code: 200,
330
+ episodes: matches,
331
+ detailUrl: detailUrl,
332
+ videoInfo: {
333
+ title: titleText,
334
+ desc: descText,
335
+ source_name: API_SITES[sourceCode].name,
336
+ source_code: sourceCode
337
+ }
338
+ });
339
+ } catch (error) {
340
+ console.error(`${API_SITES[sourceCode].name}详情获取失败:`, error);
341
+ throw error;
342
+ }
343
+ }
344
+
345
+ // 处理聚合搜索
346
+ async function handleAggregatedSearch(searchQuery) {
347
+ // 获取可用的API源列表(排除aggregated和custom)
348
+ const availableSources = Object.keys(API_SITES).filter(key =>
349
+ key !== 'aggregated' && key !== 'custom'
350
+ );
351
+
352
+ if (availableSources.length === 0) {
353
+ throw new Error('没有可用的API源');
354
+ }
355
+
356
+ // 创建所有API源的搜索请求
357
+ const searchPromises = availableSources.map(async (source) => {
358
+ try {
359
+ const apiUrl = `${API_SITES[source].api}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
360
+
361
+ // 使用Promise.race添加超时处理
362
+ const timeoutPromise = new Promise((_, reject) =>
363
+ setTimeout(() => reject(new Error(`${source}源搜索超时`)), 8000)
364
+ );
365
+
366
+ const fetchPromise = fetch(PROXY_URL + encodeURIComponent(apiUrl), {
367
+ headers: API_CONFIG.search.headers
368
+ });
369
+
370
+ const response = await Promise.race([fetchPromise, timeoutPromise]);
371
+
372
+ if (!response.ok) {
373
+ throw new Error(`${source}源请求失败: ${response.status}`);
374
+ }
375
+
376
+ const data = await response.json();
377
+
378
+ if (!data || !Array.isArray(data.list)) {
379
+ throw new Error(`${source}源返回的数据格式无效`);
380
+ }
381
+
382
+ // 为搜索结果添加源信息
383
+ const results = data.list.map(item => ({
384
+ ...item,
385
+ source_name: API_SITES[source].name,
386
+ source_code: source
387
+ }));
388
+
389
+ return results;
390
+ } catch (error) {
391
+ console.warn(`${source}源搜索失败:`, error);
392
+ return []; // 返回空数组表示该源搜索失败
393
+ }
394
+ });
395
+
396
+ try {
397
+ // 并行执行所有搜索请求
398
+ const resultsArray = await Promise.all(searchPromises);
399
+
400
+ // 合并所有结果
401
+ let allResults = [];
402
+ resultsArray.forEach(results => {
403
+ if (Array.isArray(results) && results.length > 0) {
404
+ allResults = allResults.concat(results);
405
+ }
406
+ });
407
+
408
+ // 如果没有搜索结果,返回空结果
409
+ if (allResults.length === 0) {
410
+ return JSON.stringify({
411
+ code: 200,
412
+ list: [],
413
+ msg: '所有源均无搜索结果'
414
+ });
415
+ }
416
+
417
+ // 去重(根据vod_id和source_code组合)
418
+ const uniqueResults = [];
419
+ const seen = new Set();
420
+
421
+ allResults.forEach(item => {
422
+ const key = `${item.source_code}_${item.vod_id}`;
423
+ if (!seen.has(key)) {
424
+ seen.add(key);
425
+ uniqueResults.push(item);
426
+ }
427
+ });
428
+
429
+ // 按照视频名称和来源排序
430
+ uniqueResults.sort((a, b) => {
431
+ // 首先按照视频名称排序
432
+ const nameCompare = (a.vod_name || '').localeCompare(b.vod_name || '');
433
+ if (nameCompare !== 0) return nameCompare;
434
+
435
+ // 如果名称相同,则按照来源排序
436
+ return (a.source_name || '').localeCompare(b.source_name || '');
437
+ });
438
+
439
+ return JSON.stringify({
440
+ code: 200,
441
+ list: uniqueResults,
442
+ });
443
+ } catch (error) {
444
+ console.error('聚合搜索处理错误:', error);
445
+ return JSON.stringify({
446
+ code: 400,
447
+ msg: '聚合搜索处理失败: ' + error.message,
448
+ list: []
449
+ });
450
+ }
451
+ }
452
+
453
+ // 处理多个自定义API源的聚合搜索
454
+ async function handleMultipleCustomSearch(searchQuery, customApiUrls) {
455
+ // 解析自定义API列表
456
+ const apiUrls = customApiUrls.split(CUSTOM_API_CONFIG.separator)
457
+ .map(url => url.trim())
458
+ .filter(url => url.length > 0 && /^https?:\/\//.test(url))
459
+ .slice(0, CUSTOM_API_CONFIG.maxSources);
460
+
461
+ if (apiUrls.length === 0) {
462
+ throw new Error('没有提供有效的自定义API地址');
463
+ }
464
+
465
+ // 为每个API创建搜索请求
466
+ const searchPromises = apiUrls.map(async (apiUrl, index) => {
467
+ try {
468
+ const fullUrl = `${apiUrl}${API_CONFIG.search.path}${encodeURIComponent(searchQuery)}`;
469
+
470
+ // 使用Promise.race添加超时处理
471
+ const timeoutPromise = new Promise((_, reject) =>
472
+ setTimeout(() => reject(new Error(`自定义API ${index+1} 搜索超时`)), 8000)
473
+ );
474
+
475
+ const fetchPromise = fetch(PROXY_URL + encodeURIComponent(fullUrl), {
476
+ headers: API_CONFIG.search.headers
477
+ });
478
+
479
+ const response = await Promise.race([fetchPromise, timeoutPromise]);
480
+
481
+ if (!response.ok) {
482
+ throw new Error(`自定义API ${index+1} 请求失败: ${response.status}`);
483
+ }
484
+
485
+ const data = await response.json();
486
+
487
+ if (!data || !Array.isArray(data.list)) {
488
+ throw new Error(`自定义API ${index+1} 返回的数据格式无效`);
489
+ }
490
+
491
+ // 为搜索结果添加源信息
492
+ const results = data.list.map(item => ({
493
+ ...item,
494
+ source_name: `${CUSTOM_API_CONFIG.namePrefix}${index+1}`,
495
+ source_code: 'custom',
496
+ api_url: apiUrl // 保存API URL以便详情获取
497
+ }));
498
+
499
+ return results;
500
+ } catch (error) {
501
+ console.warn(`自定义API ${index+1} 搜索失败:`, error);
502
+ return []; // 返回空数组表示该源搜索失败
503
+ }
504
+ });
505
+
506
+ try {
507
+ // 并行执行所有搜索请求
508
+ const resultsArray = await Promise.all(searchPromises);
509
+
510
+ // 合并所有结果
511
+ let allResults = [];
512
+ resultsArray.forEach(results => {
513
+ if (Array.isArray(results) && results.length > 0) {
514
+ allResults = allResults.concat(results);
515
+ }
516
+ });
517
+
518
+ // 如果没有搜索结果,返回空结果
519
+ if (allResults.length === 0) {
520
+ return JSON.stringify({
521
+ code: 200,
522
+ list: [],
523
+ msg: '所有自定义API源均无搜索结果'
524
+ });
525
+ }
526
+
527
+ // 去重(根据vod_id和api_url组合)
528
+ const uniqueResults = [];
529
+ const seen = new Set();
530
+
531
+ allResults.forEach(item => {
532
+ const key = `${item.api_url || ''}_${item.vod_id}`;
533
+ if (!seen.has(key)) {
534
+ seen.add(key);
535
+ uniqueResults.push(item);
536
+ }
537
+ });
538
+
539
+ return JSON.stringify({
540
+ code: 200,
541
+ list: uniqueResults,
542
+ });
543
+ } catch (error) {
544
+ console.error('自定义API聚合搜索处理错误:', error);
545
+ return JSON.stringify({
546
+ code: 400,
547
+ msg: '自定义API聚合搜索处理失败: ' + error.message,
548
+ list: []
549
+ });
550
+ }
551
+ }
552
+
553
+ // 拦截API请求
554
+ (function() {
555
+ const originalFetch = window.fetch;
556
+
557
+ window.fetch = async function(input, init) {
558
+ const requestUrl = typeof input === 'string' ? new URL(input, window.location.origin) : input.url;
559
+
560
+ if (requestUrl.pathname.startsWith('/api/')) {
561
+ if (window.isPasswordProtected && window.isPasswordVerified) {
562
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
563
+ return;
564
+ }
565
+ }
566
+ try {
567
+ const data = await handleApiRequest(requestUrl);
568
+ return new Response(data, {
569
+ headers: {
570
+ 'Content-Type': 'application/json',
571
+ 'Access-Control-Allow-Origin': '*',
572
+ },
573
+ });
574
+ } catch (error) {
575
+ return new Response(JSON.stringify({
576
+ code: 500,
577
+ msg: '服务器内部错误',
578
+ }), {
579
+ status: 500,
580
+ headers: {
581
+ 'Content-Type': 'application/json',
582
+ },
583
+ });
584
+ }
585
+ }
586
+
587
+ // 非API请求使用原始fetch
588
+ return originalFetch.apply(this, arguments);
589
+ };
590
+ })();
591
+
592
+ async function testSiteAvailability(apiUrl) {
593
+ try {
594
+ // 使用更简单的测试查询
595
+ const response = await fetch('/api/search?wd=test&customApi=' + encodeURIComponent(apiUrl), {
596
+ // 添加超时
597
+ signal: AbortSignal.timeout(5000)
598
+ });
599
+
600
+ // 检查响应状态
601
+ if (!response.ok) {
602
+ return false;
603
+ }
604
+
605
+ const data = await response.json();
606
+
607
+ // 检查API响应的有效性
608
+ return data && data.code !== 400 && Array.isArray(data.list);
609
+ } catch (error) {
610
+ console.error('站点可用性测试失败:', error);
611
+ return false;
612
+ }
613
+ }
js/app.js ADDED
@@ -0,0 +1,1011 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 全局变量
2
+ let selectedAPIs = JSON.parse(localStorage.getItem('selectedAPIs') || '["heimuer", "dbzy"]'); // 默认选中黑木耳和豆瓣资源
3
+ let customAPIs = JSON.parse(localStorage.getItem('customAPIs') || '[]'); // 存储自定义API列表
4
+
5
+ // 添加当前播放的集数索引
6
+ let currentEpisodeIndex = 0;
7
+ // 添加当前视频的所有集数
8
+ let currentEpisodes = [];
9
+ // 添加当前视频的标题
10
+ let currentVideoTitle = '';
11
+ // 全局变量用于倒序状态
12
+ let episodesReversed = false;
13
+
14
+ // 页面初始化
15
+ document.addEventListener('DOMContentLoaded', function() {
16
+ // 初始化API复选框
17
+ initAPICheckboxes();
18
+
19
+ // 初始化自定义API列表
20
+ renderCustomAPIsList();
21
+
22
+ // 初始化显示选中的API数量
23
+ updateSelectedApiCount();
24
+
25
+ // 渲染搜索历史
26
+ renderSearchHistory();
27
+
28
+ // 设置默认API选择(如果是第一次加载)
29
+ if (!localStorage.getItem('hasInitializedDefaults')) {
30
+ // 仅选择黑木耳源和豆瓣资源
31
+ selectedAPIs = ["heimuer", "dbzy"];
32
+ localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
33
+
34
+ // 默认选中过滤开关
35
+ localStorage.setItem('yellowFilterEnabled', 'true');
36
+ localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, 'true');
37
+
38
+ // 标记已初始化默认值
39
+ localStorage.setItem('hasInitializedDefaults', 'true');
40
+ }
41
+
42
+ // 设置黄色内容过滤开关初始状态
43
+ const yellowFilterToggle = document.getElementById('yellowFilterToggle');
44
+ if (yellowFilterToggle) {
45
+ yellowFilterToggle.checked = localStorage.getItem('yellowFilterEnabled') === 'true';
46
+ }
47
+
48
+ // 设置广告过滤开关初始状态
49
+ const adFilterToggle = document.getElementById('adFilterToggle');
50
+ if (adFilterToggle) {
51
+ adFilterToggle.checked = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true
52
+ }
53
+
54
+ // 设置事件监听器
55
+ setupEventListeners();
56
+
57
+ // 初始检查成人API选中状态
58
+ setTimeout(checkAdultAPIsSelected, 100);
59
+ });
60
+
61
+ // 初始化API复选框
62
+ function initAPICheckboxes() {
63
+ const container = document.getElementById('apiCheckboxes');
64
+ container.innerHTML = '';
65
+
66
+ // 添加普通API组标题
67
+ const normalTitle = document.createElement('div');
68
+ normalTitle.className = 'api-group-title';
69
+ normalTitle.textContent = '普通资源';
70
+ container.appendChild(normalTitle);
71
+
72
+ // 创建普通API源的复选框
73
+ Object.keys(API_SITES).forEach(apiKey => {
74
+ const api = API_SITES[apiKey];
75
+ if (api.adult) return; // 跳过成人内容API,稍后添加
76
+
77
+ const checked = selectedAPIs.includes(apiKey);
78
+
79
+ const checkbox = document.createElement('div');
80
+ checkbox.className = 'flex items-center';
81
+ checkbox.innerHTML = `
82
+ <input type="checkbox" id="api_${apiKey}"
83
+ class="form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333]"
84
+ ${checked ? 'checked' : ''}
85
+ data-api="${apiKey}">
86
+ <label for="api_${apiKey}" class="ml-1 text-xs text-gray-400 truncate">${api.name}</label>
87
+ `;
88
+ container.appendChild(checkbox);
89
+
90
+ // 添加事件监听器
91
+ checkbox.querySelector('input').addEventListener('change', function() {
92
+ updateSelectedAPIs();
93
+ checkAdultAPIsSelected();
94
+ });
95
+ });
96
+
97
+ // 仅在隐藏设置为false时添加成人API组
98
+ if (!HIDE_BUILTIN_ADULT_APIS) {
99
+ // 添加成人API组标题
100
+ const adultTitle = document.createElement('div');
101
+ adultTitle.className = 'api-group-title adult';
102
+ adultTitle.innerHTML = `黄色资源采集站 <span class="adult-warning">
103
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
104
+ <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" />
105
+ </svg>
106
+ </span>`;
107
+ container.appendChild(adultTitle);
108
+
109
+ // 创建成人API源的复选框
110
+ Object.keys(API_SITES).forEach(apiKey => {
111
+ const api = API_SITES[apiKey];
112
+ if (!api.adult) return; // 仅添加成人内容API
113
+
114
+ const checked = selectedAPIs.includes(apiKey);
115
+
116
+ const checkbox = document.createElement('div');
117
+ checkbox.className = 'flex items-center';
118
+ checkbox.innerHTML = `
119
+ <input type="checkbox" id="api_${apiKey}"
120
+ class="form-checkbox h-3 w-3 text-blue-600 bg-[#222] border border-[#333] api-adult"
121
+ ${checked ? 'checked' : ''}
122
+ data-api="${apiKey}">
123
+ <label for="api_${apiKey}" class="ml-1 text-xs text-pink-400 truncate">${api.name}</label>
124
+ `;
125
+ container.appendChild(checkbox);
126
+
127
+ // 添加事件监听器
128
+ checkbox.querySelector('input').addEventListener('change', function() {
129
+ updateSelectedAPIs();
130
+ checkAdultAPIsSelected();
131
+ });
132
+ });
133
+ }
134
+
135
+ // 初始检查成人内容状态
136
+ checkAdultAPIsSelected();
137
+ }
138
+
139
+ // 检查是否有成人API被选中
140
+ function checkAdultAPIsSelected() {
141
+ // 查找所有内置成人API复选框
142
+ const adultBuiltinCheckboxes = document.querySelectorAll('#apiCheckboxes .api-adult:checked');
143
+
144
+ // 查找所有自定义成人API复选框
145
+ const customApiCheckboxes = document.querySelectorAll('#customApisList .api-adult:checked');
146
+
147
+ const hasAdultSelected = adultBuiltinCheckboxes.length > 0 || customApiCheckboxes.length > 0;
148
+
149
+ const yellowFilterToggle = document.getElementById('yellowFilterToggle');
150
+ const yellowFilterContainer = yellowFilterToggle.closest('div').parentNode;
151
+ const filterDescription = yellowFilterContainer.querySelector('p.filter-description');
152
+
153
+ // 如果选择了成人API,禁用黄色内容过滤器
154
+ if (hasAdultSelected) {
155
+ yellowFilterToggle.checked = false;
156
+ yellowFilterToggle.disabled = true;
157
+ localStorage.setItem('yellowFilterEnabled', 'false');
158
+
159
+ // 添加禁用样式
160
+ yellowFilterContainer.classList.add('filter-disabled');
161
+
162
+ // 修改描述文字
163
+ if (filterDescription) {
164
+ filterDescription.innerHTML = '<strong class="text-pink-300">选中黄色资源站时无法启用此过滤</strong>';
165
+ }
166
+
167
+ // 移除提示信息(如果存在)
168
+ const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');
169
+ if (existingTooltip) {
170
+ existingTooltip.remove();
171
+ }
172
+ } else {
173
+ // 启用黄色内容过滤器
174
+ yellowFilterToggle.disabled = false;
175
+ yellowFilterContainer.classList.remove('filter-disabled');
176
+
177
+ // 恢复原来的描述文字
178
+ if (filterDescription) {
179
+ filterDescription.innerHTML = '过滤"伦理片"等黄色内容';
180
+ }
181
+
182
+ // 移除提示信息
183
+ const existingTooltip = yellowFilterContainer.querySelector('.filter-tooltip');
184
+ if (existingTooltip) {
185
+ existingTooltip.remove();
186
+ }
187
+ }
188
+ }
189
+
190
+ // 渲染自定义API列表
191
+ function renderCustomAPIsList() {
192
+ const container = document.getElementById('customApisList');
193
+ if (!container) return;
194
+
195
+ if (customAPIs.length === 0) {
196
+ container.innerHTML = '<p class="text-xs text-gray-500 text-center my-2">未添加自定义API</p>';
197
+ return;
198
+ }
199
+
200
+ container.innerHTML = '';
201
+ customAPIs.forEach((api, index) => {
202
+ const apiItem = document.createElement('div');
203
+ apiItem.className = 'flex items-center justify-between p-1 mb-1 bg-[#222] rounded';
204
+
205
+ // 根据是否是成人内容设置不同的样式
206
+ const textColorClass = api.isAdult ? 'text-pink-400' : 'text-white';
207
+
208
+ // 将(18+)标记移到最前面
209
+ const adultTag = api.isAdult ? '<span class="text-xs text-pink-400 mr-1">(18+)</span>' : '';
210
+
211
+ apiItem.innerHTML = `
212
+ <div class="flex items-center flex-1 min-w-0">
213
+ <input type="checkbox" id="custom_api_${index}"
214
+ class="form-checkbox h-3 w-3 text-blue-600 mr-1 ${api.isAdult ? 'api-adult' : ''}"
215
+ ${selectedAPIs.includes('custom_' + index) ? 'checked' : ''}
216
+ data-custom-index="${index}">
217
+ <div class="flex-1 min-w-0">
218
+ <div class="text-xs font-medium ${textColorClass} truncate">
219
+ ${adultTag}${api.name}
220
+ </div>
221
+ <div class="text-xs text-gray-500 truncate">${api.url}</div>
222
+ </div>
223
+ </div>
224
+ <div class="flex items-center">
225
+ <button class="text-blue-500 hover:text-blue-700 text-xs px-1" onclick="editCustomApi(${index})">✎</button>
226
+ <button class="text-red-500 hover:text-red-700 text-xs px-1" onclick="removeCustomApi(${index})">✕</button>
227
+ </div>
228
+ `;
229
+ container.appendChild(apiItem);
230
+
231
+ // 添加事件监听器
232
+ apiItem.querySelector('input').addEventListener('change', function() {
233
+ updateSelectedAPIs();
234
+ checkAdultAPIsSelected();
235
+ });
236
+ });
237
+ }
238
+
239
+ // 编辑自定义API
240
+ function editCustomApi(index) {
241
+ if (index < 0 || index >= customAPIs.length) return;
242
+
243
+ const api = customAPIs[index];
244
+
245
+ // 填充表单数据
246
+ const nameInput = document.getElementById('customApiName');
247
+ const urlInput = document.getElementById('customApiUrl');
248
+ const isAdultInput = document.getElementById('customApiIsAdult');
249
+
250
+ nameInput.value = api.name;
251
+ urlInput.value = api.url;
252
+ if (isAdultInput) isAdultInput.checked = api.isAdult || false;
253
+
254
+ // 显示表单
255
+ const form = document.getElementById('addCustomApiForm');
256
+ if (form) {
257
+ form.classList.remove('hidden');
258
+
259
+ // 替换表单按钮操作
260
+ const buttonContainer = form.querySelector('div:last-child');
261
+ buttonContainer.innerHTML = `
262
+ <button onclick="updateCustomApi(${index})" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">更新</button>
263
+ <button onclick="cancelEditCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
264
+ `;
265
+ }
266
+ }
267
+
268
+ // 更新自定义API
269
+ function updateCustomApi(index) {
270
+ if (index < 0 || index >= customAPIs.length) return;
271
+
272
+ const nameInput = document.getElementById('customApiName');
273
+ const urlInput = document.getElementById('customApiUrl');
274
+ const isAdultInput = document.getElementById('customApiIsAdult');
275
+
276
+ const name = nameInput.value.trim();
277
+ let url = urlInput.value.trim();
278
+ const isAdult = isAdultInput ? isAdultInput.checked : false;
279
+
280
+ if (!name || !url) {
281
+ showToast('请输入API名称和链接', 'warning');
282
+ return;
283
+ }
284
+
285
+ // 确保URL格式正确
286
+ if (!/^https?:\/\/.+/.test(url)) {
287
+ showToast('API链接格式不正确,需以http://或https://开头', 'warning');
288
+ return;
289
+ }
290
+
291
+ // 移除URL末尾的斜杠
292
+ if (url.endsWith('/')) {
293
+ url = url.slice(0, -1);
294
+ }
295
+
296
+ // 更新API信息
297
+ customAPIs[index] = { name, url, isAdult };
298
+ localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
299
+
300
+ // 重新渲染自定义API列表
301
+ renderCustomAPIsList();
302
+
303
+ // 重新检查成人API选中状态
304
+ checkAdultAPIsSelected();
305
+
306
+ // 恢复添加按钮
307
+ restoreAddCustomApiButtons();
308
+
309
+ // 清空表单并隐藏
310
+ nameInput.value = '';
311
+ urlInput.value = '';
312
+ if (isAdultInput) isAdultInput.checked = false;
313
+ document.getElementById('addCustomApiForm').classList.add('hidden');
314
+
315
+ showToast('已更新自定义API: ' + name, 'success');
316
+ }
317
+
318
+ // 取消编辑自定义API
319
+ function cancelEditCustomApi() {
320
+ // 清空表单
321
+ document.getElementById('customApiName').value = '';
322
+ document.getElementById('customApiUrl').value = '';
323
+ const isAdultInput = document.getElementById('customApiIsAdult');
324
+ if (isAdultInput) isAdultInput.checked = false;
325
+
326
+ // 隐藏表单
327
+ document.getElementById('addCustomApiForm').classList.add('hidden');
328
+
329
+ // 恢复添加按钮
330
+ restoreAddCustomApiButtons();
331
+ }
332
+
333
+ // 恢复自定义API添加按钮
334
+ function restoreAddCustomApiButtons() {
335
+ const form = document.getElementById('addCustomApiForm');
336
+ const buttonContainer = form.querySelector('div:last-child');
337
+ buttonContainer.innerHTML = `
338
+ <button onclick="addCustomApi()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-xs">添加</button>
339
+ <button onclick="cancelAddCustomApi()" class="bg-[#444] hover:bg-[#555] text-white px-3 py-1 rounded text-xs">取消</button>
340
+ `;
341
+ }
342
+
343
+ // 更新选中的API列表
344
+ function updateSelectedAPIs() {
345
+ // 获取所有内置API复选框
346
+ const builtInApiCheckboxes = document.querySelectorAll('#apiCheckboxes input:checked');
347
+
348
+ // 获取选中的内置API
349
+ const builtInApis = Array.from(builtInApiCheckboxes).map(input => input.dataset.api);
350
+
351
+ // 获取选中的自定义API
352
+ const customApiCheckboxes = document.querySelectorAll('#customApisList input:checked');
353
+ const customApiIndices = Array.from(customApiCheckboxes).map(input => 'custom_' + input.dataset.customIndex);
354
+
355
+ // 合并内置和自定义API
356
+ selectedAPIs = [...builtInApis, ...customApiIndices];
357
+
358
+ // 保存到localStorage
359
+ localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
360
+
361
+ // 更新显示选中的API数量
362
+ updateSelectedApiCount();
363
+ }
364
+
365
+ // 更新选中的API数量显示
366
+ function updateSelectedApiCount() {
367
+ const countEl = document.getElementById('selectedApiCount');
368
+ if (countEl) {
369
+ countEl.textContent = selectedAPIs.length;
370
+ }
371
+ }
372
+
373
+ // 全选或取消全选API
374
+ function selectAllAPIs(selectAll = true, excludeAdult = false) {
375
+ const checkboxes = document.querySelectorAll('#apiCheckboxes input[type="checkbox"]');
376
+
377
+ checkboxes.forEach(checkbox => {
378
+ if (excludeAdult && checkbox.classList.contains('api-adult')) {
379
+ checkbox.checked = false;
380
+ } else {
381
+ checkbox.checked = selectAll;
382
+ }
383
+ });
384
+
385
+ updateSelectedAPIs();
386
+ checkAdultAPIsSelected();
387
+ }
388
+
389
+ // 显示添加自定义API表单
390
+ function showAddCustomApiForm() {
391
+ const form = document.getElementById('addCustomApiForm');
392
+ if (form) {
393
+ form.classList.remove('hidden');
394
+ }
395
+ }
396
+
397
+ // 取消添加自定义API - 修改函数来重用恢复按钮逻辑
398
+ function cancelAddCustomApi() {
399
+ const form = document.getElementById('addCustomApiForm');
400
+ if (form) {
401
+ form.classList.add('hidden');
402
+ document.getElementById('customApiName').value = '';
403
+ document.getElementById('customApiUrl').value = '';
404
+ const isAdultInput = document.getElementById('customApiIsAdult');
405
+ if (isAdultInput) isAdultInput.checked = false;
406
+
407
+ // 确保按钮是添加按钮
408
+ restoreAddCustomApiButtons();
409
+ }
410
+ }
411
+
412
+ // 添加自定义API
413
+ function addCustomApi() {
414
+ const nameInput = document.getElementById('customApiName');
415
+ const urlInput = document.getElementById('customApiUrl');
416
+ const isAdultInput = document.getElementById('customApiIsAdult');
417
+
418
+ const name = nameInput.value.trim();
419
+ let url = urlInput.value.trim();
420
+ const isAdult = isAdultInput ? isAdultInput.checked : false;
421
+
422
+ if (!name || !url) {
423
+ showToast('请输入API名称和链接', 'warning');
424
+ return;
425
+ }
426
+
427
+ // 确保URL格式正确
428
+ if (!/^https?:\/\/.+/.test(url)) {
429
+ showToast('API链接格式不正确,需以http://或https://开头', 'warning');
430
+ return;
431
+ }
432
+
433
+ // 移除URL末尾的斜杠
434
+ if (url.endsWith('/')) {
435
+ url = url.slice(0, -1);
436
+ }
437
+
438
+ // 添加到自定义API列表 - 增加isAdult属性
439
+ customAPIs.push({ name, url, isAdult });
440
+ localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
441
+
442
+ // 默认选中新添加的API
443
+ const newApiIndex = customAPIs.length - 1;
444
+ selectedAPIs.push('custom_' + newApiIndex);
445
+ localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
446
+
447
+ // 重新渲染自定义API列表
448
+ renderCustomAPIsList();
449
+
450
+ // 更新选中的API数量
451
+ updateSelectedApiCount();
452
+
453
+ // 重新检查成人API选中状态
454
+ checkAdultAPIsSelected();
455
+
456
+ // 清空表单并隐藏
457
+ nameInput.value = '';
458
+ urlInput.value = '';
459
+ if (isAdultInput) isAdultInput.checked = false;
460
+ document.getElementById('addCustomApiForm').classList.add('hidden');
461
+
462
+ showToast('已添加自定义API: ' + name, 'success');
463
+ }
464
+
465
+ // 移除自定义API
466
+ function removeCustomApi(index) {
467
+ if (index < 0 || index >= customAPIs.length) return;
468
+
469
+ const apiName = customAPIs[index].name;
470
+
471
+ // 从列表中移除API
472
+ customAPIs.splice(index, 1);
473
+ localStorage.setItem('customAPIs', JSON.stringify(customAPIs));
474
+
475
+ // 从选中列表中移除此API
476
+ const customApiId = 'custom_' + index;
477
+ selectedAPIs = selectedAPIs.filter(id => id !== customApiId);
478
+
479
+ // 更新大于此索引的自定义API索引
480
+ selectedAPIs = selectedAPIs.map(id => {
481
+ if (id.startsWith('custom_')) {
482
+ const currentIndex = parseInt(id.replace('custom_', ''));
483
+ if (currentIndex > index) {
484
+ return 'custom_' + (currentIndex - 1);
485
+ }
486
+ }
487
+ return id;
488
+ });
489
+
490
+ localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
491
+
492
+ // 重新渲染自定义API列表
493
+ renderCustomAPIsList();
494
+
495
+ // 更新选中的API数量
496
+ updateSelectedApiCount();
497
+
498
+ // 重新检查成人API选中状态
499
+ checkAdultAPIsSelected();
500
+
501
+ showToast('已移除自定义API: ' + apiName, 'info');
502
+ }
503
+
504
+ // 设置事件监听器
505
+ function setupEventListeners() {
506
+ // 回车搜索
507
+ document.getElementById('searchInput').addEventListener('keypress', function(e) {
508
+ if (e.key === 'Enter') {
509
+ search();
510
+ }
511
+ });
512
+
513
+ // 点击外部关闭设置面板
514
+ document.addEventListener('click', function(e) {
515
+ const panel = document.getElementById('settingsPanel');
516
+ const settingsButton = document.querySelector('button[onclick="toggleSettings(event)"]');
517
+
518
+ if (!panel.contains(e.target) && !settingsButton.contains(e.target) && panel.classList.contains('show')) {
519
+ panel.classList.remove('show');
520
+ }
521
+ });
522
+
523
+ // 黄色内容过滤开关事件绑定
524
+ const yellowFilterToggle = document.getElementById('yellowFilterToggle');
525
+ if (yellowFilterToggle) {
526
+ yellowFilterToggle.addEventListener('change', function(e) {
527
+ localStorage.setItem('yellowFilterEnabled', e.target.checked);
528
+ });
529
+ }
530
+
531
+ // 广告过滤开关事件绑定
532
+ const adFilterToggle = document.getElementById('adFilterToggle');
533
+ if (adFilterToggle) {
534
+ adFilterToggle.addEventListener('change', function(e) {
535
+ localStorage.setItem(PLAYER_CONFIG.adFilteringStorage, e.target.checked);
536
+ });
537
+ }
538
+ }
539
+
540
+ // 重置搜索区域
541
+ function resetSearchArea() {
542
+ // 清理搜索结果
543
+ document.getElementById('results').innerHTML = '';
544
+ document.getElementById('searchInput').value = '';
545
+
546
+ // 恢复搜索区域��样式
547
+ document.getElementById('searchArea').classList.add('flex-1');
548
+ document.getElementById('searchArea').classList.remove('mb-8');
549
+ document.getElementById('resultsArea').classList.add('hidden');
550
+
551
+ // 确保页脚正确显示,移除相对定位
552
+ const footer = document.querySelector('.footer');
553
+ if (footer) {
554
+ footer.style.position = '';
555
+ }
556
+
557
+ // 如果有豆瓣功能,检查是否需要显示豆瓣推荐区域
558
+ if (typeof updateDoubanVisibility === 'function') {
559
+ updateDoubanVisibility();
560
+ }
561
+ }
562
+
563
+ // 获取自定义API信息
564
+ function getCustomApiInfo(customApiIndex) {
565
+ const index = parseInt(customApiIndex);
566
+ if (isNaN(index) || index < 0 || index >= customAPIs.length) {
567
+ return null;
568
+ }
569
+ return customAPIs[index];
570
+ }
571
+
572
+ // 搜索功能 - 修改为支持多选API
573
+ async function search() {
574
+ // 密码保护校验
575
+ if (window.isPasswordProtected && window.isPasswordVerified) {
576
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
577
+ showPasswordModal && showPasswordModal();
578
+ return;
579
+ }
580
+ }
581
+ const query = document.getElementById('searchInput').value.trim();
582
+
583
+ if (!query) {
584
+ showToast('请输入搜索内容', 'info');
585
+ return;
586
+ }
587
+
588
+ if (selectedAPIs.length === 0) {
589
+ showToast('请至少选择一个API源', 'warning');
590
+ return;
591
+ }
592
+
593
+ showLoading();
594
+
595
+ try {
596
+ // 保存搜索历史
597
+ saveSearchHistory(query);
598
+
599
+ // 从所有选中的API源搜索
600
+ let allResults = [];
601
+ const searchPromises = selectedAPIs.map(async (apiId) => {
602
+ try {
603
+ let apiUrl, apiName;
604
+
605
+ // 处理自定义API
606
+ if (apiId.startsWith('custom_')) {
607
+ const customIndex = apiId.replace('custom_', '');
608
+ const customApi = getCustomApiInfo(customIndex);
609
+ if (!customApi) return [];
610
+
611
+ apiUrl = customApi.url + API_CONFIG.search.path + encodeURIComponent(query);
612
+ apiName = customApi.name;
613
+ } else {
614
+ // 内置API
615
+ if (!API_SITES[apiId]) return [];
616
+ apiUrl = API_SITES[apiId].api + API_CONFIG.search.path + encodeURIComponent(query);
617
+ apiName = API_SITES[apiId].name;
618
+ }
619
+
620
+ // 添加超时处理
621
+ const controller = new AbortController();
622
+ const timeoutId = setTimeout(() => controller.abort(), 8000);
623
+
624
+ const response = await fetch(PROXY_URL + encodeURIComponent(apiUrl), {
625
+ headers: API_CONFIG.search.headers,
626
+ signal: controller.signal
627
+ });
628
+
629
+ clearTimeout(timeoutId);
630
+
631
+ if (!response.ok) {
632
+ return [];
633
+ }
634
+
635
+ const data = await response.json();
636
+
637
+ if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) {
638
+ return [];
639
+ }
640
+
641
+ // 添加源信息到每个结果
642
+ const results = data.list.map(item => ({
643
+ ...item,
644
+ source_name: apiName,
645
+ source_code: apiId,
646
+ api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined
647
+ }));
648
+
649
+ return results;
650
+ } catch (error) {
651
+ console.warn(`API ${apiId} 搜索失败:`, error);
652
+ return [];
653
+ }
654
+ });
655
+
656
+ // 等待所有搜索请求完成
657
+ const resultsArray = await Promise.all(searchPromises);
658
+
659
+ // 合并所有结果
660
+ resultsArray.forEach(results => {
661
+ if (Array.isArray(results) && results.length > 0) {
662
+ allResults = allResults.concat(results);
663
+ }
664
+ });
665
+
666
+ // 更新搜索结果计数
667
+ const searchResultsCount = document.getElementById('searchResultsCount');
668
+ if (searchResultsCount) {
669
+ searchResultsCount.textContent = allResults.length;
670
+ }
671
+
672
+ // 显示结果区域,调整搜索区域
673
+ document.getElementById('searchArea').classList.remove('flex-1');
674
+ document.getElementById('searchArea').classList.add('mb-8');
675
+ document.getElementById('resultsArea').classList.remove('hidden');
676
+
677
+ // 隐藏豆瓣推荐区域(如果存在)
678
+ const doubanArea = document.getElementById('doubanArea');
679
+ if (doubanArea) {
680
+ doubanArea.classList.add('hidden');
681
+ }
682
+
683
+ const resultsDiv = document.getElementById('results');
684
+
685
+ // 如果没有结果
686
+ if (!allResults || allResults.length === 0) {
687
+ resultsDiv.innerHTML = `
688
+ <div class="col-span-full text-center py-16">
689
+ <svg class="mx-auto h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
690
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
691
+ 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" />
692
+ </svg>
693
+ <h3 class="mt-2 text-lg font-medium text-gray-400">没有找到匹配的结果</h3>
694
+ <p class="mt-1 text-sm text-gray-500">请尝试其他关键词或更换数据源</p>
695
+ </div>
696
+ `;
697
+ hideLoading();
698
+ return;
699
+ }
700
+
701
+ // 处理搜索结果过滤:如果启用了黄色内容过滤,则过滤掉分类含有敏感内容的项目
702
+ const yellowFilterEnabled = localStorage.getItem('yellowFilterEnabled') === 'true';
703
+ if (yellowFilterEnabled) {
704
+ const banned = ['伦理片','门事件','萝莉少女','制服诱惑','国产传媒','cosplay','黑丝诱惑','无码','日本无码','有码','日本有码','SWAG','网红主播', '色情片','同性片','福利视频','福利片'];
705
+ allResults = allResults.filter(item => {
706
+ const typeName = item.type_name || '';
707
+ return !banned.some(keyword => typeName.includes(keyword));
708
+ });
709
+ }
710
+
711
+ // 添加XSS保护,使用textContent和属性转义
712
+ resultsDiv.innerHTML = allResults.map(item => {
713
+ const safeId = item.vod_id ? item.vod_id.toString().replace(/[^\w-]/g, '') : '';
714
+ const safeName = (item.vod_name || '').toString()
715
+ .replace(/</g, '&lt;')
716
+ .replace(/>/g, '&gt;')
717
+ .replace(/"/g, '&quot;');
718
+ const sourceInfo = item.source_name ?
719
+ `<span class="bg-[#222] text-xs px-1.5 py-0.5 rounded-full">${item.source_name}</span>` : '';
720
+ const sourceCode = item.source_code || '';
721
+
722
+ // 添加API URL属性,用于详情获取
723
+ const apiUrlAttr = item.api_url ?
724
+ `data-api-url="${item.api_url.replace(/"/g, '&quot;')}"` : '';
725
+
726
+ // 更紧凑的卡片布局
727
+ const hasCover = item.vod_pic && item.vod_pic.startsWith('http');
728
+
729
+ return `
730
+ <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"
731
+ onclick="showDetails('${safeId}','${safeName}','${sourceCode}')" ${apiUrlAttr}>
732
+ <div class="flex flex-col h-full">
733
+ ${hasCover ? `
734
+ <div class="relative overflow-hidden" style="height: 160px;">
735
+ <img src="${item.vod_pic}" alt="${safeName}"
736
+ class="w-full h-full object-cover transition-transform hover:scale-110"
737
+ onerror="this.onerror=null; this.src='https://via.placeholder.com/300x450?text=无封面'; this.classList.add('object-contain');"
738
+ loading="lazy">
739
+ <div class="absolute inset-0 bg-gradient-to-t from-[#111] to-transparent opacity-60"></div>
740
+ </div>` : ''}
741
+
742
+ <div class="p-2 flex flex-col flex-grow">
743
+ <div class="flex-grow">
744
+ <h3 class="text-sm font-semibold mb-1 break-words line-clamp-2 text-center" title="${safeName}">${safeName}</h3>
745
+
746
+ <div class="flex flex-wrap justify-center gap-1 mb-1">
747
+ ${(item.type_name || '').toString().replace(/</g, '&lt;') ?
748
+ `<span class="text-xs py-0 px-1 rounded bg-opacity-20 bg-blue-500 text-blue-300">
749
+ ${(item.type_name || '').toString().replace(/</g, '&lt;')}
750
+ </span>` : ''}
751
+ ${(item.vod_year || '') ?
752
+ `<span class="text-xs py-0 px-1 rounded bg-opacity-20 bg-purple-500 text-purple-300">
753
+ ${item.vod_year}
754
+ </span>` : ''}
755
+ </div>
756
+ <p class="text-gray-400 text-xs line-clamp-1 overflow-hidden text-center">
757
+ ${(item.vod_remarks || '暂无介绍').toString().replace(/</g, '&lt;')}
758
+ </p>
759
+ </div>
760
+
761
+ <div class="flex justify-between items-center mt-1 pt-1 border-t border-gray-800 text-xs">
762
+ ${sourceInfo ? `<div>${sourceInfo}</div>` : '<div></div>'}
763
+ <div>
764
+ <span class="text-xs text-gray-500 flex items-center">
765
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 mr-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
766
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
767
+ </svg>
768
+ 播放
769
+ </span>
770
+ </div>
771
+ </div>
772
+ </div>
773
+ </div>
774
+ </div>
775
+ `;
776
+ }).join('');
777
+ } catch (error) {
778
+ console.error('搜索错误:', error);
779
+ if (error.name === 'AbortError') {
780
+ showToast('搜索请求超时,请检查网络连接', 'error');
781
+ } else {
782
+ showToast('搜索请求失败,请稍后重试', 'error');
783
+ }
784
+ } finally {
785
+ hideLoading();
786
+ }
787
+ }
788
+
789
+ // 显示详情 - 修改为支持自定义API
790
+ async function showDetails(id, vod_name, sourceCode) {
791
+ // 密码保护校验
792
+ if (window.isPasswordProtected && window.isPasswordVerified) {
793
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
794
+ showPasswordModal && showPasswordModal();
795
+ return;
796
+ }
797
+ }
798
+ if (!id) {
799
+ showToast('视频ID无效', 'error');
800
+ return;
801
+ }
802
+
803
+ showLoading();
804
+ try {
805
+ // 构建API参数
806
+ let apiParams = '';
807
+
808
+ // 处理自定义API源
809
+ if (sourceCode.startsWith('custom_')) {
810
+ const customIndex = sourceCode.replace('custom_', '');
811
+ const customApi = getCustomApiInfo(customIndex);
812
+ if (!customApi) {
813
+ showToast('自定义API配置无效', 'error');
814
+ hideLoading();
815
+ return;
816
+ }
817
+
818
+ apiParams = '&customApi=' + encodeURIComponent(customApi.url) + '&source=custom';
819
+ } else {
820
+ // 内置API
821
+ apiParams = '&source=' + sourceCode;
822
+ }
823
+
824
+ const response = await fetch('/api/detail?id=' + encodeURIComponent(id) + apiParams);
825
+
826
+ const data = await response.json();
827
+
828
+ const modal = document.getElementById('modal');
829
+ const modalTitle = document.getElementById('modalTitle');
830
+ const modalContent = document.getElementById('modalContent');
831
+
832
+ // 显示来源信息
833
+ const sourceName = data.videoInfo && data.videoInfo.source_name ?
834
+ ` <span class="text-sm font-normal text-gray-400">(${data.videoInfo.source_name})</span>` : '';
835
+
836
+ // 不对标题进行截断处理,允许完整显示
837
+ modalTitle.innerHTML = `<span class="break-words">${vod_name || '未知视频'}</span>${sourceName}`;
838
+ currentVideoTitle = vod_name || '未知视频';
839
+
840
+ if (data.episodes && data.episodes.length > 0) {
841
+ // 安全处理集数URL
842
+ const safeEpisodes = data.episodes.map(url => {
843
+ try {
844
+ // 确保URL是有效的并且是http或https开头
845
+ return url && (url.startsWith('http://') || url.startsWith('https://'))
846
+ ? url.replace(/"/g, '&quot;')
847
+ : '';
848
+ } catch (e) {
849
+ return '';
850
+ }
851
+ }).filter(url => url); // 过滤掉空URL
852
+
853
+ // 保存当前视频的所有集数
854
+ currentEpisodes = safeEpisodes;
855
+ episodesReversed = false; // 默认正序
856
+ modalContent.innerHTML = `
857
+ <div class="flex justify-end mb-2">
858
+ <button onclick="toggleEpisodeOrder()" class="px-4 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-semibold rounded-full shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 flex items-center justify-center space-x-2">
859
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
860
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clip-rule="evenodd" />
861
+ </svg>
862
+ <span>倒序排列</span>
863
+ </button>
864
+ </div>
865
+ <div id="episodesGrid" class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
866
+ ${renderEpisodes(vod_name)}
867
+ </div>
868
+ `;
869
+ } else {
870
+ modalContent.innerHTML = '<p class="text-center text-gray-400 py-8">没有找到可播放的视频</p>';
871
+ }
872
+
873
+ modal.classList.remove('hidden');
874
+ } catch (error) {
875
+ console.error('获取详情错误:', error);
876
+ showToast('获取详情失败,请稍后重试', 'error');
877
+ } finally {
878
+ hideLoading();
879
+ }
880
+ }
881
+
882
+ // 更新播放视频函数,修改为在新标签页中打开播放页面,并保存到历史记录
883
+ function playVideo(url, vod_name, episodeIndex = 0) {
884
+ // 密码保护校验
885
+ if (window.isPasswordProtected && window.isPasswordVerified) {
886
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
887
+ showPasswordModal && showPasswordModal();
888
+ return;
889
+ }
890
+ }
891
+ if (!url) {
892
+ showToast('无效的视频链接', 'error');
893
+ return;
894
+ }
895
+
896
+ // 获取当前视频来源名称(从模态框标题中提取)
897
+ let sourceName = '';
898
+ const modalTitle = document.getElementById('modalTitle');
899
+ if (modalTitle) {
900
+ const sourceSpan = modalTitle.querySelector('span.text-gray-400');
901
+ if (sourceSpan) {
902
+ // 提取括号内的来源名称, 例如从 "(黑木耳)" 提取 "黑木耳"
903
+ const sourceText = sourceSpan.textContent;
904
+ const match = sourceText.match(/\(([^)]+)\)/);
905
+ if (match && match[1]) {
906
+ sourceName = match[1].trim();
907
+ }
908
+ }
909
+ }
910
+
911
+ // 保存当前状态到localStorage,让播放页面可以获取
912
+ const currentVideoTitle = vod_name;
913
+ localStorage.setItem('currentVideoTitle', currentVideoTitle);
914
+ localStorage.setItem('currentEpisodeIndex', episodeIndex);
915
+ localStorage.setItem('currentEpisodes', JSON.stringify(currentEpisodes));
916
+ localStorage.setItem('episodesReversed', episodesReversed);
917
+
918
+ // 构建视频信息对象,使用标题作为唯一标识
919
+ const videoTitle = vod_name || currentVideoTitle;
920
+ const videoInfo = {
921
+ title: videoTitle,
922
+ url: url,
923
+ episodeIndex: episodeIndex,
924
+ sourceName: sourceName,
925
+ timestamp: Date.now(),
926
+ // 重要:将完整的剧集信息也添加到历史记录中
927
+ episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : []
928
+ };
929
+
930
+ // 保存到观看历史,添加sourceName
931
+ if (typeof addToViewingHistory === 'function') {
932
+ addToViewingHistory(videoInfo);
933
+ }
934
+
935
+ // 构建播放页面URL,传递必要参数
936
+ const playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(videoTitle)}&index=${episodeIndex}&source=${encodeURIComponent(sourceName)}`;
937
+
938
+ // 在新标签页中打开播放页面
939
+ window.open(playerUrl, '_blank');
940
+ }
941
+
942
+ // 播放上一集
943
+ function playPreviousEpisode() {
944
+ if (currentEpisodeIndex > 0) {
945
+ const prevIndex = currentEpisodeIndex - 1;
946
+ const prevUrl = currentEpisodes[prevIndex];
947
+ playVideo(prevUrl, currentVideoTitle, prevIndex);
948
+ }
949
+ }
950
+
951
+ // 播放下一集
952
+ function playNextEpisode() {
953
+ if (currentEpisodeIndex < currentEpisodes.length - 1) {
954
+ const nextIndex = currentEpisodeIndex + 1;
955
+ const nextUrl = currentEpisodes[nextIndex];
956
+ playVideo(nextUrl, currentVideoTitle, nextIndex);
957
+ }
958
+ }
959
+
960
+ // 处理播放器加载错误
961
+ function handlePlayerError() {
962
+ hideLoading();
963
+ showToast('视频播放加载失败,请尝试其他视频源', 'error');
964
+ }
965
+
966
+ // 辅助函数用于渲染剧集按钮(使用当前的排序状态)
967
+ function renderEpisodes(vodName) {
968
+ const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;
969
+ return episodes.map((episode, index) => {
970
+ // 根据倒序状态计算真实的剧集索引
971
+ const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;
972
+ return `
973
+ <button id="episode-${realIndex}" onclick="playVideo('${episode}','${vodName.replace(/"/g, '&quot;')}', ${realIndex})"
974
+ class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors text-center episode-btn">
975
+ 第${realIndex + 1}集
976
+ </button>
977
+ `;
978
+ }).join('');
979
+ }
980
+
981
+ // 切换排序状态的函数
982
+ function toggleEpisodeOrder() {
983
+ episodesReversed = !episodesReversed;
984
+ // 重新渲染剧集区域,使用 currentVideoTitle 作为视频标题
985
+ const episodesGrid = document.getElementById('episodesGrid');
986
+ if (episodesGrid) {
987
+ episodesGrid.innerHTML = renderEpisodes(currentVideoTitle);
988
+ }
989
+
990
+ // 更新按钮文本和箭头方向
991
+ const toggleBtn = document.querySelector('button[onclick="toggleEpisodeOrder()"]');
992
+ if (toggleBtn) {
993
+ toggleBtn.querySelector('span').textContent = episodesReversed ? '正序排列' : '倒序排列';
994
+ const arrowIcon = toggleBtn.querySelector('svg');
995
+ if (arrowIcon) {
996
+ arrowIcon.style.transform = episodesReversed ? 'rotate(180deg)' : 'rotate(0deg)';
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // app.js 或路由文件中
1002
+ const authMiddleware = require('./middleware/auth');
1003
+ const config = require('./config');
1004
+
1005
+ // 对所有请求启用鉴权(按需调整作用范围)
1006
+ if (config.auth.enabled) {
1007
+ app.use(authMiddleware);
1008
+ }
1009
+
1010
+ // 或者针对特定路由
1011
+ app.use('/api', authMiddleware);
js/config.js ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 全局常量配置
2
+ const PROXY_URL = '/proxy/'; // 适用于 Cloudflare, Netlify (带重写), Vercel (带重写)
3
+ // const HOPLAYER_URL = 'https://hoplayer.com/index.html';
4
+ const SEARCH_HISTORY_KEY = 'videoSearchHistory';
5
+ const MAX_HISTORY_ITEMS = 5;
6
+
7
+ // 密码保护配置
8
+ const PASSWORD_CONFIG = {
9
+ localStorageKey: 'passwordVerified', // 存储验证状态的键名
10
+ verificationTTL: 90 * 24 * 60 * 60 * 1000, // 验证有效期(90天,约3个月)
11
+ };
12
+
13
+ // 网站信息配置
14
+ const SITE_CONFIG = {
15
+ name: 'LibreTV',
16
+ url: 'https://libretv.is-an.org',
17
+ description: '免费在线视频搜索与观看平台',
18
+ logo: 'https://images.icon-icons.com/38/PNG/512/retrotv_5520.png',
19
+ version: '1.0.3'
20
+ };
21
+
22
+ // API站点配置
23
+ const API_SITES = {
24
+ heimuer: {
25
+ api: 'https://json.heimuer.xyz',
26
+ name: '黑木耳',
27
+ detail: 'https://heimuer.tv'
28
+ },
29
+ ffzy: {
30
+ api: 'http://ffzy5.tv',
31
+ name: '非凡影视',
32
+ detail: 'http://ffzy5.tv'
33
+ },
34
+ tyyszy: {
35
+ api: 'https://tyyszy.com',
36
+ name: '天涯资源',
37
+ },
38
+ ckzy: {
39
+ api: 'https://www.ckzy1.com',
40
+ name: 'CK资源',
41
+ adult: true
42
+ },
43
+ zy360: {
44
+ api: 'https://360zy.com',
45
+ name: '360资源',
46
+ },
47
+ wolong: {
48
+ api: 'https://wolongzyw.com',
49
+ name: '卧龙资源',
50
+ },
51
+ cjhw: {
52
+ api: 'https://cjhwba.com',
53
+ name: '新华为',
54
+ },
55
+ hwba: {
56
+ api: 'https://cjwba.com',
57
+ name: '华为吧资源',
58
+ },
59
+ jisu: {
60
+ api: 'https://jszyapi.com',
61
+ name: '极速资源',
62
+ detail: 'https://jszyapi.com'
63
+ },
64
+ dbzy: {
65
+ api: 'https://dbzy.com',
66
+ name: '豆瓣资源',
67
+ },
68
+ bfzy: {
69
+ api: 'https://bfzyapi.com',
70
+ name: '暴风资源',
71
+ },
72
+ mozhua: {
73
+ api: 'https://mozhuazy.com',
74
+ name: '魔爪资源',
75
+ },
76
+ mdzy: {
77
+ api: 'https://www.mdzyapi.com',
78
+ name: '魔都资源',
79
+ },
80
+ ruyi: {
81
+ api: 'https://cj.rycjapi.com',
82
+ name: '如意资源',
83
+ },
84
+ jkun: {
85
+ api: 'https://jkunzyapi.com',
86
+ name: 'jkun资源',
87
+ adult: true
88
+ },
89
+ bwzy: {
90
+ api: 'https://api.bwzym3u8.com',
91
+ name: '百万资源',
92
+ adult: true
93
+ },
94
+ souav: {
95
+ api: 'https://api.souavzy.vip',
96
+ name: 'souav资源',
97
+ adult: true
98
+ },
99
+ r155: {
100
+ api: 'https://155api.com',
101
+ name: '155资源',
102
+ adult: true
103
+ },
104
+ lsb: {
105
+ api: 'https://apilsbzy1.com',
106
+ name: 'lsb资源',
107
+ adult: true
108
+ },
109
+ huangcang: {
110
+ api: 'https://hsckzy.vip',
111
+ name: '黄色仓库',
112
+ adult: true,
113
+ detail: 'https://hsckzy.vip'
114
+ },
115
+ zuid: {
116
+ api: 'https://api.zuidapi.com',
117
+ name: '最大资源'
118
+ },
119
+ yutu: {
120
+ api: 'https://yutuzy10.com',
121
+ name: '玉兔资源',
122
+ adult: true
123
+ }
124
+ // 您可以按需添加更多源
125
+ };
126
+
127
+ // 添加聚合搜索的配置选项
128
+ const AGGREGATED_SEARCH_CONFIG = {
129
+ enabled: true, // 是否启用聚合搜索
130
+ timeout: 8000, // 单个源超时时间(毫秒)
131
+ maxResults: 10000, // 最大结果数量
132
+ parallelRequests: true, // 是否并行请求所有源
133
+ showSourceBadges: true // 是否显示来源徽章
134
+ };
135
+
136
+ // 抽象API请求配置
137
+ const API_CONFIG = {
138
+ search: {
139
+ // 修改搜索接口为返回更多详细数据(包括视频封面、简介和播放列表)
140
+ path: '/api.php/provide/vod/?ac=videolist&wd=',
141
+ headers: {
142
+ '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',
143
+ 'Accept': 'application/json'
144
+ }
145
+ },
146
+ detail: {
147
+ // 修改详情接口也使用videolist接口,但是通过ID查询,减少请求次数
148
+ path: '/api.php/provide/vod/?ac=videolist&ids=',
149
+ headers: {
150
+ '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',
151
+ 'Accept': 'application/json'
152
+ }
153
+ }
154
+ };
155
+
156
+ // 优化后的正则表达式模式
157
+ const M3U8_PATTERN = /\$https?:\/\/[^"'\s]+?\.m3u8/g;
158
+
159
+ // 添加自定义播放器URL
160
+ const CUSTOM_PLAYER_URL = 'player.html'; // 使用相对路径引用本地player.html
161
+
162
+ // 增加视频播放相关配置
163
+ const PLAYER_CONFIG = {
164
+ autoplay: true,
165
+ allowFullscreen: true,
166
+ width: '100%',
167
+ height: '600',
168
+ timeout: 15000, // 播放器加载超时时间
169
+ filterAds: true, // 是否启用广告过滤
170
+ autoPlayNext: true, // 默认启用自动连播功能
171
+ adFilteringEnabled: true, // 默认开启分片广告过滤
172
+ adFilteringStorage: 'adFilteringEnabled' // 存储广告过滤设置的键名
173
+ };
174
+
175
+ // 增加错误信息本地化
176
+ const ERROR_MESSAGES = {
177
+ NETWORK_ERROR: '网络连接错误,请检查网络设置',
178
+ TIMEOUT_ERROR: '请求超时,服务器响应时间过长',
179
+ API_ERROR: 'API接口返回错误,请尝试更换数据源',
180
+ PLAYER_ERROR: '播放器加载失败,请尝试其他视频源',
181
+ UNKNOWN_ERROR: '发生未知错误,请刷新页面重试'
182
+ };
183
+
184
+ // 添加进一步安全设置
185
+ const SECURITY_CONFIG = {
186
+ enableXSSProtection: true, // 是否启用XSS保护
187
+ sanitizeUrls: true, // 是否清理URL
188
+ maxQueryLength: 100, // 最大搜索长度
189
+ // allowedApiDomains 不再需要,因为所有请求都通过内部代理
190
+ };
191
+
192
+ // 添加多个自定义API源的配置
193
+ const CUSTOM_API_CONFIG = {
194
+ separator: ',', // 分隔符
195
+ maxSources: 5, // 最大允许的自定义源数量
196
+ testTimeout: 5000, // 测试超时时间(毫秒)
197
+ namePrefix: 'Custom-', // 自定义源名称前缀
198
+ validateUrl: true, // 验证URL格式
199
+ cacheResults: true, // 缓存测试结果
200
+ cacheExpiry: 5184000000, // 缓存过期时间(2个月)
201
+ adultPropName: 'isAdult' // 用于标记成人内容的属性名
202
+ };
203
+
204
+ // 新增隐藏内置黄色采集站API的变量,默认为true
205
+ const HIDE_BUILTIN_ADULT_APIS = true;
js/douban.js ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 豆瓣热门电影电视剧推荐功能
2
+
3
+ // 豆瓣标签列表
4
+ const doubanTags = ['热门', '最新', '经典', '豆瓣高分','古装剧', '冷门佳片', '科幻' ,'喜剧', '综艺', '欧美', '电视剧', '韩国', '日本', '动漫','动画片'];
5
+ let doubanCurrentTag = localStorage.getItem('doubanCurrentTag') || '热门';
6
+ let doubanPageStart = 0;
7
+ const doubanPageSize = 16; // 一次显示的项目数量
8
+
9
+ // 初始化豆瓣功能
10
+ function initDouban() {
11
+ // 设置豆瓣开关的初始状态
12
+ const doubanToggle = document.getElementById('doubanToggle');
13
+ if (doubanToggle) {
14
+ const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
15
+ doubanToggle.checked = isEnabled;
16
+
17
+ // 设置开关外观
18
+ const toggleBg = doubanToggle.nextElementSibling;
19
+ const toggleDot = toggleBg.nextElementSibling;
20
+ if (isEnabled) {
21
+ toggleBg.classList.add('bg-pink-600');
22
+ toggleDot.classList.add('translate-x-6');
23
+ }
24
+
25
+ // 添加事件监听
26
+ doubanToggle.addEventListener('change', function(e) {
27
+ const isChecked = e.target.checked;
28
+ localStorage.setItem('doubanEnabled', isChecked);
29
+
30
+ // 更新开关外观
31
+ if (isChecked) {
32
+ toggleBg.classList.add('bg-pink-600');
33
+ toggleDot.classList.add('translate-x-6');
34
+ } else {
35
+ toggleBg.classList.remove('bg-pink-600');
36
+ toggleDot.classList.remove('translate-x-6');
37
+ }
38
+
39
+ // 更新显示状态
40
+ updateDoubanVisibility();
41
+ });
42
+
43
+ // 初始更新显示状态
44
+ updateDoubanVisibility();
45
+ }
46
+
47
+ // 渲染豆瓣标签
48
+ renderDoubanTags();
49
+
50
+ // 换一批按钮事件监听
51
+ setupDoubanRefreshBtn();
52
+
53
+ // 初始加载热门内容
54
+ if (localStorage.getItem('doubanEnabled') === 'true') {
55
+ renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
56
+ }
57
+ }
58
+
59
+ // 根据设置更新豆瓣区域的显示状态
60
+ function updateDoubanVisibility() {
61
+ const doubanArea = document.getElementById('doubanArea');
62
+ if (!doubanArea) return;
63
+
64
+ const isEnabled = localStorage.getItem('doubanEnabled') === 'true';
65
+ const isSearching = document.getElementById('resultsArea') &&
66
+ !document.getElementById('resultsArea').classList.contains('hidden');
67
+
68
+ // 只有在启用且没有搜索结果显示时才显示豆瓣区域
69
+ if (isEnabled && !isSearching) {
70
+ doubanArea.classList.remove('hidden');
71
+ // 如果豆瓣结果为空,重新加载
72
+ if (document.getElementById('douban-results').children.length === 0) {
73
+ renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
74
+ }
75
+ } else {
76
+ doubanArea.classList.add('hidden');
77
+ }
78
+ }
79
+
80
+ // 只填充搜索框,不执行搜索,让用户自主决定搜索时机
81
+ function fillSearchInput(title) {
82
+ if (!title) return;
83
+
84
+ // 安全处理标题,防止XSS
85
+ const safeTitle = title
86
+ .replace(/</g, '&lt;')
87
+ .replace(/>/g, '&gt;')
88
+ .replace(/"/g, '&quot;');
89
+
90
+ const input = document.getElementById('searchInput');
91
+ if (input) {
92
+ input.value = safeTitle;
93
+
94
+ // 聚焦搜索框,便于用户立即使用键盘操作
95
+ input.focus();
96
+
97
+ // 显示一个提示,告知用户点击搜索按钮进行搜索
98
+ showToast('已填充搜索内容,点击搜索按钮开始搜索', 'info');
99
+ }
100
+ }
101
+
102
+ // 填充搜索框并执行搜索
103
+ function fillAndSearch(title) {
104
+ if (!title) return;
105
+
106
+ // 安全处理标题,防止XSS
107
+ const safeTitle = title
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;');
111
+
112
+ const input = document.getElementById('searchInput');
113
+ if (input) {
114
+ input.value = safeTitle;
115
+ search(); // 使用已有的search函数执行搜索
116
+ }
117
+ }
118
+
119
+ // 填充搜索框,确保豆瓣资源API被选中,然后执行搜索
120
+ function fillAndSearchWithDouban(title) {
121
+ if (!title) return;
122
+
123
+ // 安全处理标题,防止XSS
124
+ const safeTitle = title
125
+ .replace(/</g, '&lt;')
126
+ .replace(/>/g, '&gt;')
127
+ .replace(/"/g, '&quot;');
128
+
129
+ // 确保豆瓣资源API被选中
130
+ if (typeof selectedAPIs !== 'undefined' && !selectedAPIs.includes('dbzy')) {
131
+ // 在设置中勾选豆瓣资源API复选框
132
+ const doubanCheckbox = document.querySelector('input[id="api_dbzy"]');
133
+ if (doubanCheckbox) {
134
+ doubanCheckbox.checked = true;
135
+
136
+ // 触发updateSelectedAPIs函数以更新状态
137
+ if (typeof updateSelectedAPIs === 'function') {
138
+ updateSelectedAPIs();
139
+ } else {
140
+ // 如果函数不可用,则手动添加到selectedAPIs
141
+ selectedAPIs.push('dbzy');
142
+ localStorage.setItem('selectedAPIs', JSON.stringify(selectedAPIs));
143
+
144
+ // 更新选中API计数(如果有这个元素)
145
+ const countEl = document.getElementById('selectedAPICount');
146
+ if (countEl) {
147
+ countEl.textContent = selectedAPIs.length;
148
+ }
149
+ }
150
+
151
+ showToast('已自动选择豆瓣资源API', 'info');
152
+ }
153
+ }
154
+
155
+ // 填充搜索框并执行搜索
156
+ const input = document.getElementById('searchInput');
157
+ if (input) {
158
+ input.value = safeTitle;
159
+ search(); // 使用已有的search函数执行搜索
160
+ }
161
+ }
162
+
163
+ // 渲染豆瓣标签选择器
164
+ function renderDoubanTags() {
165
+ const tagContainer = document.getElementById('douban-tags');
166
+ if (!tagContainer) return;
167
+
168
+ tagContainer.innerHTML = '';
169
+
170
+ doubanTags.forEach(tag => {
171
+ const btn = document.createElement('button');
172
+ // 更新标签样式:统一高度,添加过渡效果,改进颜色对比度
173
+ btn.className = 'py-1.5 px-3.5 rounded text-sm font-medium transition-all duration-300 ' +
174
+ (tag === doubanCurrentTag ?
175
+ 'bg-pink-600 text-white shadow-md' :
176
+ 'bg-[#1a1a1a] text-gray-300 hover:bg-pink-700 hover:text-white');
177
+
178
+ btn.textContent = tag;
179
+
180
+ btn.onclick = function() {
181
+ if (doubanCurrentTag !== tag) {
182
+ doubanCurrentTag = tag;
183
+ localStorage.setItem('doubanCurrentTag', tag);
184
+ doubanPageStart = 0;
185
+ renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
186
+ renderDoubanTags();
187
+ }
188
+ };
189
+
190
+ tagContainer.appendChild(btn);
191
+ });
192
+ }
193
+
194
+ // 设置换一批按钮事件
195
+ function setupDoubanRefreshBtn() {
196
+ // 修复ID,使用正确的ID douban-refresh 而不是 douban-refresh-btn
197
+ const btn = document.getElementById('douban-refresh');
198
+ if (!btn) return;
199
+
200
+ btn.onclick = function() {
201
+ doubanPageStart += doubanPageSize;
202
+ if (doubanPageStart > 9 * doubanPageSize) {
203
+ doubanPageStart = 0;
204
+ }
205
+
206
+ renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart);
207
+ };
208
+ }
209
+
210
+ // 渲染热门推荐内容
211
+ function renderRecommend(tag, pageLimit, pageStart) {
212
+ const container = document.getElementById("douban-results");
213
+ if (!container) return;
214
+
215
+ // 显示加载状态
216
+ container.innerHTML = `
217
+ <div class="col-span-full text-center py-10">
218
+ <div class="w-6 h-6 border-2 border-pink-500 border-t-transparent rounded-full animate-spin mr-2 inline-block"></div>
219
+ <span class="text-pink-500">加载中...</span>
220
+ </div>
221
+ `;
222
+
223
+ const target = `https://movie.douban.com/j/search_subjects?type=movie&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
224
+
225
+ // 添加超时控制
226
+ const controller = new AbortController();
227
+ const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
228
+
229
+ // 设置请求选项,包括信号和头部
230
+ const fetchOptions = {
231
+ signal: controller.signal,
232
+ headers: {
233
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36',
234
+ 'Referer': 'https://movie.douban.com/',
235
+ 'Accept': 'application/json, text/plain, */*',
236
+ }
237
+ };
238
+
239
+ // 尝试直接访问(豆瓣API可能允许部分CORS请求)
240
+ fetch(PROXY_URL + encodeURIComponent(target), fetchOptions)
241
+ .then(response => {
242
+ clearTimeout(timeoutId);
243
+ if (!response.ok) {
244
+ throw new Error(`HTTP error! Status: ${response.status}`);
245
+ }
246
+ return response.json();
247
+ })
248
+ .then(data => {
249
+ renderDoubanCards(data, container);
250
+ })
251
+ .catch(err => {
252
+ console.error("豆瓣 API 请求失败(直接代理):", err);
253
+
254
+ // 失败后尝试备用方法:作为备选
255
+ const fallbackUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(target)}`;
256
+
257
+ fetch(fallbackUrl)
258
+ .then(response => {
259
+ if (!response.ok) throw new Error(`备用API请求失败! 状态: ${response.status}`);
260
+ return response.json();
261
+ })
262
+ .then(data => {
263
+ // 解析原始内容
264
+ if (data && data.contents) {
265
+ const doubanData = JSON.parse(data.contents);
266
+ renderDoubanCards(doubanData, container);
267
+ } else {
268
+ throw new Error("无法获取有效数据");
269
+ }
270
+ })
271
+ .catch(fallbackErr => {
272
+ console.error("��瓣 API 备用请求也失败:", fallbackErr);
273
+ container.innerHTML = `
274
+ <div class="col-span-full text-center py-8">
275
+ <div class="text-red-400">❌ 获取豆瓣数据失败,请稍后重试</div>
276
+ <div class="text-gray-500 text-sm mt-2">提示:使用VPN可能有助于解决此问题</div>
277
+ </div>
278
+ `;
279
+ });
280
+ });
281
+ }
282
+
283
+ // 抽取渲染豆瓣卡片的逻辑到单独函数
284
+ function renderDoubanCards(data, container) {
285
+ // 创建文档片段以提高性能
286
+ const fragment = document.createDocumentFragment();
287
+
288
+ // 如果没有数据
289
+ if (!data.subjects || data.subjects.length === 0) {
290
+ const emptyEl = document.createElement("div");
291
+ emptyEl.className = "col-span-full text-center py-8";
292
+ emptyEl.innerHTML = `
293
+ <div class="text-pink-500">❌ 暂无数据,请尝试其他分类或刷新</div>
294
+ `;
295
+ fragment.appendChild(emptyEl);
296
+ } else {
297
+ // 循环创建每个影视卡片
298
+ data.subjects.forEach(item => {
299
+ const card = document.createElement("div");
300
+ card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg";
301
+
302
+ // 生成卡片内容,确保安全显示(防止XSS)
303
+ const safeTitle = item.title
304
+ .replace(/</g, '&lt;')
305
+ .replace(/>/g, '&gt;')
306
+ .replace(/"/g, '&quot;');
307
+
308
+ const safeRate = (item.rate || "暂无")
309
+ .replace(/</g, '&lt;')
310
+ .replace(/>/g, '&gt;');
311
+
312
+ // 处理图片URL
313
+ // 1. 直接使用豆瓣图片URL (添加no-referrer属性)
314
+ const originalCoverUrl = item.cover;
315
+
316
+ // 2. 也准备代理URL作为备选
317
+ const proxiedCoverUrl = PROXY_URL + encodeURIComponent(originalCoverUrl);
318
+
319
+ // 为不同设备优化卡片布局
320
+ card.innerHTML = `
321
+ <div class="relative w-full aspect-[2/3] overflow-hidden cursor-pointer" onclick="fillAndSearchWithDouban('${safeTitle}')">
322
+ <img src="${originalCoverUrl}" alt="${safeTitle}"
323
+ class="w-full h-full object-cover transition-transform duration-500 hover:scale-110"
324
+ onerror="this.onerror=null; this.src='${proxiedCoverUrl}'; this.classList.add('object-contain');"
325
+ loading="lazy" referrerpolicy="no-referrer">
326
+ <div class="absolute inset-0 bg-gradient-to-t from-black to-transparent opacity-60"></div>
327
+ <div class="absolute bottom-2 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded-sm">
328
+ <span class="text-yellow-400">★</span> ${safeRate}
329
+ </div>
330
+ <div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded-sm hover:bg-[#333] transition-colors">
331
+ <a href="${item.url}" target="_blank" rel="noopener noreferrer" title="在豆瓣查看">
332
+ 🔗
333
+ </a>
334
+ </div>
335
+ </div>
336
+ <div class="p-2 text-center bg-[#111]">
337
+ <button onclick="fillAndSearchWithDouban('${safeTitle}')"
338
+ class="text-sm font-medium text-white truncate w-full hover:text-pink-400 transition"
339
+ title="${safeTitle}">
340
+ ${safeTitle}
341
+ </button>
342
+ </div>
343
+ `;
344
+
345
+ fragment.appendChild(card);
346
+ });
347
+ }
348
+
349
+ // 清空并添加所有新元素
350
+ container.innerHTML = "";
351
+ container.appendChild(fragment);
352
+ }
353
+
354
+ // 重置到首页
355
+ function resetToHome() {
356
+ resetSearchArea();
357
+ updateDoubanVisibility();
358
+ }
359
+
360
+ // 加载豆瓣首页内容
361
+ document.addEventListener('DOMContentLoaded', initDouban);
js/password.js ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // 密码保护功能
2
+
3
+ /**
4
+ * 检查是否设置了密码保护
5
+ * 通过读取页面上嵌入的环境变量来检查
6
+ */
7
+ function isPasswordProtected() {
8
+ // 检查页面上嵌入的环境变量
9
+ const pwd = window.__ENV__ && window.__ENV__.PASSWORD;
10
+ // 只有当密码 hash 存在且为64位(SHA-256十六进制长度)才认为启用密码保护
11
+ return typeof pwd === 'string' && pwd.length === 64 && !/^0+$/.test(pwd);
12
+ }
13
+
14
+ /**
15
+ * 检查用户是否已通过密码验证
16
+ * 检查localStorage中的验证状态和时间戳是否有效,并确认密码哈希未更改
17
+ */
18
+ function isPasswordVerified() {
19
+ try {
20
+ // 如果没有设置密码保护,则视为已验证
21
+ if (!isPasswordProtected()) {
22
+ return true;
23
+ }
24
+
25
+ const verificationData = JSON.parse(localStorage.getItem(PASSWORD_CONFIG.localStorageKey) || '{}');
26
+ const { verified, timestamp, passwordHash } = verificationData;
27
+
28
+ // 获取当前环境中的密码哈希
29
+ const currentHash = window.__ENV__ && window.__ENV__.PASSWORD;
30
+
31
+ // 验证是否已验证、未过期,且密码哈希未更改
32
+ if (verified && timestamp && passwordHash === currentHash) {
33
+ const now = Date.now();
34
+ const expiry = timestamp + PASSWORD_CONFIG.verificationTTL;
35
+ return now < expiry;
36
+ }
37
+
38
+ return false;
39
+ } catch (error) {
40
+ console.error('验证密码状态时出错:', error);
41
+ return false;
42
+ }
43
+ }
44
+
45
+ window.isPasswordProtected = isPasswordProtected;
46
+ window.isPasswordVerified = isPasswordVerified;
47
+
48
+ /**
49
+ * 验证用户输入的密码是否正确(异步,使用SHA-256哈希)
50
+ */
51
+ async function verifyPassword(password) {
52
+ const correctHash = window.__ENV__ && window.__ENV__.PASSWORD;
53
+ if (!correctHash) return false;
54
+ const inputHash = await sha256(password);
55
+ const isValid = inputHash === correctHash;
56
+ if (isValid) {
57
+ const verificationData = {
58
+ verified: true,
59
+ timestamp: Date.now(),
60
+ passwordHash: correctHash // 保存当前密码的哈希值
61
+ };
62
+ localStorage.setItem(PASSWORD_CONFIG.localStorageKey, JSON.stringify(verificationData));
63
+ }
64
+ return isValid;
65
+ }
66
+
67
+ // SHA-256实现,可用Web Crypto API
68
+ async function sha256(message) {
69
+ if (window.crypto && crypto.subtle && crypto.subtle.digest) {
70
+ const msgBuffer = new TextEncoder().encode(message);
71
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
72
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
73
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
74
+ }
75
+ // HTTP 下调用原始 js‑sha256
76
+ if (typeof window._jsSha256 === 'function') {
77
+ return window._jsSha256(message);
78
+ }
79
+ throw new Error('No SHA-256 implementation available.');
80
+ }
81
+
82
+ /**
83
+ * 显示密码验证弹窗
84
+ */
85
+ function showPasswordModal() {
86
+ const passwordModal = document.getElementById('passwordModal');
87
+ if (passwordModal) {
88
+ passwordModal.style.display = 'flex';
89
+
90
+ // 确保输入框获取焦点
91
+ setTimeout(() => {
92
+ const passwordInput = document.getElementById('passwordInput');
93
+ if (passwordInput) {
94
+ passwordInput.focus();
95
+ }
96
+ }, 100);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * 隐藏密码验证弹窗
102
+ */
103
+ function hidePasswordModal() {
104
+ const passwordModal = document.getElementById('passwordModal');
105
+ if (passwordModal) {
106
+ passwordModal.style.display = 'none';
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 显示密码错误信息
112
+ */
113
+ function showPasswordError() {
114
+ const errorElement = document.getElementById('passwordError');
115
+ if (errorElement) {
116
+ errorElement.classList.remove('hidden');
117
+ }
118
+ }
119
+
120
+ /**
121
+ * 隐藏密码错误信息
122
+ */
123
+ function hidePasswordError() {
124
+ const errorElement = document.getElementById('passwordError');
125
+ if (errorElement) {
126
+ errorElement.classList.add('hidden');
127
+ }
128
+ }
129
+
130
+ /**
131
+ * 处理密码提交事件(异步)
132
+ */
133
+ async function handlePasswordSubmit() {
134
+ const passwordInput = document.getElementById('passwordInput');
135
+ const password = passwordInput ? passwordInput.value.trim() : '';
136
+ if (await verifyPassword(password)) {
137
+ hidePasswordError();
138
+ hidePasswordModal();
139
+ } else {
140
+ showPasswordError();
141
+ if (passwordInput) {
142
+ passwordInput.value = '';
143
+ passwordInput.focus();
144
+ }
145
+ }
146
+ }
147
+
148
+ /**
149
+ * 初始化密码验证系统(需适配异步事件)
150
+ */
151
+ function initPasswordProtection() {
152
+ if (!isPasswordProtected()) {
153
+ return; // 如果未设置密码保护,则不进行任何操作
154
+ }
155
+
156
+ // 如果未验证密码,则显示密码验证弹窗
157
+ if (!isPasswordVerified()) {
158
+ showPasswordModal();
159
+
160
+ // 设置密码提交按钮事件监听
161
+ const submitButton = document.getElementById('passwordSubmitBtn');
162
+ if (submitButton) {
163
+ submitButton.addEventListener('click', handlePasswordSubmit);
164
+ }
165
+
166
+ // 设置密码输入框回车键监听
167
+ const passwordInput = document.getElementById('passwordInput');
168
+ if (passwordInput) {
169
+ passwordInput.addEventListener('keypress', function(e) {
170
+ if (e.key === 'Enter') {
171
+ handlePasswordSubmit();
172
+ }
173
+ });
174
+ }
175
+ }
176
+ }
177
+
178
+ // 在页面加载完成后初始化密码保护
179
+ document.addEventListener('DOMContentLoaded', initPasswordProtection);
js/sha256.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export async function sha256(message) {
2
+ const msgBuffer = new TextEncoder().encode(message);
3
+ const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
4
+ const hashArray = Array.from(new Uint8Array(hashBuffer));
5
+ return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
6
+ }
js/ui.js ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // UI相关函数
2
+ function toggleSettings(e) {
3
+ // 密码保护校验
4
+ if (window.isPasswordProtected && window.isPasswordVerified) {
5
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
6
+ showPasswordModal && showPasswordModal();
7
+ return;
8
+ }
9
+ }
10
+ // 阻止事件冒泡,防止触发document的点击事件
11
+ e && e.stopPropagation();
12
+ const panel = document.getElementById('settingsPanel');
13
+ panel.classList.toggle('show');
14
+ }
15
+
16
+ // 改进的Toast显示函数 - 支持队列显示多个Toast
17
+ const toastQueue = [];
18
+ let isShowingToast = false;
19
+
20
+ function showToast(message, type = 'error') {
21
+ // 将新的toast添加到队列
22
+ toastQueue.push({ message, type });
23
+
24
+ // 如果当前没有显示中的toast,则开始显示
25
+ if (!isShowingToast) {
26
+ showNextToast();
27
+ }
28
+ }
29
+
30
+ function showNextToast() {
31
+ if (toastQueue.length === 0) {
32
+ isShowingToast = false;
33
+ return;
34
+ }
35
+
36
+ isShowingToast = true;
37
+ const { message, type } = toastQueue.shift();
38
+
39
+ const toast = document.getElementById('toast');
40
+ const toastMessage = document.getElementById('toastMessage');
41
+
42
+ const bgColors = {
43
+ 'error': 'bg-red-500',
44
+ 'success': 'bg-green-500',
45
+ 'info': 'bg-blue-500',
46
+ 'warning': 'bg-yellow-500'
47
+ };
48
+
49
+ const bgColor = bgColors[type] || bgColors.error;
50
+ toast.className = `fixed top-4 left-1/2 -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg transform transition-all duration-300 ${bgColor} text-white`;
51
+ toastMessage.textContent = message;
52
+
53
+ // 显示提示
54
+ toast.style.opacity = '1';
55
+ toast.style.transform = 'translateX(-50%) translateY(0)';
56
+
57
+ // 3秒后自动隐藏
58
+ setTimeout(() => {
59
+ toast.style.opacity = '0';
60
+ toast.style.transform = 'translateX(-50%) translateY(-100%)';
61
+
62
+ // 等待动画完成后显示下一个toast
63
+ setTimeout(() => {
64
+ showNextToast();
65
+ }, 300);
66
+ }, 3000);
67
+ }
68
+
69
+ // 添加显示/隐藏 loading 的函数
70
+ let loadingTimeoutId = null;
71
+
72
+ function showLoading(message = '加载中...') {
73
+ // 清除任何现有的超时
74
+ if (loadingTimeoutId) {
75
+ clearTimeout(loadingTimeoutId);
76
+ }
77
+
78
+ const loading = document.getElementById('loading');
79
+ const messageEl = loading.querySelector('p');
80
+ messageEl.textContent = message;
81
+ loading.style.display = 'flex';
82
+
83
+ // 设置30秒后自动关闭loading,防止无限loading
84
+ loadingTimeoutId = setTimeout(() => {
85
+ hideLoading();
86
+ showToast('操作超时,请稍后重试', 'warning');
87
+ }, 30000);
88
+ }
89
+
90
+ function hideLoading() {
91
+ // 清除超时
92
+ if (loadingTimeoutId) {
93
+ clearTimeout(loadingTimeoutId);
94
+ loadingTimeoutId = null;
95
+ }
96
+
97
+ const loading = document.getElementById('loading');
98
+ loading.style.display = 'none';
99
+ }
100
+
101
+ function updateSiteStatus(isAvailable) {
102
+ const statusEl = document.getElementById('siteStatus');
103
+ if (isAvailable) {
104
+ statusEl.innerHTML = '<span class="text-green-500">●</span> 可用';
105
+ } else {
106
+ statusEl.innerHTML = '<span class="text-red-500">●</span> 不可用';
107
+ }
108
+ }
109
+
110
+ function closeModal() {
111
+ document.getElementById('modal').classList.add('hidden');
112
+ // 清除 iframe 内容
113
+ document.getElementById('modalContent').innerHTML = '';
114
+ }
115
+
116
+ // 获取搜索历史的增强版本 - 支持新旧格式
117
+ function getSearchHistory() {
118
+ try {
119
+ const data = localStorage.getItem(SEARCH_HISTORY_KEY);
120
+ if (!data) return [];
121
+
122
+ const parsed = JSON.parse(data);
123
+
124
+ // 检查是否是数组
125
+ if (!Array.isArray(parsed)) return [];
126
+
127
+ // 支持旧格式(字符串数组)和新格式(对象数组)
128
+ return parsed.map(item => {
129
+ if (typeof item === 'string') {
130
+ return { text: item, timestamp: 0 };
131
+ }
132
+ return item;
133
+ }).filter(item => item && item.text);
134
+ } catch (e) {
135
+ console.error('获取搜索历史出错:', e);
136
+ return [];
137
+ }
138
+ }
139
+
140
+ // 保存搜索历史的增强版本 - 添加时间戳和最大数量限制,现在缓存2个月
141
+ function saveSearchHistory(query) {
142
+ if (!query || !query.trim()) return;
143
+
144
+ // 清理输入,防止XSS
145
+ query = query.trim().substring(0, 50).replace(/</g, '&lt;').replace(/>/g, '&gt;');
146
+
147
+ let history = getSearchHistory();
148
+
149
+ // 获取当前时间
150
+ const now = Date.now();
151
+
152
+ // 过滤掉超过2个月的记录(约60天,60*24*60*60*1000 = 5184000000毫秒)
153
+ history = history.filter(item =>
154
+ typeof item === 'object' && item.timestamp && (now - item.timestamp < 5184000000)
155
+ );
156
+
157
+ // 删除已存在的相同项
158
+ history = history.filter(item =>
159
+ typeof item === 'object' ? item.text !== query : item !== query
160
+ );
161
+
162
+ // 新项���加到开头,包含时间戳
163
+ history.unshift({
164
+ text: query,
165
+ timestamp: now
166
+ });
167
+
168
+ // 限制历史记录数量
169
+ if (history.length > MAX_HISTORY_ITEMS) {
170
+ history = history.slice(0, MAX_HISTORY_ITEMS);
171
+ }
172
+
173
+ try {
174
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history));
175
+ } catch (e) {
176
+ console.error('保存搜索历史失败:', e);
177
+ // 如果存储失败(可能是localStorage已满),尝试清理旧数据
178
+ try {
179
+ localStorage.removeItem(SEARCH_HISTORY_KEY);
180
+ localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history.slice(0, 3)));
181
+ } catch (e2) {
182
+ console.error('再次保存搜索历史失败:', e2);
183
+ }
184
+ }
185
+
186
+ renderSearchHistory();
187
+ }
188
+
189
+ // 渲染最近搜索历史的增强版本
190
+ function renderSearchHistory() {
191
+ const historyContainer = document.getElementById('recentSearches');
192
+ if (!historyContainer) return;
193
+
194
+ const history = getSearchHistory();
195
+
196
+ if (history.length === 0) {
197
+ historyContainer.innerHTML = '';
198
+ return;
199
+ }
200
+
201
+ // 创建一个包含标题和清除按钮的行
202
+ historyContainer.innerHTML = `
203
+ <div class="flex justify-between items-center w-full mb-2">
204
+ <div class="text-gray-500">最近搜索:</div>
205
+ <button id="clearHistoryBtn" class="text-gray-500 hover:text-white transition-colors"
206
+ onclick="clearSearchHistory()" aria-label="清除搜索历史">
207
+ 清除搜索历史
208
+ </button>
209
+ </div>
210
+ `;
211
+
212
+ history.forEach(item => {
213
+ const tag = document.createElement('button');
214
+ tag.className = 'search-tag';
215
+ tag.textContent = item.text;
216
+
217
+ // 添加时间提示(如果有时间戳)
218
+ if (item.timestamp) {
219
+ const date = new Date(item.timestamp);
220
+ tag.title = `搜索于: ${date.toLocaleString()}`;
221
+ }
222
+
223
+ tag.onclick = function() {
224
+ document.getElementById('searchInput').value = item.text;
225
+ search();
226
+ };
227
+ historyContainer.appendChild(tag);
228
+ });
229
+ }
230
+
231
+ // 增加清除搜索历史功能
232
+ function clearSearchHistory() {
233
+ // 密码保护校验
234
+ if (window.isPasswordProtected && window.isPasswordVerified) {
235
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
236
+ showPasswordModal && showPasswordModal();
237
+ return;
238
+ }
239
+ }
240
+ try {
241
+ localStorage.removeItem(SEARCH_HISTORY_KEY);
242
+ renderSearchHistory();
243
+ showToast('搜索历史已清除', 'success');
244
+ } catch (e) {
245
+ console.error('清除搜索历史失败:', e);
246
+ showToast('清除搜索历史失败:', 'error');
247
+ }
248
+ }
249
+
250
+ // 历史面板相关函数
251
+ function toggleHistory(e) {
252
+ // 密码保护校验
253
+ if (window.isPasswordProtected && window.isPasswordVerified) {
254
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
255
+ showPasswordModal && showPasswordModal();
256
+ return;
257
+ }
258
+ }
259
+ if (e) e.stopPropagation();
260
+
261
+ const panel = document.getElementById('historyPanel');
262
+ if (panel) {
263
+ panel.classList.toggle('show');
264
+
265
+ // 如果打开了历史记录面板,则加载历史数据
266
+ if (panel.classList.contains('show')) {
267
+ loadViewingHistory();
268
+ }
269
+
270
+ // 如果设置面板是打开的,则关闭它
271
+ const settingsPanel = document.getElementById('settingsPanel');
272
+ if (settingsPanel && settingsPanel.classList.contains('show')) {
273
+ settingsPanel.classList.remove('show');
274
+ }
275
+ }
276
+ }
277
+
278
+ // 格式化时间戳为友好的日期时间格式
279
+ function formatTimestamp(timestamp) {
280
+ const date = new Date(timestamp);
281
+ const now = new Date();
282
+ const diff = now - date;
283
+
284
+ // 小于1小时,显示"X分钟前"
285
+ if (diff < 3600000) {
286
+ const minutes = Math.floor(diff / 60000);
287
+ return minutes <= 0 ? '刚刚' : `${minutes}分钟前`;
288
+ }
289
+
290
+ // 小于24小时,显示"X小时前"
291
+ if (diff < 86400000) {
292
+ const hours = Math.floor(diff / 3600000);
293
+ return `${hours}小时前`;
294
+ }
295
+
296
+ // 小于7天,显示"X天前"
297
+ if (diff < 604800000) {
298
+ const days = Math.floor(diff / 86400000);
299
+ return `${days}天前`;
300
+ }
301
+
302
+ // 其他情况,显示完整日期
303
+ const year = date.getFullYear();
304
+ const month = (date.getMonth() + 1).toString().padStart(2, '0');
305
+ const day = date.getDate().toString().padStart(2, '0');
306
+ const hour = date.getHours().toString().padStart(2, '0');
307
+ const minute = date.getMinutes().toString().padStart(2, '0');
308
+
309
+ return `${year}-${month}-${day} ${hour}:${minute}`;
310
+ }
311
+
312
+ // 获取观看历史记录
313
+ function getViewingHistory() {
314
+ try {
315
+ const data = localStorage.getItem('viewingHistory');
316
+ return data ? JSON.parse(data) : [];
317
+ } catch (e) {
318
+ console.error('获取观看历史失败:', e);
319
+ return [];
320
+ }
321
+ }
322
+
323
+ // 加载观看历史并渲染
324
+ function loadViewingHistory() {
325
+ const historyList = document.getElementById('historyList');
326
+ if (!historyList) return;
327
+
328
+ const history = getViewingHistory();
329
+
330
+ if (history.length === 0) {
331
+ historyList.innerHTML = `<div class="text-center text-gray-500 py-8">暂无观看记录</div>`;
332
+ return;
333
+ }
334
+
335
+ // 渲染历史记录
336
+ historyList.innerHTML = history.map(item => {
337
+ // 防止XSS
338
+ const safeTitle = item.title
339
+ .replace(/</g, '&lt;')
340
+ .replace(/>/g, '&gt;')
341
+ .replace(/"/g, '&quot;');
342
+
343
+ const safeSource = item.sourceName ?
344
+ item.sourceName.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;') :
345
+ '未知来源';
346
+
347
+ const episodeText = item.episodeIndex !== undefined ?
348
+ `第${item.episodeIndex + 1}集` : '';
349
+
350
+ // 格式化进度信息
351
+ let progressHtml = '';
352
+ if (item.playbackPosition && item.duration && item.playbackPosition > 10 && item.playbackPosition < item.duration * 0.95) {
353
+ const percent = Math.round((item.playbackPosition / item.duration) * 100);
354
+ const formattedTime = formatPlaybackTime(item.playbackPosition);
355
+ const formattedDuration = formatPlaybackTime(item.duration);
356
+
357
+ progressHtml = `
358
+ <div class="history-progress">
359
+ <div class="progress-bar">
360
+ <div class="progress-filled" style="width:${percent}%"></div>
361
+ </div>
362
+ <div class="progress-text">${formattedTime} / ${formattedDuration}</div>
363
+ </div>
364
+ `;
365
+ }
366
+
367
+ // 为防止XSS,使用encodeURIComponent编码URL
368
+ const safeURL = encodeURIComponent(item.url);
369
+
370
+ // 构建历史记录项HTML,添加删除按钮,需要放在position:relative的容器中
371
+ return `
372
+ <div class="history-item cursor-pointer relative group" onclick="playFromHistory('${item.url}', '${safeTitle}', ${item.episodeIndex || 0}, ${item.playbackPosition || 0})">
373
+ <button onclick="event.stopPropagation(); deleteHistoryItem('${safeURL}')"
374
+ class="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 text-gray-400 hover:text-red-400 p-1 rounded-full hover:bg-gray-800 z-10"
375
+ title="删除记录">
376
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
377
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
378
+ </svg>
379
+ </button>
380
+ <div class="history-info">
381
+ <div class="history-title">${safeTitle}</div>
382
+ <div class="history-meta">
383
+ <span class="history-episode">${episodeText}</span>
384
+ ${episodeText ? '<span class="history-separator mx-1">·</span>' : ''}
385
+ <span class="history-source">${safeSource}</span>
386
+ </div>
387
+ ${progressHtml}
388
+ <div class="history-time">${formatTimestamp(item.timestamp)}</div>
389
+ </div>
390
+ </div>
391
+ `;
392
+ }).join('');
393
+
394
+ // 检查是否存在较多历史记录,添加底部边距确保底部按钮不会挡住内容
395
+ if (history.length > 5) {
396
+ historyList.classList.add('pb-4');
397
+ }
398
+ }
399
+
400
+ // 格式化播放时间为 mm:ss 格式
401
+ function formatPlaybackTime(seconds) {
402
+ if (!seconds || isNaN(seconds)) return '00:00';
403
+
404
+ const minutes = Math.floor(seconds / 60);
405
+ const remainingSeconds = Math.floor(seconds % 60);
406
+
407
+ return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
408
+ }
409
+
410
+ // 删除单个历史记录项
411
+ function deleteHistoryItem(encodedUrl) {
412
+ try {
413
+ // 解码URL
414
+ const url = decodeURIComponent(encodedUrl);
415
+
416
+ // 获取当前历史记录
417
+ const history = getViewingHistory();
418
+
419
+ // 过滤掉要删除的项
420
+ const newHistory = history.filter(item => item.url !== url);
421
+
422
+ // 保存回localStorage
423
+ localStorage.setItem('viewingHistory', JSON.stringify(newHistory));
424
+
425
+ // 重新加载历史记录显示
426
+ loadViewingHistory();
427
+
428
+ // 显示成功提示
429
+ showToast('已删除该记录', 'success');
430
+ } catch (e) {
431
+ console.error('删除历史记录项失败:', e);
432
+ showToast('删除记录失败', 'error');
433
+ }
434
+ }
435
+
436
+ // 从历史记录播放
437
+ function playFromHistory(url, title, episodeIndex, playbackPosition = 0) {
438
+ try {
439
+ // 尝试从localStorage获取当前视频的集数信息
440
+ let episodesList = [];
441
+
442
+ // 检查viewingHistory,查找匹配的项以获取其集数数据
443
+ const historyRaw = localStorage.getItem('viewingHistory');
444
+ if (historyRaw) {
445
+ const history = JSON.parse(historyRaw);
446
+ // 根据标题查找匹配的历史记录
447
+ const historyItem = history.find(item => item.title === title);
448
+
449
+ // 如果找到了匹配的历史记录,尝试获取该条目的集数数据
450
+ if (historyItem && historyItem.episodes && Array.isArray(historyItem.episodes)) {
451
+ episodesList = historyItem.episodes;
452
+ console.log(`从历史记录找到视频 ${title} 的集数数据:`, episodesList.length);
453
+ }
454
+ }
455
+
456
+ // 如果在历史记录中没找到,尝试使用上一个会话的集数数据
457
+ if (episodesList.length === 0) {
458
+ try {
459
+ const storedEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
460
+ if (storedEpisodes.length > 0) {
461
+ episodesList = storedEpisodes;
462
+ console.log(`使用localStorage中的集数数据:`, episodesList.length);
463
+ }
464
+ } catch (e) {
465
+ console.error('解析currentEpisodes失败:', e);
466
+ }
467
+ }
468
+
469
+ // 将剧集列表保存到localStorage,避免过长的URL
470
+ if (episodesList.length > 0) {
471
+ localStorage.setItem('currentEpisodes', JSON.stringify(episodesList));
472
+ console.log(`已将剧集列表保存到localStorage,共 ${episodesList.length} 集`);
473
+ }
474
+ // 构造带播放进度参数的URL
475
+ const positionParam = playbackPosition > 10 ? `&position=${Math.floor(playbackPosition)}` : '';
476
+
477
+ if (url.includes('?')) {
478
+ // URL已有参数,添加索引和位置参数
479
+ let playUrl = url;
480
+ if (!url.includes('index=') && episodeIndex > 0) {
481
+ playUrl += `&index=${episodeIndex}`;
482
+ }
483
+ if (playbackPosition > 10) {
484
+ playUrl += positionParam;
485
+ }
486
+ window.open(playUrl, '_blank');
487
+ } else {
488
+ // 原始URL,构造player页面链接
489
+ const playerUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}${positionParam}`;
490
+ window.open(playerUrl, '_blank');
491
+ }
492
+ } catch (e) {
493
+ console.error('从历史记录播放失败:', e);
494
+ // 回退到原始简单URL
495
+ const simpleUrl = `player.html?url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}&index=${episodeIndex}`;
496
+ window.open(simpleUrl, '_blank');
497
+ }
498
+ }
499
+
500
+ // 添加观看历史 - 确保每个视频标题只有一条记录
501
+ function addToViewingHistory(videoInfo) {
502
+ // 密码保护校验
503
+ if (window.isPasswordProtected && window.isPasswordVerified) {
504
+ if (window.isPasswordProtected() && !window.isPasswordVerified()) {
505
+ showPasswordModal && showPasswordModal();
506
+ return;
507
+ }
508
+ }
509
+ try {
510
+ const history = getViewingHistory();
511
+
512
+ // 检查是否已经存在相同标题的记录(同一视频的不同集数)
513
+ const existingIndex = history.findIndex(item => item.title === videoInfo.title);
514
+ if (existingIndex !== -1) {
515
+ // 存在则更新现有记录的集数和时间戳
516
+ const existingItem = history[existingIndex];
517
+ existingItem.episodeIndex = videoInfo.episodeIndex;
518
+ existingItem.timestamp = Date.now();
519
+
520
+ // 确保来源信息保留
521
+ if (videoInfo.sourceName && !existingItem.sourceName) {
522
+ existingItem.sourceName = videoInfo.sourceName;
523
+ }
524
+
525
+ // 更新播放进度信息,仅当新进度有效且大于10秒时
526
+ if (videoInfo.playbackPosition && videoInfo.playbackPosition > 10) {
527
+ existingItem.playbackPosition = videoInfo.playbackPosition;
528
+ existingItem.duration = videoInfo.duration || existingItem.duration;
529
+ }
530
+
531
+ // 更新URL,确保能够跳转到正确的集数
532
+ existingItem.url = videoInfo.url;
533
+
534
+ // 重要:确保episodes数据与当前视频匹配
535
+ // 只有当videoInfo中包含有效的episodes数据时才更新
536
+ if (videoInfo.episodes && Array.isArray(videoInfo.episodes) && videoInfo.episodes.length > 0) {
537
+ // 如果传入的集数数据与当前保存的不同,则更新
538
+ if (!existingItem.episodes ||
539
+ !Array.isArray(existingItem.episodes) ||
540
+ existingItem.episodes.length !== videoInfo.episodes.length) {
541
+ console.log(`更新 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`);
542
+ existingItem.episodes = [...videoInfo.episodes]; // 使用深拷贝
543
+ }
544
+ }
545
+
546
+ // 移到最前面
547
+ history.splice(existingIndex, 1);
548
+ history.unshift(existingItem);
549
+ } else {
550
+ // 添加新记录到最前面,确保包含剧集数据
551
+ const newItem = {
552
+ ...videoInfo,
553
+ timestamp: Date.now()
554
+ };
555
+
556
+ // 确保episodes字段是一个数组
557
+ if (videoInfo.episodes && Array.isArray(videoInfo.episodes)) {
558
+ newItem.episodes = [...videoInfo.episodes]; // 使用深拷贝
559
+ console.log(`保存新视频 "${videoInfo.title}" 的剧集数据: ${videoInfo.episodes.length}集`);
560
+ } else {
561
+ // 如果没有提供episodes,初始化为空数组
562
+ newItem.episodes = [];
563
+ }
564
+
565
+ history.unshift(newItem);
566
+ }
567
+
568
+ // 限制历史记录数量为50条
569
+ const maxHistoryItems = 50;
570
+ if (history.length > maxHistoryItems) {
571
+ history.splice(maxHistoryItems);
572
+ }
573
+
574
+ // 保存到本地存储
575
+ localStorage.setItem('viewingHistory', JSON.stringify(history));
576
+ } catch (e) {
577
+ console.error('保存观看历史失败:', e);
578
+ }
579
+ }
580
+
581
+ // 清空观看历史
582
+ function clearViewingHistory() {
583
+ try {
584
+ localStorage.removeItem('viewingHistory');
585
+ loadViewingHistory(); // 重新加载空的历史记录
586
+ showToast('观看历史已清空', 'success');
587
+ } catch (e) {
588
+ console.error('清除观看历史失败:', e);
589
+ showToast('清除观看历史失败', 'error');
590
+ }
591
+ }
592
+
593
+ // 更新toggleSettings函数以处理历史面板互动
594
+ const originalToggleSettings = toggleSettings;
595
+ toggleSettings = function(e) {
596
+ if (e) e.stopPropagation();
597
+
598
+ // 原始设置面板切换逻辑
599
+ originalToggleSettings(e);
600
+
601
+ // 如果历史记录面板是打开的,则关闭它
602
+ const historyPanel = document.getElementById('historyPanel');
603
+ if (historyPanel && historyPanel.classList.contains('show')) {
604
+ historyPanel.classList.remove('show');
605
+ }
606
+ };
607
+
608
+ // 点击外部关闭历史面板
609
+ document.addEventListener('DOMContentLoaded', function() {
610
+ document.addEventListener('click', function(e) {
611
+ const historyPanel = document.getElementById('historyPanel');
612
+ const historyButton = document.querySelector('button[onclick="toggleHistory(event)"]');
613
+
614
+ if (historyPanel && historyButton &&
615
+ !historyPanel.contains(e.target) &&
616
+ !historyButton.contains(e.target) &&
617
+ historyPanel.classList.contains('show')) {
618
+ historyPanel.classList.remove('show');
619
+ }
620
+ });
621
+ });
middleware.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { sha256 } from './js/sha256.js'; // 需新建或引入SHA-256实现
2
+
3
+ // Vercel Middleware to inject environment variables
4
+ export default async function middleware(request) {
5
+ // Get the URL from the request
6
+ const url = new URL(request.url);
7
+
8
+ // Only process HTML pages
9
+ const isHtmlPage = url.pathname.endsWith('.html') || url.pathname.endsWith('/');
10
+ if (!isHtmlPage) {
11
+ return; // Let the request pass through unchanged
12
+ }
13
+
14
+ // Fetch the original response
15
+ const response = await fetch(request);
16
+
17
+ // Check if it's an HTML response
18
+ const contentType = response.headers.get('content-type') || '';
19
+ if (!contentType.includes('text/html')) {
20
+ return response; // Return the original response if not HTML
21
+ }
22
+
23
+ // Get the HTML content
24
+ const originalHtml = await response.text();
25
+
26
+ // Replace the placeholder with actual environment variable
27
+ // If PASSWORD is not set, replace with empty string
28
+ const password = process.env.PASSWORD || '';
29
+ let passwordHash = '';
30
+ if (password) {
31
+ passwordHash = await sha256(password);
32
+ }
33
+ const modifiedHtml = originalHtml.replace(
34
+ 'window.__ENV__.PASSWORD = "{{PASSWORD}}";',
35
+ `window.__ENV__.PASSWORD = "${passwordHash}"; // SHA-256 hash`
36
+ );
37
+
38
+ // Create a new response with the modified HTML
39
+ return new Response(modifiedHtml, {
40
+ status: response.status,
41
+ statusText: response.statusText,
42
+ headers: response.headers
43
+ });
44
+ }
45
+
46
+ export const config = {
47
+ matcher: ['/', '/((?!api|_next/static|_vercel|favicon.ico).*)'],
48
+ };
netlify.toml ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # netlify.toml
2
+
3
+ [build]
4
+ # 如果你的项目不需要构建步骤 (纯静态 + functions),可以省略 publish
5
+ # publish = "." # 假设你的 HTML/CSS/JS 文件在根目录
6
+ functions = "netlify/functions" # 指定 Netlify 函数目录
7
+
8
+ # 配置重写规则,将 /proxy/* 的请求路由到 proxy 函数
9
+ # 这样前端的 PROXY_URL 仍然可以是 '/proxy/'
10
+ [[redirects]]
11
+ from = "/proxy/*"
12
+ to = "/.netlify/functions/proxy/:splat" # 将路径参数传递给函数
13
+ status = 200 # 重要:这是代理,不是重定向
14
+
15
+ # (可选)为其他静态文件设置缓存头等
16
+ # [[headers]]
17
+ # for = "/*"
18
+ # [headers.values]
19
+ # # Add any global headers here
netlify/functions/proxy.mjs ADDED
@@ -0,0 +1,274 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // /netlify/functions/proxy.mjs - Netlify Function (ES Module)
2
+
3
+ import fetch from 'node-fetch';
4
+ import { URL } from 'url'; // Use Node.js built-in URL
5
+
6
+ // --- Configuration (Read from Environment Variables) ---
7
+ const DEBUG_ENABLED = process.env.DEBUG === 'true';
8
+ const CACHE_TTL = parseInt(process.env.CACHE_TTL || '86400', 10); // Default 24 hours
9
+ const MAX_RECURSION = parseInt(process.env.MAX_RECURSION || '5', 10); // Default 5 levels
10
+
11
+ // --- User Agent Handling ---
12
+ let USER_AGENTS = [
13
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
14
+ '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'
15
+ ];
16
+ try {
17
+ const agentsJsonString = process.env.USER_AGENTS_JSON;
18
+ if (agentsJsonString) {
19
+ const parsedAgents = JSON.parse(agentsJsonString);
20
+ if (Array.isArray(parsedAgents) && parsedAgents.length > 0) {
21
+ USER_AGENTS = parsedAgents;
22
+ console.log(`[Proxy Log Netlify] Loaded ${USER_AGENTS.length} user agents from environment variable.`);
23
+ } else {
24
+ console.warn("[Proxy Log Netlify] USER_AGENTS_JSON environment variable is not a valid non-empty array, using default.");
25
+ }
26
+ } else {
27
+ console.log("[Proxy Log Netlify] USER_AGENTS_JSON environment variable not set, using default user agents.");
28
+ }
29
+ } catch (e) {
30
+ console.error(`[Proxy Log Netlify] Error parsing USER_AGENTS_JSON environment variable: ${e.message}. Using default user agents.`);
31
+ }
32
+ const FILTER_DISCONTINUITY = false; // Ad filtering disabled
33
+
34
+ // --- Helper Functions (Same as Vercel version, except rewriteUrlToProxy) ---
35
+
36
+ function logDebug(message) {
37
+ if (DEBUG_ENABLED) {
38
+ console.log(`[Proxy Log Netlify] ${message}`);
39
+ }
40
+ }
41
+
42
+ function getTargetUrlFromPath(encodedPath) {
43
+ if (!encodedPath) { logDebug("getTargetUrlFromPath received empty path."); return null; }
44
+ try {
45
+ const decodedUrl = decodeURIComponent(encodedPath);
46
+ if (decodedUrl.match(/^https?:\/\/.+/i)) { return decodedUrl; }
47
+ else {
48
+ logDebug(`Invalid decoded URL format: ${decodedUrl}`);
49
+ if (encodedPath.match(/^https?:\/\/.+/i)) { logDebug(`Warning: Path was not encoded but looks like URL: ${encodedPath}`); return encodedPath; }
50
+ return null;
51
+ }
52
+ } catch (e) { logDebug(`Error decoding target URL: ${encodedPath} - ${e.message}`); return null; }
53
+ }
54
+
55
+ function getBaseUrl(urlStr) {
56
+ if (!urlStr) return '';
57
+ try {
58
+ const parsedUrl = new URL(urlStr);
59
+ const pathSegments = parsedUrl.pathname.split('/').filter(Boolean);
60
+ if (pathSegments.length <= 1) { return `${parsedUrl.origin}/`; }
61
+ pathSegments.pop(); return `${parsedUrl.origin}/${pathSegments.join('/')}/`;
62
+ } catch (e) {
63
+ logDebug(`Getting BaseUrl failed for "${urlStr}": ${e.message}`);
64
+ const lastSlashIndex = urlStr.lastIndexOf('/');
65
+ if (lastSlashIndex > urlStr.indexOf('://') + 2) { return urlStr.substring(0, lastSlashIndex + 1); }
66
+ return urlStr + '/';
67
+ }
68
+ }
69
+
70
+ function resolveUrl(baseUrl, relativeUrl) {
71
+ if (!relativeUrl) return ''; if (relativeUrl.match(/^https?:\/\/.+/i)) { return relativeUrl; } if (!baseUrl) return relativeUrl;
72
+ try { return new URL(relativeUrl, baseUrl).toString(); }
73
+ catch (e) {
74
+ logDebug(`URL resolution failed: base="${baseUrl}", relative="${relativeUrl}". Error: ${e.message}`);
75
+ if (relativeUrl.startsWith('/')) { try { const baseOrigin = new URL(baseUrl).origin; return `${baseOrigin}${relativeUrl}`; } catch { return relativeUrl; } }
76
+ else { return `${baseUrl.substring(0, baseUrl.lastIndexOf('/') + 1)}${relativeUrl}`; }
77
+ }
78
+ }
79
+
80
+ // ** MODIFIED for Netlify redirect **
81
+ function rewriteUrlToProxy(targetUrl) {
82
+ if (!targetUrl || typeof targetUrl !== 'string') return '';
83
+ // Use the path defined in netlify.toml 'from' field
84
+ return `/proxy/${encodeURIComponent(targetUrl)}`;
85
+ }
86
+
87
+ function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; }
88
+
89
+ async function fetchContentWithType(targetUrl, requestHeaders) {
90
+ const headers = {
91
+ 'User-Agent': getRandomUserAgent(),
92
+ 'Accept': requestHeaders['accept'] || '*/*',
93
+ 'Accept-Language': requestHeaders['accept-language'] || 'zh-CN,zh;q=0.9,en;q=0.8',
94
+ 'Referer': requestHeaders['referer'] || new URL(targetUrl).origin,
95
+ };
96
+ Object.keys(headers).forEach(key => headers[key] === undefined || headers[key] === null || headers[key] === '' ? delete headers[key] : {});
97
+ logDebug(`Fetching target: ${targetUrl} with headers: ${JSON.stringify(headers)}`);
98
+ try {
99
+ const response = await fetch(targetUrl, { headers, redirect: 'follow' });
100
+ if (!response.ok) {
101
+ const errorBody = await response.text().catch(() => '');
102
+ logDebug(`Fetch failed: ${response.status} ${response.statusText} - ${targetUrl}`);
103
+ const err = new Error(`HTTP error ${response.status}: ${response.statusText}. URL: ${targetUrl}. Body: ${errorBody.substring(0, 200)}`);
104
+ err.status = response.status; throw err;
105
+ }
106
+ const content = await response.text();
107
+ const contentType = response.headers.get('content-type') || '';
108
+ logDebug(`Fetch success: ${targetUrl}, Content-Type: ${contentType}, Length: ${content.length}`);
109
+ return { content, contentType, responseHeaders: response.headers };
110
+ } catch (error) {
111
+ logDebug(`Fetch exception for ${targetUrl}: ${error.message}`);
112
+ throw new Error(`Failed to fetch target URL ${targetUrl}: ${error.message}`);
113
+ }
114
+ }
115
+
116
+ function isM3u8Content(content, contentType) {
117
+ if (contentType && (contentType.includes('application/vnd.apple.mpegurl') || contentType.includes('application/x-mpegurl') || contentType.includes('audio/mpegurl'))) { return true; }
118
+ return content && typeof content === 'string' && content.trim().startsWith('#EXTM3U');
119
+ }
120
+
121
+ function processKeyLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing KEY URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
122
+ function processMapLine(line, baseUrl) { return line.replace(/URI="([^"]+)"/, (match, uri) => { const absoluteUri = resolveUrl(baseUrl, uri); logDebug(`Processing MAP URI: Original='${uri}', Absolute='${absoluteUri}'`); return `URI="${rewriteUrlToProxy(absoluteUri)}"`; }); }
123
+ function processMediaPlaylist(url, content) {
124
+ const baseUrl = getBaseUrl(url); if (!baseUrl) { logDebug(`Could not determine base URL for media playlist: ${url}. Cannot process relative paths.`); }
125
+ const lines = content.split('\n'); const output = [];
126
+ for (let i = 0; i < lines.length; i++) {
127
+ const line = lines[i].trim(); if (!line && i === lines.length - 1) { output.push(line); continue; } if (!line) continue;
128
+ if (line.startsWith('#EXT-X-KEY')) { output.push(processKeyLine(line, baseUrl)); continue; }
129
+ if (line.startsWith('#EXT-X-MAP')) { output.push(processMapLine(line, baseUrl)); continue; }
130
+ if (line.startsWith('#EXTINF')) { output.push(line); continue; }
131
+ if (!line.startsWith('#')) { const absoluteUrl = resolveUrl(baseUrl, line); logDebug(`Rewriting media segment: Original='${line}', Resolved='${absoluteUrl}'`); output.push(rewriteUrlToProxy(absoluteUrl)); continue; }
132
+ output.push(line);
133
+ } return output.join('\n');
134
+ }
135
+ async function processM3u8Content(targetUrl, content, recursionDepth = 0) {
136
+ if (content.includes('#EXT-X-STREAM-INF') || content.includes('#EXT-X-MEDIA:')) { logDebug(`Detected master playlist: ${targetUrl} (Depth: ${recursionDepth})`); return await processMasterPlaylist(targetUrl, content, recursionDepth); }
137
+ logDebug(`Detected media playlist: ${targetUrl} (Depth: ${recursionDepth})`); return processMediaPlaylist(targetUrl, content);
138
+ }
139
+ async function processMasterPlaylist(url, content, recursionDepth) {
140
+ if (recursionDepth > MAX_RECURSION) { throw new Error(`Max recursion depth (${MAX_RECURSION}) exceeded for master playlist: ${url}`); }
141
+ const baseUrl = getBaseUrl(url); const lines = content.split('\n'); let highestBandwidth = -1; let bestVariantUrl = '';
142
+ 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); } } }
143
+ if (!bestVariantUrl) { logDebug(`No BANDWIDTH found, trying first URI in: ${url}`); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line && !line.startsWith('#') && line.match(/\.m3u8($|\?.*)/i)) { bestVariantUrl = resolveUrl(baseUrl, line); logDebug(`Fallback: Found first sub-playlist URI: ${bestVariantUrl}`); break; } } }
144
+ if (!bestVariantUrl) { logDebug(`No valid sub-playlist URI found in master: ${url}. Processing as media playlist.`); return processMediaPlaylist(url, content); }
145
+ logDebug(`Selected sub-playlist (Bandwidth: ${highestBandwidth}): ${bestVariantUrl}`);
146
+ const { content: variantContent, contentType: variantContentType } = await fetchContentWithType(bestVariantUrl, {});
147
+ if (!isM3u8Content(variantContent, variantContentType)) { logDebug(`Fetched sub-playlist ${bestVariantUrl} is not M3U8 (Type: ${variantContentType}). Treating as media playlist.`); return processMediaPlaylist(bestVariantUrl, variantContent); }
148
+ return await processM3u8Content(bestVariantUrl, variantContent, recursionDepth + 1);
149
+ }
150
+
151
+
152
+ // --- Netlify Handler ---
153
+ export const handler = async (event, context) => {
154
+ console.log('--- Netlify Proxy Request ---');
155
+ console.log('Time:', new Date().toISOString());
156
+ console.log('Method:', event.httpMethod);
157
+ console.log('Path:', event.path);
158
+ // Note: event.queryStringParameters contains query params if any
159
+ // Note: event.headers contains incoming headers
160
+
161
+ // --- CORS Headers (for all responses) ---
162
+ const corsHeaders = {
163
+ 'Access-Control-Allow-Origin': '*',
164
+ 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS',
165
+ 'Access-Control-Allow-Headers': '*', // Allow all headers client might send
166
+ };
167
+
168
+ // --- Handle OPTIONS Preflight Request ---
169
+ if (event.httpMethod === 'OPTIONS') {
170
+ logDebug("Handling OPTIONS request");
171
+ return {
172
+ statusCode: 204,
173
+ headers: {
174
+ ...corsHeaders,
175
+ 'Access-Control-Max-Age': '86400', // Cache preflight for 24 hours
176
+ },
177
+ body: '',
178
+ };
179
+ }
180
+
181
+ // --- Extract Target URL ---
182
+ // Based on netlify.toml rewrite: from = "/proxy/*" to = "/.netlify/functions/proxy/:splat"
183
+ // The :splat part should be available in event.path after the base path
184
+ let encodedUrlPath = '';
185
+ const proxyPrefix = '/proxy/'; // Match the 'from' path in netlify.toml
186
+ if (event.path && event.path.startsWith(proxyPrefix)) {
187
+ encodedUrlPath = event.path.substring(proxyPrefix.length);
188
+ logDebug(`Extracted encoded path from event.path: ${encodedUrlPath}`);
189
+ } else {
190
+ logDebug(`Could not extract encoded path from event.path: ${event.path}`);
191
+ // Potentially handle direct calls too? Less likely needed.
192
+ // const functionPath = '/.netlify/functions/proxy/';
193
+ // if (event.path && event.path.startsWith(functionPath)) {
194
+ // encodedUrlPath = event.path.substring(functionPath.length);
195
+ // }
196
+ }
197
+
198
+ const targetUrl = getTargetUrlFromPath(encodedUrlPath);
199
+ logDebug(`Resolved target URL: ${targetUrl || 'null'}`);
200
+
201
+ if (!targetUrl) {
202
+ logDebug('Error: Invalid proxy request path.');
203
+ return {
204
+ statusCode: 400,
205
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ success: false, error: "Invalid proxy request path. Could not extract target URL." }),
207
+ };
208
+ }
209
+
210
+ logDebug(`Processing proxy request for target: ${targetUrl}`);
211
+
212
+ try {
213
+ // Fetch Original Content (Pass Netlify event headers)
214
+ const { content, contentType, responseHeaders } = await fetchContentWithType(targetUrl, event.headers);
215
+
216
+ // --- Process if M3U8 ---
217
+ if (isM3u8Content(content, contentType)) {
218
+ logDebug(`Processing M3U8 content: ${targetUrl}`);
219
+ const processedM3u8 = await processM3u8Content(targetUrl, content);
220
+
221
+ logDebug(`Successfully processed M3U8 for ${targetUrl}`);
222
+ return {
223
+ statusCode: 200,
224
+ headers: {
225
+ ...corsHeaders, // Include CORS headers
226
+ 'Content-Type': 'application/vnd.apple.mpegurl;charset=utf-8',
227
+ 'Cache-Control': `public, max-age=${CACHE_TTL}`,
228
+ // Note: Do NOT include content-encoding or content-length from original response
229
+ // as node-fetch likely decompressed it and length changed.
230
+ },
231
+ body: processedM3u8, // Netlify expects body as string
232
+ };
233
+ } else {
234
+ // --- Return Original Content (Non-M3U8) ---
235
+ logDebug(`Returning non-M3U8 content directly: ${targetUrl}, Type: ${contentType}`);
236
+
237
+ // Prepare headers for Netlify response object
238
+ const netlifyHeaders = { ...corsHeaders };
239
+ responseHeaders.forEach((value, key) => {
240
+ const lowerKey = key.toLowerCase();
241
+ // Exclude problematic headers and CORS headers (already added)
242
+ if (!lowerKey.startsWith('access-control-') &&
243
+ lowerKey !== 'content-encoding' &&
244
+ lowerKey !== 'content-length') {
245
+ netlifyHeaders[key] = value; // Add other original headers
246
+ }
247
+ });
248
+ netlifyHeaders['Cache-Control'] = `public, max-age=${CACHE_TTL}`; // Set our cache policy
249
+
250
+ return {
251
+ statusCode: 200,
252
+ headers: netlifyHeaders,
253
+ body: content, // Body as string
254
+ // isBase64Encoded: false, // Set true only if returning binary data as base64
255
+ };
256
+ }
257
+
258
+ } catch (error) {
259
+ logDebug(`ERROR in proxy processing for ${targetUrl}: ${error.message}`);
260
+ console.error(`[Proxy Error Stack Netlify] ${error.stack}`); // Log full stack
261
+
262
+ const statusCode = error.status || 500; // Get status from error if available
263
+
264
+ return {
265
+ statusCode: statusCode,
266
+ headers: { ...corsHeaders, 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ success: false,
269
+ error: `Proxy processing error: ${error.message}`,
270
+ targetUrl: targetUrl
271
+ }),
272
+ };
273
+ }
274
+ };
nginx.conf ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name localhost;
4
+
5
+ #access_log /var/log/nginx/host.access.log main;
6
+
7
+ resolver 114.114.114.114 8.8.8.8 valid=300s;
8
+ resolver_timeout 5s;
9
+
10
+ # 创建代理路由
11
+ location /proxy/ {
12
+ # 设置CORS头部
13
+ add_header 'Access-Control-Allow-Origin' '*';
14
+ add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
15
+ add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
16
+ add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
17
+
18
+ # OPTIONS请求处理
19
+ if ($request_method = 'OPTIONS') {
20
+ add_header 'Access-Control-Max-Age' 1728000;
21
+ add_header 'Content-Type' 'text/plain charset=UTF-8';
22
+ add_header 'Content-Length' 0;
23
+ return 204;
24
+ }
25
+
26
+ set $target_url '';
27
+
28
+ # 执行Lua脚本解析URL
29
+ rewrite_by_lua_file /usr/share/nginx/html/proxy.lua;
30
+
31
+ proxy_ssl_server_name on;
32
+ proxy_ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;
33
+
34
+ # 设置代理头信息
35
+ # 不设置Host,让Nginx自动根据目标URL设置
36
+ proxy_set_header X-Real-IP $remote_addr;
37
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
38
+ proxy_set_header X-Forwarded-Proto $scheme;
39
+ # 处理可能的重定向
40
+ proxy_redirect off;
41
+ proxy_buffering off;
42
+ # 代理超时设置
43
+ proxy_connect_timeout 60s;
44
+ proxy_send_timeout 60s;
45
+ proxy_read_timeout 60s;
46
+
47
+ proxy_pass $target_url;
48
+ }
49
+
50
+ location / {
51
+ root /usr/share/nginx/html;
52
+ index index.html index.htm;
53
+ }
54
+
55
+ #error_page 404 /404.html;
56
+
57
+ # redirect server error pages to the static page /50x.html
58
+ #
59
+ error_page 500 502 503 504 /50x.html;
60
+ location = /50x.html {
61
+ root /usr/share/nginx/html;
62
+ }
63
+
64
+ # proxy the PHP scripts to Apache listening on 127.0.0.1:80
65
+ #
66
+ #location ~ \.php$ {
67
+ # proxy_pass http://127.0.0.1;
68
+ #}
69
+
70
+ # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
71
+ #
72
+ #location ~ \.php$ {
73
+ # root html;
74
+ # fastcgi_pass 127.0.0.1:9000;
75
+ # fastcgi_index index.php;
76
+ # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
77
+ # include fastcgi_params;
78
+ #}
79
+
80
+ # deny access to .htaccess files, if Apache's document root
81
+ # concurs with nginx's one
82
+ #
83
+ #location ~ /\.ht {
84
+ # deny all;
85
+ #}
86
+ }
package-lock.json ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "libretv",
3
+ "version": "1.1.0",
4
+ "lockfileVersion": 3,
5
+ "requires": true,
6
+ "packages": {
7
+ "": {
8
+ "name": "libretv",
9
+ "version": "1.1.0",
10
+ "license": "MIT",
11
+ "dependencies": {
12
+ "node-fetch": "^3.3.2"
13
+ }
14
+ },
15
+ "node_modules/data-uri-to-buffer": {
16
+ "version": "4.0.1",
17
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
18
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
19
+ "license": "MIT",
20
+ "engines": {
21
+ "node": ">= 12"
22
+ }
23
+ },
24
+ "node_modules/fetch-blob": {
25
+ "version": "3.2.0",
26
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
27
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
28
+ "funding": [
29
+ {
30
+ "type": "github",
31
+ "url": "https://github.com/sponsors/jimmywarting"
32
+ },
33
+ {
34
+ "type": "paypal",
35
+ "url": "https://paypal.me/jimmywarting"
36
+ }
37
+ ],
38
+ "license": "MIT",
39
+ "dependencies": {
40
+ "node-domexception": "^1.0.0",
41
+ "web-streams-polyfill": "^3.0.3"
42
+ },
43
+ "engines": {
44
+ "node": "^12.20 || >= 14.13"
45
+ }
46
+ },
47
+ "node_modules/formdata-polyfill": {
48
+ "version": "4.0.10",
49
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
50
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
51
+ "license": "MIT",
52
+ "dependencies": {
53
+ "fetch-blob": "^3.1.2"
54
+ },
55
+ "engines": {
56
+ "node": ">=12.20.0"
57
+ }
58
+ },
59
+ "node_modules/node-domexception": {
60
+ "version": "1.0.0",
61
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
62
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
63
+ "funding": [
64
+ {
65
+ "type": "github",
66
+ "url": "https://github.com/sponsors/jimmywarting"
67
+ },
68
+ {
69
+ "type": "github",
70
+ "url": "https://paypal.me/jimmywarting"
71
+ }
72
+ ],
73
+ "license": "MIT",
74
+ "engines": {
75
+ "node": ">=10.5.0"
76
+ }
77
+ },
78
+ "node_modules/node-fetch": {
79
+ "version": "3.3.2",
80
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
81
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
82
+ "license": "MIT",
83
+ "dependencies": {
84
+ "data-uri-to-buffer": "^4.0.0",
85
+ "fetch-blob": "^3.1.4",
86
+ "formdata-polyfill": "^4.0.10"
87
+ },
88
+ "engines": {
89
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
90
+ },
91
+ "funding": {
92
+ "type": "opencollective",
93
+ "url": "https://opencollective.com/node-fetch"
94
+ }
95
+ },
96
+ "node_modules/web-streams-polyfill": {
97
+ "version": "3.3.3",
98
+ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
99
+ "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==",
100
+ "license": "MIT",
101
+ "engines": {
102
+ "node": ">= 8"
103
+ }
104
+ }
105
+ }
106
+ }
package.json ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "libretv",
3
+ "version": "1.1.0",
4
+ "description": "免费在线视频搜索与观看平台",
5
+ "private": true,
6
+ "type": "module",
7
+ "scripts": {
8
+ "test": "echo \"Error: no test specified\" && exit 1"
9
+ },
10
+ "dependencies": {
11
+ "node-fetch": "^3.3.2"
12
+ },
13
+ "author": "bestZwei",
14
+ "license": "MIT"
15
+ }
player.html ADDED
@@ -0,0 +1,1403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>LibreTV 播放器</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="css/styles.css">
9
+ <style>
10
+ body, html {
11
+ margin: 0;
12
+ padding: 0;
13
+ width: 100%;
14
+ height: 100%;
15
+ background-color: #0f1622;
16
+ color: white;
17
+ }
18
+ .player-container {
19
+ width: 100%;
20
+ max-width: 1200px;
21
+ margin: 0 auto;
22
+ }
23
+ #player {
24
+ width: 100%;
25
+ height: 60vh; /* 视频播放器高度 */
26
+ }
27
+ .loading-container {
28
+ position: absolute;
29
+ top: 0;
30
+ left: 0;
31
+ right: 0;
32
+ bottom: 0;
33
+ display: flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ background-color: rgba(0, 0, 0, 0.7);
37
+ color: white;
38
+ z-index: 100;
39
+ flex-direction: column;
40
+ }
41
+ .loading-spinner {
42
+ width: 50px;
43
+ height: 50px;
44
+ border: 4px solid rgba(255, 255, 255, 0.3);
45
+ border-radius: 50%;
46
+ border-top-color: white;
47
+ animation: spin 1s ease-in-out infinite;
48
+ margin-bottom: 10px;
49
+ }
50
+ @keyframes spin {
51
+ to { transform: rotate(360deg); }
52
+ }
53
+ .error-container {
54
+ position: absolute;
55
+ top: 0;
56
+ left: 0;
57
+ right: 0;
58
+ bottom: 0;
59
+ display: none;
60
+ align-items: center;
61
+ justify-content: center;
62
+ background-color: rgba(0, 0, 0, 0.7);
63
+ color: white;
64
+ z-index: 100;
65
+ flex-direction: column;
66
+ text-align: center;
67
+ padding: 1rem;
68
+ }
69
+ .error-icon {
70
+ font-size: 48px;
71
+ margin-bottom: 10px;
72
+ }
73
+ .episode-active {
74
+ background-color: #3b82f6 !important;
75
+ border-color: #60a5fa !important;
76
+ }
77
+ .episode-grid {
78
+ max-height: 30vh;
79
+ overflow-y: auto;
80
+ padding: 1rem 0;
81
+ }
82
+ .switch {
83
+ position: relative;
84
+ display: inline-block;
85
+ width: 46px;
86
+ height: 24px;
87
+ }
88
+ .switch input {
89
+ opacity: 0;
90
+ width: 0;
91
+ height: 0;
92
+ }
93
+ .slider {
94
+ position: absolute;
95
+ cursor: pointer;
96
+ top: 0;
97
+ left: 0;
98
+ right: 0;
99
+ bottom: 0;
100
+ background-color: #333;
101
+ transition: .4s;
102
+ border-radius: 24px;
103
+ }
104
+ .slider:before {
105
+ position: absolute;
106
+ content: "";
107
+ height: 18px;
108
+ width: 18px;
109
+ left: 3px;
110
+ bottom: 3px;
111
+ background-color: white;
112
+ transition: .4s;
113
+ border-radius: 50%;
114
+ }
115
+ input:checked + .slider {
116
+ background-color: #00ccff;
117
+ }
118
+ input:checked + .slider:before {
119
+ transform: translateX(22px);
120
+ }
121
+ /* 添加快捷键提示样式 */
122
+ .shortcut-hint {
123
+ position: fixed;
124
+ top: 50%;
125
+ left: 50%;
126
+ transform: translate(-50%, -50%);
127
+ background-color: rgba(0, 0, 0, 0.8);
128
+ color: white;
129
+ padding: 1rem 2rem;
130
+ border-radius: 0.5rem;
131
+ font-size: 1.5rem;
132
+ display: flex;
133
+ align-items: center;
134
+ gap: 0.5rem;
135
+ z-index: 1000;
136
+ opacity: 0;
137
+ transition: opacity 0.3s ease;
138
+ }
139
+ .shortcut-hint.show {
140
+ opacity: 1;
141
+ }
142
+
143
+ /* 原生全屏时,播放器容器铺满 */
144
+ .player-container:-webkit-full-screen,
145
+ .player-container:fullscreen {
146
+ position: fixed;
147
+ top: 0; left: 0;
148
+ width: 100vw; height: 100vh;
149
+ z-index: 10000;
150
+ background-color: #000;
151
+ }
152
+ .player-container:-webkit-full-screen #player,
153
+ .player-container:fullscreen #player {
154
+ width: 100%; height: 100%;
155
+ }
156
+ </style>
157
+ </head>
158
+ <body>
159
+ <header class="bg-[#111] p-4 flex justify-between items-center border-b border-[#333]">
160
+ <div class="flex items-center">
161
+ <a href="index.html" class="flex items-center">
162
+ <svg class="w-8 h-8 mr-2 text-[#00ccff]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
163
+ <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>
164
+ </svg>
165
+ <h1 class="text-xl font-bold gradient-text">LibreTV</h1>
166
+ </a>
167
+ </div>
168
+ <h2 id="videoTitle" class="text-xl font-semibold truncate flex-1 text-center"></h2>
169
+ <a href="index.html" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors">
170
+ 返回首页
171
+ </a>
172
+ </header>
173
+
174
+ <main class="container mx-auto px-4 py-4">
175
+ <!-- 视频播放区 -->
176
+ <div id="playerContainer" class="player-container">
177
+ <div class="relative">
178
+ <div id="player"></div>
179
+ <div class="loading-container" id="loading">
180
+ <div class="loading-spinner"></div>
181
+ <div>正在加载视频...</div>
182
+ </div>
183
+ <div class="error-container" id="error">
184
+ <div class="error-icon">⚠️</div>
185
+ <div id="error-message">视频加载失败</div>
186
+ <div style="margin-top: 10px; font-size: 14px; color: #aaa;">请尝试其他视频源或稍后重试</div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <!-- 集数导航 -->
192
+ <div class="player-container">
193
+ <div class="flex justify-between items-center my-4">
194
+ <button onclick="playPreviousEpisode()" id="prevButton" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors">
195
+ <svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
196
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
197
+ </svg>
198
+ 上一集
199
+ </button>
200
+ <span class="text-gray-400" id="episodeInfo">加载中...</span>
201
+ <button onclick="playNextEpisode()" id="nextButton" class="px-4 py-2 bg-[#222] hover:bg-[#333] border border-[#333] rounded-lg transition-colors">
202
+ 下一集
203
+ <svg class="w-5 h-5 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
204
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
205
+ </svg>
206
+ </button>
207
+ </div>
208
+ </div>
209
+
210
+ <!-- 添加自动播放开关和排序按钮 -->
211
+ <div class="player-container">
212
+ <div class="flex justify-end items-center mb-4 gap-2">
213
+ <span class="text-gray-400 text-sm">自动连播</span>
214
+ <label class="switch">
215
+ <input type="checkbox" id="autoplayToggle">
216
+ <span class="slider"></span>
217
+ </label>
218
+ <button onclick="toggleEpisodeOrder()" class="ml-4 px-4 py-2 bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-white font-semibold rounded-full shadow-lg hover:shadow-xl transition-all duration-300 flex items-center justify-center space-x-2">
219
+ <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" id="orderIcon" viewBox="0 0 20 20" fill="currentColor">
220
+ <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-11a1 1 0 10-2 0v3.586L7.707 9.293a1 1 0 00-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 10.586V7z" clip-rule="evenodd" />
221
+ </svg>
222
+ <span id="orderText">倒序排列</span>
223
+ </button>
224
+ <button id="lockToggle" onclick="toggleControlsLock()" title="锁定控制"
225
+ class="px-2 py-1 bg-[#333] hover:bg-[#444] text-white rounded-full transition">
226
+ <svg id="lockIcon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
227
+ <!-- 默认状态:未锁图标 -->
228
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
229
+ d="M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z" />
230
+ </svg>
231
+ </button>
232
+ </div>
233
+ </div>
234
+
235
+ <!-- 集数网格 -->
236
+ <div class="player-container">
237
+ <div class="episode-grid" id="episodesGrid">
238
+ <div class="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2" id="episodesList">
239
+ <!-- 集数将在这里动态加载 -->
240
+ <div class="col-span-full text-center text-gray-400 py-8">加载中...</div>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ </main>
245
+
246
+ <!-- 添加快捷键提示元素 -->
247
+ <div class="shortcut-hint" id="shortcutHint">
248
+ <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" id="shortcutIcon">
249
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
250
+ </svg>
251
+ <span id="shortcutText"></span>
252
+ </div>
253
+
254
+ <script src="https://s4.zstatic.net/ajax/libs/hls.js/1.5.6/hls.min.js" integrity="sha256-X1GmLMzVcTBRiGjEau+gxGpjRK96atNczcLBg5w6hKA=" crossorigin="anonymous"></script>
255
+ <script src="https://s4.zstatic.net/ajax/libs/dplayer/1.26.0/DPlayer.min.js" integrity="sha256-OJg03lDZP0NAcl3waC9OT5jEa8XZ8SM2n081Ik953o4=" crossorigin="anonymous"></script>
256
+ <script src="js/config.js"></script>
257
+ <script>
258
+ // 全局变量
259
+ let currentVideoTitle = '';
260
+ let currentEpisodeIndex = 0;
261
+ let currentEpisodes = [];
262
+ let episodesReversed = false;
263
+ let dp = null;
264
+ let currentHls = null; // 跟踪当前HLS实例
265
+ let autoplayEnabled = true; // 默认开启自动连播
266
+ let isUserSeeking = false; // 跟踪用户是否正在拖动进度条
267
+ let videoHasEnded = false; // 跟踪视频是否已经自然结束
268
+ let userClickedPosition = null; // 记录用户点击的位置
269
+ let shortcutHintTimeout = null; // 用于控制快捷键提示显示时间
270
+ let adFilteringEnabled = true; // 默认开启广告过滤
271
+ let progressSaveInterval = null; // 定期保存进度的计时器
272
+
273
+ // 页面加载
274
+ document.addEventListener('DOMContentLoaded', function() {
275
+ // 解析URL参数
276
+ const urlParams = new URLSearchParams(window.location.search);
277
+ const videoUrl = urlParams.get('url');
278
+ const title = urlParams.get('title');
279
+ let index = parseInt(urlParams.get('index') || '0');
280
+ const episodesList = urlParams.get('episodes'); // 新增:从URL获取集数信息
281
+
282
+ // 从localStorage获取数据
283
+ currentVideoTitle = title || localStorage.getItem('currentVideoTitle') || '未知视频';
284
+ currentEpisodeIndex = index;
285
+
286
+ // 设置自动连播开关状态
287
+ autoplayEnabled = localStorage.getItem('autoplayEnabled') !== 'false'; // 默认为true
288
+ document.getElementById('autoplayToggle').checked = autoplayEnabled;
289
+
290
+ // 获取广告过滤设置
291
+ adFilteringEnabled = localStorage.getItem(PLAYER_CONFIG.adFilteringStorage) !== 'false'; // 默认为true
292
+
293
+ // 监听自动连播开关变化
294
+ document.getElementById('autoplayToggle').addEventListener('change', function(e) {
295
+ autoplayEnabled = e.target.checked;
296
+ localStorage.setItem('autoplayEnabled', autoplayEnabled);
297
+ });
298
+
299
+ // 优先使用URL传递的集数信息,否则从localStorage获取
300
+ try {
301
+ if (episodesList) {
302
+ // 如果URL中有集数数据,优先使用它
303
+ currentEpisodes = JSON.parse(decodeURIComponent(episodesList));
304
+ console.log('从URL恢复集数信息:', currentEpisodes.length);
305
+ } else {
306
+ // 否则从localStorage获取
307
+ currentEpisodes = JSON.parse(localStorage.getItem('currentEpisodes') || '[]');
308
+ console.log('从localStorage恢复集数信息:', currentEpisodes.length);
309
+ }
310
+
311
+ // 检查集数索引是否有效,如果无效则调整为0
312
+ if (index < 0 || (currentEpisodes.length > 0 && index >= currentEpisodes.length)) {
313
+ console.warn(`无效的剧集索引 ${index},调整为范围内的值`);
314
+
315
+ // 如果索引太大,则使用最大有效索引
316
+ if (index >= currentEpisodes.length && currentEpisodes.length > 0) {
317
+ index = currentEpisodes.length - 1;
318
+ } else {
319
+ index = 0;
320
+ }
321
+
322
+ // 更新URL以反映修正后的索引
323
+ const newUrl = new URL(window.location.href);
324
+ newUrl.searchParams.set('index', index);
325
+ window.history.replaceState({}, '', newUrl);
326
+ }
327
+
328
+ // 更新当前索引为验证过的值
329
+ currentEpisodeIndex = index;
330
+
331
+ episodesReversed = localStorage.getItem('episodesReversed') === 'true';
332
+ } catch (e) {
333
+ console.error('获取集数信息失败:', e);
334
+ currentEpisodes = [];
335
+ currentEpisodeIndex = 0;
336
+ episodesReversed = false;
337
+ }
338
+
339
+ // 设置页面标题
340
+ document.title = currentVideoTitle + ' - LibreTV播放器';
341
+ document.getElementById('videoTitle').textContent = currentVideoTitle;
342
+
343
+ // 初始化播放器
344
+ if (videoUrl) {
345
+ initPlayer(videoUrl);
346
+
347
+ // 尝试从URL参数中恢复播放位置
348
+ const position = urlParams.get('position');
349
+ if (position) {
350
+ setTimeout(() => {
351
+ if (dp && dp.video) {
352
+ const positionNum = parseInt(position);
353
+ if (!isNaN(positionNum) && positionNum > 0) {
354
+ dp.seek(positionNum);
355
+ showPositionRestoreHint(positionNum);
356
+ }
357
+ }
358
+ }, 1500);
359
+ }
360
+ } else {
361
+ showError('无效的视频链接');
362
+ }
363
+
364
+ // 更新集数信息
365
+ updateEpisodeInfo();
366
+
367
+ // 渲染集数列表
368
+ renderEpisodes();
369
+
370
+ // 更新按钮状态
371
+ updateButtonStates();
372
+
373
+ // 更新排序按钮状态
374
+ updateOrderButton();
375
+
376
+ // 添加对进度条的监听,确保点击准确跳转
377
+ setTimeout(() => {
378
+ setupProgressBarPreciseClicks();
379
+ }, 1000);
380
+
381
+ // 添加键盘快捷键事件监听
382
+ document.addEventListener('keydown', handleKeyboardShortcuts);
383
+
384
+ // 添加页面离开事件监听,保存播放位置
385
+ window.addEventListener('beforeunload', saveCurrentProgress);
386
+
387
+ // 新增:页面隐藏(切后台/切标签)时也保存
388
+ document.addEventListener('visibilitychange', function() {
389
+ if (document.visibilityState === 'hidden') {
390
+ saveCurrentProgress();
391
+ }
392
+ });
393
+
394
+ // 新增:视频暂停时也保存
395
+ // 需确保 dp.video 已初始化
396
+ const waitForVideo = setInterval(() => {
397
+ if (dp && dp.video) {
398
+ dp.video.addEventListener('pause', saveCurrentProgress);
399
+
400
+ // 新增:播放进度变化时节流保存
401
+ let lastSave = 0;
402
+ dp.video.addEventListener('timeupdate', function() {
403
+ const now = Date.now();
404
+ if (now - lastSave > 5000) { // 每5秒最多保存一次
405
+ saveCurrentProgress();
406
+ lastSave = now;
407
+ }
408
+ });
409
+
410
+ clearInterval(waitForVideo);
411
+ }
412
+ }, 200);
413
+ });
414
+
415
+ // 处理键盘快捷键
416
+ function handleKeyboardShortcuts(e) {
417
+ // 忽略输入框中的按键事件
418
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
419
+
420
+ // Alt + 左箭头 = 上一集
421
+ if (e.altKey && e.key === 'ArrowLeft') {
422
+ if (currentEpisodeIndex > 0) {
423
+ playPreviousEpisode();
424
+ showShortcutHint('上一集', 'left');
425
+ e.preventDefault();
426
+ }
427
+ }
428
+
429
+ // Alt + 右箭头 = 下一集
430
+ if (e.altKey && e.key === 'ArrowRight') {
431
+ if (currentEpisodeIndex < currentEpisodes.length - 1) {
432
+ playNextEpisode();
433
+ showShortcutHint('下一集', 'right');
434
+ e.preventDefault();
435
+ }
436
+ }
437
+ }
438
+
439
+ // 显示快捷键提示
440
+ function showShortcutHint(text, direction) {
441
+ const hintElement = document.getElementById('shortcutHint');
442
+ const textElement = document.getElementById('shortcutText');
443
+ const iconElement = document.getElementById('shortcutIcon');
444
+
445
+ // 清除之前的超时
446
+ if (shortcutHintTimeout) {
447
+ clearTimeout(shortcutHintTimeout);
448
+ }
449
+
450
+ // 设置文本和图标方向
451
+ textElement.textContent = text;
452
+
453
+ if (direction === 'left') {
454
+ iconElement.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>';
455
+ } else {
456
+ iconElement.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>';
457
+ }
458
+
459
+ // 显示提示
460
+ hintElement.classList.add('show');
461
+
462
+ // 两秒后隐藏
463
+ shortcutHintTimeout = setTimeout(() => {
464
+ hintElement.classList.remove('show');
465
+ }, 2000);
466
+ }
467
+
468
+ // 初始化播放器
469
+ function initPlayer(videoUrl) {
470
+ if (!videoUrl) return;
471
+
472
+ // 配置HLS.js选项
473
+ const hlsConfig = {
474
+ debug: false,
475
+ loader: adFilteringEnabled ? CustomHlsJsLoader : Hls.DefaultConfig.loader,
476
+ enableWorker: true,
477
+ lowLatencyMode: false,
478
+ backBufferLength: 90,
479
+ maxBufferLength: 30,
480
+ maxMaxBufferLength: 60,
481
+ maxBufferSize: 30 * 1000 * 1000,
482
+ maxBufferHole: 0.5,
483
+ fragLoadingMaxRetry: 6,
484
+ fragLoadingMaxRetryTimeout: 64000,
485
+ fragLoadingRetryDelay: 1000,
486
+ manifestLoadingMaxRetry: 3,
487
+ manifestLoadingRetryDelay: 1000,
488
+ levelLoadingMaxRetry: 4,
489
+ levelLoadingRetryDelay: 1000,
490
+ startLevel: -1,
491
+ abrEwmaDefaultEstimate: 500000,
492
+ abrBandWidthFactor: 0.95,
493
+ abrBandWidthUpFactor: 0.7,
494
+ abrMaxWithRealBitrate: true,
495
+ stretchShortVideoTrack: true,
496
+ appendErrorMaxRetry: 5, // 增加尝试次数
497
+ liveSyncDurationCount: 3,
498
+ liveDurationInfinity: false
499
+ };
500
+
501
+ // 创建DPlayer实例
502
+ dp = new DPlayer({
503
+ container: document.getElementById('player'),
504
+ autoplay: true,
505
+ theme: '#00ccff',
506
+ preload: 'auto',
507
+ loop: false,
508
+ lang: 'zh-cn',
509
+ hotkey: true, // 启用键盘控制,包括空格暂停/播放、方向键控制进度和音量
510
+ mutex: true,
511
+ volume: 0.7,
512
+ screenshot: true, // 启用截图功能
513
+ preventClickToggle: false, // 允许点击视频切换播放/暂停
514
+ airplay: true, // 在Safari中启用AirPlay功能
515
+ chromecast: true, // 启用Chromecast投屏功能
516
+ contextmenu: [ // 自定义右键菜单
517
+ {
518
+ text: '关于 LibreTV',
519
+ link: 'https://github.com/bestzwei/LibreTV'
520
+ },
521
+ {
522
+ text: '问题反馈',
523
+ click: (player) => {
524
+ window.open('https://github.com/bestzwei/LibreTV/issues', '_blank');
525
+ }
526
+ }
527
+ ],
528
+ video: {
529
+ url: videoUrl,
530
+ type: 'hls',
531
+ pic: 'https://img.picgo.net/2025/04/12/image362e7d38b4af4a74.png', // 设置视频封面图
532
+ customType: {
533
+ hls: function(video, player) {
534
+ // 清理之前的HLS实例
535
+ if (currentHls && currentHls.destroy) {
536
+ try {
537
+ currentHls.destroy();
538
+ } catch (e) {
539
+ console.warn('销毁旧HLS实例出错:', e);
540
+ }
541
+ }
542
+
543
+ // 创建新的HLS实例
544
+ const hls = new Hls(hlsConfig);
545
+ currentHls = hls;
546
+
547
+ // 跟踪是否已经显示错误
548
+ let errorDisplayed = false;
549
+ // 跟踪是否有错误发生
550
+ let errorCount = 0;
551
+ // 跟踪视频是否开始播放
552
+ let playbackStarted = false;
553
+ // 跟踪视频是否出现bufferAppendError
554
+ let bufferAppendErrorCount = 0;
555
+
556
+ // 监听视频播放事件
557
+ video.addEventListener('playing', function() {
558
+ playbackStarted = true;
559
+ document.getElementById('loading').style.display = 'none';
560
+ document.getElementById('error').style.display = 'none';
561
+ });
562
+
563
+ // 监听视频进度事件
564
+ video.addEventListener('timeupdate', function() {
565
+ if (video.currentTime > 1) {
566
+ // 视频进度超过1秒,隐藏错误(如果存在)
567
+ document.getElementById('error').style.display = 'none';
568
+ }
569
+ });
570
+
571
+ hls.loadSource(video.src);
572
+ hls.attachMedia(video);
573
+
574
+ hls.on(Hls.Events.MANIFEST_PARSED, function() {
575
+ video.play().catch(e => {
576
+ console.warn('自动播放被阻止:', e);
577
+ });
578
+ });
579
+
580
+ hls.on(Hls.Events.ERROR, function(event, data) {
581
+ console.log('HLS事件:', event, '数据:', data);
582
+
583
+ // 增加错误计数
584
+ errorCount++;
585
+
586
+ // 处理bufferAppendError
587
+ if (data.details === 'bufferAppendError') {
588
+ bufferAppendErrorCount++;
589
+ console.warn(`bufferAppendError 发生 ${bufferAppendErrorCount} 次`);
590
+
591
+ // 如果视频已经开始播放,则忽略这个错误
592
+ if (playbackStarted) {
593
+ console.log('视频已在播放中,忽略bufferAppendError');
594
+ return;
595
+ }
596
+
597
+ // 如果出现多次bufferAppendError但视频未播放,尝试恢复
598
+ if (bufferAppendErrorCount >= 3) {
599
+ hls.recoverMediaError();
600
+ }
601
+ }
602
+
603
+ // 如果是致命错误,且视频未播放
604
+ if (data.fatal && !playbackStarted) {
605
+ console.error('致命HLS错误:', data);
606
+
607
+ // 尝试恢复错误
608
+ switch(data.type) {
609
+ case Hls.ErrorTypes.NETWORK_ERROR:
610
+ console.log("尝试恢复网络错误");
611
+ hls.startLoad();
612
+ break;
613
+ case Hls.ErrorTypes.MEDIA_ERROR:
614
+ console.log("尝试恢复媒体错误");
615
+ hls.recoverMediaError();
616
+ break;
617
+ default:
618
+ // 仅在多次恢复尝试后显示错误
619
+ if (errorCount > 3 && !errorDisplayed) {
620
+ errorDisplayed = true;
621
+ showError('视频加载失败,可能是格式不兼容或源不可用');
622
+ }
623
+ break;
624
+ }
625
+ }
626
+ });
627
+
628
+ // 监听分段加载事件
629
+ hls.on(Hls.Events.FRAG_LOADED, function() {
630
+ document.getElementById('loading').style.display = 'none';
631
+ });
632
+
633
+ // 监听级别加载事件
634
+ hls.on(Hls.Events.LEVEL_LOADED, function() {
635
+ document.getElementById('loading').style.display = 'none';
636
+ });
637
+
638
+ // --- 体验加强版:黑屏快进跳广告 ---
639
+ let tmp_time_add = 0.1;
640
+ const tmp_max_buffer_length = hls.config.maxBufferLength;
641
+ hls.on(Hls.Events.FRAG_PARSED, (event, data) => {
642
+ if (data.frag.endList) {
643
+ const cur = hls.media.currentTime;
644
+ const dur = hls.media.duration || 0;
645
+ if (cur < dur) {
646
+ data.frag.endList = undefined;
647
+ // 根据 tmp_time_add 调整 buffer 长度
648
+ hls.config.maxBufferLength = tmp_time_add < 1
649
+ ? 2
650
+ : tmp_max_buffer_length;
651
+ // 重新加载并跳转
652
+ hls.loadSource(video.src);
653
+ hls.attachMedia(video);
654
+ hls.media.currentTime = cur + tmp_time_add;
655
+ // 切换下次跳转时长:0.1 ↔ 5
656
+ tmp_time_add = tmp_time_add < 1 ? 5 : 0.1;
657
+ player.video.play().catch(() => {});
658
+ } else {
659
+ player.video.pause();
660
+ }
661
+ }
662
+ });
663
+ }
664
+ }
665
+ }
666
+ });
667
+ // 全屏模式下锁定横屏
668
+ dp.on('fullscreen', () => {
669
+ if (window.screen.orientation && window.screen.orientation.lock) {
670
+ window.screen.orientation.lock('landscape')
671
+ .then(() => {
672
+ console.log('屏幕已锁定为横向模式');
673
+ })
674
+ .catch((error) => {
675
+ console.warn('无法锁定屏幕方向,请手动旋转设备:', error);
676
+ });
677
+ } else {
678
+ console.warn('当前浏览器不支持锁定屏幕方向,请手动旋转设备。');
679
+ }
680
+ });
681
+
682
+ // 全屏取消时解锁屏幕方向
683
+ dp.on('fullscreen_cancel', () => {
684
+ if (window.screen.orientation && window.screen.orientation.unlock) {
685
+ window.screen.orientation.unlock();
686
+ }
687
+ });
688
+
689
+ dp.on('loadedmetadata', function() {
690
+ document.getElementById('loading').style.display = 'none';
691
+ videoHasEnded = false; // 视频加载时重置结束标志
692
+
693
+ // 视频加载完成后重新设置进度条点击监听
694
+ setupProgressBarPreciseClicks();
695
+
696
+ // 视频加载成功后,在稍微延迟后将其添加到观看历史
697
+ setTimeout(saveToHistory, 3000);
698
+
699
+ // 启动定期保存播放进度
700
+ startProgressSaveInterval();
701
+ });
702
+
703
+ dp.on('error', function() {
704
+ // 检查视频是否已经在播放
705
+ if (dp.video && dp.video.currentTime > 1) {
706
+ console.log('发生错误,但视频已在播放中,忽略');
707
+ return;
708
+ }
709
+ showError('视频播放失败,请检查视频源或网络连接');
710
+ });
711
+
712
+ // 添加seeking和seeked事件监听器,以检测用户是否在拖动进度条
713
+ dp.on('seeking', function() {
714
+ isUserSeeking = true;
715
+ videoHasEnded = false; // 重置视频结束标志
716
+
717
+ // 如果是用户通过点击进度条设置的位置,确保准确跳转
718
+ if (userClickedPosition !== null && dp.video) {
719
+ // 确保用户的点击位置被正确应用,避免自动跳至视频末尾
720
+ const clickedTime = userClickedPosition;
721
+
722
+ // 防止跳转到视频结尾
723
+ if (Math.abs(dp.video.duration - clickedTime) < 0.5) {
724
+ // 如果点击的位置非常接近结尾,稍微减少一点时间
725
+ dp.video.currentTime = Math.max(0, clickedTime - 0.5);
726
+ } else {
727
+ dp.video.currentTime = clickedTime;
728
+ }
729
+
730
+ // 清除记录的位置
731
+ setTimeout(() => {
732
+ userClickedPosition = null;
733
+ }, 200);
734
+ }
735
+ });
736
+
737
+ // 改进seeked事件处理
738
+ dp.on('seeked', function() {
739
+ // 如果视频跳转到了非常接近结尾的位置(小于0.3秒),且不是自然播放到此处
740
+ if (dp.video && dp.video.duration > 0) {
741
+ const timeFromEnd = dp.video.duration - dp.video.currentTime;
742
+ if (timeFromEnd < 0.3 && isUserSeeking) {
743
+ // 将播放时间往回移动一点点,避免触发结束事件
744
+ dp.video.currentTime = Math.max(0, dp.video.currentTime - 1);
745
+ }
746
+ }
747
+
748
+ // 延迟重置seeking标志,以便于区分自然播放结束和用户拖拽
749
+ setTimeout(() => {
750
+ isUserSeeking = false;
751
+ }, 200);
752
+ });
753
+
754
+ // 修改视频结束事件监听器,添加额外检查
755
+ dp.on('ended', function() {
756
+ videoHasEnded = true; // 标记视频已自然结束
757
+
758
+ // 视频已播放完,清除播放进度记录
759
+ clearVideoProgress();
760
+
761
+ // 如果启用了自动连播,并且有下一集可播放,则自动播放下一集
762
+ if (autoplayEnabled && currentEpisodeIndex < currentEpisodes.length - 1) {
763
+ console.log('视频播放结束,自动播放下一集');
764
+ // 稍长延迟以确保所有事件处理完成
765
+ setTimeout(() => {
766
+ // 确认不是因为用户拖拽导致的假结束事件
767
+ if (videoHasEnded && !isUserSeeking) {
768
+ playNextEpisode();
769
+ videoHasEnded = false; // 重置标志
770
+ }
771
+ }, 1000);
772
+ } else {
773
+ console.log('视频播放结束,无下一集或未启用自动连播');
774
+ }
775
+ });
776
+
777
+ // 添加事件监听以检测近视频末尾的点击拖动
778
+ dp.on('timeupdate', function() {
779
+ if (dp.video && dp.duration > 0) {
780
+ // 如果视频接近结尾但不是自然播放到结尾,重置自然结束标志
781
+ if (isUserSeeking && dp.video.currentTime > dp.video.duration * 0.95) {
782
+ videoHasEnded = false;
783
+ }
784
+ }
785
+ });
786
+
787
+ // 10秒后如果仍在加载,但不立即显示错误
788
+ setTimeout(function() {
789
+ // 如果视频已经播放开始,则不显示错误
790
+ if (dp && dp.video && dp.video.currentTime > 0) {
791
+ return;
792
+ }
793
+
794
+ if (document.getElementById('loading').style.display !== 'none') {
795
+ document.getElementById('loading').innerHTML = `
796
+ <div class="loading-spinner"></div>
797
+ <div>视频加载时间较长,请耐心等待...</div>
798
+ <div style="font-size: 12px; color: #aaa; margin-top: 10px;">如长时间无响应,请尝试其他视频源</div>
799
+ `;
800
+ }
801
+ }, 10000);
802
+
803
+ // 绑定原生全屏:DPlayer 触发全屏时调用 requestFullscreen
804
+ (function(){
805
+ const fsContainer = document.getElementById('playerContainer');
806
+ dp.on('fullscreen', () => {
807
+ if (fsContainer.requestFullscreen) {
808
+ fsContainer.requestFullscreen().catch(err => console.warn('原生全屏失败:', err));
809
+ }
810
+ });
811
+ dp.on('fullscreen_cancel', () => {
812
+ if (document.fullscreenElement) {
813
+ document.exitFullscreen();
814
+ }
815
+ });
816
+ })();
817
+ }
818
+
819
+ // 自定义M3U8 Loader用于过滤广告
820
+ class CustomHlsJsLoader extends Hls.DefaultConfig.loader {
821
+ constructor(config) {
822
+ super(config);
823
+ const load = this.load.bind(this);
824
+ this.load = function(context, config, callbacks) {
825
+ // 拦截manifest和level请求
826
+ if (context.type === 'manifest' || context.type === 'level') {
827
+ const onSuccess = callbacks.onSuccess;
828
+ callbacks.onSuccess = function(response, stats, context) {
829
+ // 如果是m3u8文件,处理内容以移除广告分段
830
+ if (response.data && typeof response.data === 'string') {
831
+ // 过滤掉广告段 - 实现更精确的广告过滤逻辑
832
+ response.data = filterAdsFromM3U8(response.data, true);
833
+ }
834
+ return onSuccess(response, stats, context);
835
+ };
836
+ }
837
+ // 执行原始load方法
838
+ load(context, config, callbacks);
839
+ };
840
+ }
841
+ }
842
+
843
+ // M3U8清单广告过滤函数
844
+ function filterAdsFromM3U8(m3u8Content, strictMode = false) {
845
+ if (!m3u8Content) return '';
846
+
847
+ // 按行分割M3U8内容
848
+ const lines = m3u8Content.split('\n');
849
+ const filteredLines = [];
850
+
851
+ for (let i = 0; i < lines.length; i++) {
852
+ const line = lines[i];
853
+
854
+ // 只过滤#EXT-X-DISCONTINUITY标识
855
+ if (!line.includes('#EXT-X-DISCONTINUITY')) {
856
+ filteredLines.push(line);
857
+ }
858
+ }
859
+
860
+ return filteredLines.join('\n');
861
+ }
862
+
863
+ // 显示错误
864
+ function showError(message) {
865
+ // 在视频已经播放的情况下不显示错误
866
+ if (dp && dp.video && dp.video.currentTime > 1) {
867
+ console.log('忽略错误:', message);
868
+ return;
869
+ }
870
+
871
+ document.getElementById('loading').style.display = 'none';
872
+ document.getElementById('error').style.display = 'flex';
873
+ document.getElementById('error-message').textContent = message;
874
+ }
875
+
876
+ // 更新集数信息
877
+ function updateEpisodeInfo() {
878
+ if (currentEpisodes.length > 0) {
879
+ document.getElementById('episodeInfo').textContent = `第 ${currentEpisodeIndex + 1}/${currentEpisodes.length} 集`;
880
+ } else {
881
+ document.getElementById('episodeInfo').textContent = '无集数信息';
882
+ }
883
+ }
884
+
885
+ // 更新按钮状态
886
+ function updateButtonStates() {
887
+ const prevButton = document.getElementById('prevButton');
888
+ const nextButton = document.getElementById('nextButton');
889
+
890
+ // 处理上一集按钮
891
+ if (currentEpisodeIndex > 0) {
892
+ prevButton.classList.remove('bg-gray-700', 'cursor-not-allowed');
893
+ prevButton.classList.add('bg-[#222]', 'hover:bg-[#333]');
894
+ prevButton.removeAttribute('disabled');
895
+ } else {
896
+ prevButton.classList.add('bg-gray-700', 'cursor-not-allowed');
897
+ prevButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');
898
+ prevButton.setAttribute('disabled', '');
899
+ }
900
+
901
+ // 处理下一集按钮
902
+ if (currentEpisodeIndex < currentEpisodes.length - 1) {
903
+ nextButton.classList.remove('bg-gray-700', 'cursor-not-allowed');
904
+ nextButton.classList.add('bg-[#222]', 'hover:bg-[#333]');
905
+ nextButton.removeAttribute('disabled');
906
+ } else {
907
+ nextButton.classList.add('bg-gray-700', 'cursor-not-allowed');
908
+ nextButton.classList.remove('bg-[#222]', 'hover:bg-[#333]');
909
+ nextButton.setAttribute('disabled', '');
910
+ }
911
+ }
912
+
913
+ // 渲染集数按钮
914
+ function renderEpisodes() {
915
+ const episodesList = document.getElementById('episodesList');
916
+ if (!episodesList) return;
917
+
918
+ if (!currentEpisodes || currentEpisodes.length === 0) {
919
+ episodesList.innerHTML = '<div class="col-span-full text-center text-gray-400 py-8">没有可用的集数</div>';
920
+ return;
921
+ }
922
+
923
+ const episodes = episodesReversed ? [...currentEpisodes].reverse() : currentEpisodes;
924
+ let html = '';
925
+
926
+ episodes.forEach((episode, index) => {
927
+ // 根据倒序状态计算真实的剧集索引
928
+ const realIndex = episodesReversed ? currentEpisodes.length - 1 - index : index;
929
+ const isActive = realIndex === currentEpisodeIndex;
930
+
931
+ html += `
932
+ <button id="episode-${realIndex}"
933
+ onclick="playEpisode(${realIndex})"
934
+ class="px-4 py-2 ${isActive ? 'episode-active' : 'bg-[#222] hover:bg-[#333]'} border ${isActive ? 'border-blue-500' : 'border-[#333]'} rounded-lg transition-colors text-center episode-btn">
935
+ 第${realIndex + 1}集
936
+ </button>
937
+ `;
938
+ });
939
+
940
+ episodesList.innerHTML = html;
941
+ }
942
+
943
+ // 播放指定集数
944
+ function playEpisode(index) {
945
+ // 确保index在有效范围内
946
+ if (index < 0 || index >= currentEpisodes.length) {
947
+ console.error(`无效的剧集索引: ${index}, 当前剧集数量: ${currentEpisodes.length}`);
948
+ showToast(`无效的剧集索引: ${index + 1},当前剧集总数: ${currentEpisodes.length}`);
949
+ return;
950
+ }
951
+
952
+ // 保存当前播放进度(如果正在播放)
953
+ if (dp && dp.video && !dp.video.paused && !videoHasEnded) {
954
+ saveCurrentProgress();
955
+ }
956
+
957
+ // 清除进度保存计时器
958
+ if (progressSaveInterval) {
959
+ clearInterval(progressSaveInterval);
960
+ progressSaveInterval = null;
961
+ }
962
+
963
+ // 首先隐藏之前可能显示的错误
964
+ document.getElementById('error').style.display = 'none';
965
+ // 显示加载指示器
966
+ document.getElementById('loading').style.display = 'flex';
967
+ document.getElementById('loading').innerHTML = `
968
+ <div class="loading-spinner"></div>
969
+ <div>正在加载视频...</div>
970
+ `;
971
+
972
+ const url = currentEpisodes[index];
973
+ currentEpisodeIndex = index;
974
+ videoHasEnded = false; // 重置视频结束标志
975
+
976
+ // 获取当前URL参数,保留source参数
977
+ const urlParams = new URLSearchParams(window.location.search);
978
+ const sourceName = urlParams.get('source') || '';
979
+
980
+ // 更新URL,不刷新页面,保留source参数
981
+ const newUrl = new URL(window.location.href);
982
+ newUrl.searchParams.set('index', index);
983
+ newUrl.searchParams.set('url', url);
984
+ if (sourceName) {
985
+ newUrl.searchParams.set('source', sourceName);
986
+ }
987
+ window.history.pushState({}, '', newUrl);
988
+
989
+ // 更新播放器
990
+ if (dp) {
991
+ try {
992
+ dp.switchVideo({
993
+ url: url,
994
+ type: 'hls'
995
+ });
996
+
997
+ // 确保播放开始
998
+ const playPromise = dp.play();
999
+ if (playPromise !== undefined) {
1000
+ playPromise.catch(error => {
1001
+ console.warn('播放失败,尝试重新初始化:', error);
1002
+ // 如果切换视频失败,重新初始化播放器
1003
+ initPlayer(url);
1004
+ });
1005
+ }
1006
+ } catch (e) {
1007
+ console.error('切换视频出错,尝试重新初始化:', e);
1008
+ // 如果出错,重新初始化播放器
1009
+ initPlayer(url);
1010
+ }
1011
+ } else {
1012
+ initPlayer(url);
1013
+ }
1014
+
1015
+ // 更新UI
1016
+ updateEpisodeInfo();
1017
+ updateButtonStates();
1018
+ renderEpisodes();
1019
+
1020
+ // 重置用户点击位置记录
1021
+ userClickedPosition = null;
1022
+
1023
+ // 三秒后保存到历史记录
1024
+ setTimeout(() => saveToHistory(), 3000);
1025
+ }
1026
+
1027
+ // 播放上一集
1028
+ function playPreviousEpisode() {
1029
+ if (currentEpisodeIndex > 0) {
1030
+ playEpisode(currentEpisodeIndex - 1);
1031
+ }
1032
+ }
1033
+
1034
+ // 播放下一集
1035
+ function playNextEpisode() {
1036
+ if (currentEpisodeIndex < currentEpisodes.length - 1) {
1037
+ playEpisode(currentEpisodeIndex + 1);
1038
+ }
1039
+ }
1040
+
1041
+ // 切换集数排序
1042
+ function toggleEpisodeOrder() {
1043
+ episodesReversed = !episodesReversed;
1044
+
1045
+ // 保存到localStorage
1046
+ localStorage.setItem('episodesReversed', episodesReversed);
1047
+
1048
+ // 重新渲染集数列表
1049
+ renderEpisodes();
1050
+
1051
+ // 更新排序按钮
1052
+ updateOrderButton();
1053
+ }
1054
+
1055
+ // 更新排序按钮状态
1056
+ function updateOrderButton() {
1057
+ const orderText = document.getElementById('orderText');
1058
+ const orderIcon = document.getElementById('orderIcon');
1059
+
1060
+ if (orderText && orderIcon) {
1061
+ orderText.textContent = episodesReversed ? '正序排列' : '倒序排列';
1062
+ orderIcon.style.transform = episodesReversed ? 'rotate(180deg)' : '';
1063
+ }
1064
+ }
1065
+
1066
+ // 设置进度条准确点击处理
1067
+ function setupProgressBarPreciseClicks() {
1068
+ // 查找DPlayer的进度条元素
1069
+ const progressBar = document.querySelector('.dplayer-bar-wrap');
1070
+ if (!progressBar || !dp || !dp.video) return;
1071
+
1072
+ // 移除可能存在的旧事件监听器
1073
+ progressBar.removeEventListener('mousedown', handleProgressBarClick);
1074
+
1075
+ // 添加新的事件监听器
1076
+ progressBar.addEventListener('mousedown', handleProgressBarClick);
1077
+
1078
+ // 在移动端也添加触摸事件支持
1079
+ progressBar.removeEventListener('touchstart', handleProgressBarTouch);
1080
+ progressBar.addEventListener('touchstart', handleProgressBarTouch);
1081
+
1082
+ console.log('进度条精确点击监听器已设置');
1083
+ }
1084
+
1085
+ // 处理进度条点击
1086
+ function handleProgressBarClick(e) {
1087
+ if (!dp || !dp.video) return;
1088
+
1089
+ // 计算点击位置相对于进度条的比例
1090
+ const rect = e.currentTarget.getBoundingClientRect();
1091
+ const percentage = (e.clientX - rect.left) / rect.width;
1092
+
1093
+ // 计算点击位置对应的视频时间
1094
+ const duration = dp.video.duration;
1095
+ let clickTime = percentage * duration;
1096
+
1097
+ // 处理视频接近结尾的情况
1098
+ if (duration - clickTime < 1) {
1099
+ // 如果点击位置非常接近结尾,稍微往前移一点
1100
+ clickTime = Math.min(clickTime, duration - 1.5);
1101
+ console.log(`进度条点击接近结尾,调整时间为 ${clickTime.toFixed(2)}/${duration.toFixed(2)}`);
1102
+ }
1103
+
1104
+ // 记录用户点击的位置
1105
+ userClickedPosition = clickTime;
1106
+
1107
+ // 输出调试信息
1108
+ console.log(`进度条点击: ${percentage.toFixed(4)}, 时间: ${clickTime.toFixed(2)}/${duration.toFixed(2)}`);
1109
+
1110
+ // 阻止默认事件传播,避免DPlayer内部逻辑将视频跳至末尾
1111
+ e.stopPropagation();
1112
+
1113
+ // 直接设置视频时间
1114
+ dp.seek(clickTime);
1115
+ }
1116
+
1117
+ // 处理移动端触摸事件
1118
+ function handleProgressBarTouch(e) {
1119
+ if (!dp || !dp.video || !e.touches[0]) return;
1120
+
1121
+ const touch = e.touches[0];
1122
+ const rect = e.currentTarget.getBoundingClientRect();
1123
+ const percentage = (touch.clientX - rect.left) / rect.width;
1124
+
1125
+ const duration = dp.video.duration;
1126
+ let clickTime = percentage * duration;
1127
+
1128
+ // 处理视频接近结尾的情况
1129
+ if (duration - clickTime < 1) {
1130
+ clickTime = Math.min(clickTime, duration - 1.5);
1131
+ }
1132
+
1133
+ // 记录用户点击的位置
1134
+ userClickedPosition = clickTime;
1135
+
1136
+ console.log(`进度条触摸: ${percentage.toFixed(4)}, 时间: ${clickTime.toFixed(2)}/${duration.toFixed(2)}`);
1137
+
1138
+ e.stopPropagation();
1139
+ dp.seek(clickTime);
1140
+ }
1141
+
1142
+ // 在播放器初始化后添加视频到历史记录
1143
+ function saveToHistory() {
1144
+ // 确保 currentEpisodes 非空
1145
+ if (!currentEpisodes || currentEpisodes.length === 0) {
1146
+ console.warn('没有可用的剧集列表,无法保存完整的历史记录');
1147
+ }
1148
+
1149
+ // 尝试从URL中获取参数
1150
+ const urlParams = new URLSearchParams(window.location.search);
1151
+ const sourceName = urlParams.get('source') || '';
1152
+
1153
+ // 获取当前播放进度
1154
+ let currentPosition = 0;
1155
+ let videoDuration = 0;
1156
+
1157
+ if (dp && dp.video) {
1158
+ currentPosition = dp.video.currentTime;
1159
+ videoDuration = dp.video.duration;
1160
+ }
1161
+
1162
+ // 构建要保存的视频信息对象
1163
+ const videoInfo = {
1164
+ title: currentVideoTitle,
1165
+ // 创建基础URL,使用标题作为唯一标识符
1166
+ url: `player.html?title=${encodeURIComponent(currentVideoTitle)}&source=${encodeURIComponent(sourceName)}`,
1167
+ episodeIndex: currentEpisodeIndex,
1168
+ sourceName: sourceName,
1169
+ timestamp: Date.now(),
1170
+ // 添加播放进度信息
1171
+ playbackPosition: currentPosition > 10 ? currentPosition : 0,
1172
+ duration: videoDuration,
1173
+ // 重要:保存完整的集数列表,确保进行深拷贝
1174
+ episodes: currentEpisodes && currentEpisodes.length > 0 ? [...currentEpisodes] : []
1175
+ };
1176
+
1177
+ // 如果外部定义了addToViewingHistory函数,则调用它
1178
+ if (typeof addToViewingHistory === 'function') {
1179
+ addToViewingHistory(videoInfo);
1180
+ console.log(`已保存 "${currentVideoTitle}" 的历史记录, 集数数据: ${currentEpisodes.length}集`);
1181
+ } else {
1182
+ // 否则直接使用本地实现
1183
+ try {
1184
+ const history = JSON.parse(localStorage.getItem('viewingHistory') || '[]');
1185
+
1186
+ // 检查是否已经存在相同标题的记录(同一视频的不同集数)
1187
+ const existingIndex = history.findIndex(item => item.title === videoInfo.title);
1188
+ if (existingIndex !== -1) {
1189
+ // 存在则更新现有记录的集数、时间戳和URL
1190
+ history[existingIndex].episodeIndex = currentEpisodeIndex;
1191
+ history[existingIndex].timestamp = Date.now();
1192
+ // 更新播放进度信息
1193
+ history[existingIndex].playbackPosition = currentPosition > 10 ? currentPosition : history[existingIndex].playbackPosition;
1194
+ history[existingIndex].duration = videoDuration || history[existingIndex].duration;
1195
+ // 同时更新URL以保存当前的集数状态
1196
+ history[existingIndex].url = window.location.href;
1197
+ // 更新集数列表(如果有且与当前不同)
1198
+ if (currentEpisodes && currentEpisodes.length > 0) {
1199
+ // 检查是否需要更新集数数据(针对不同长度的集数列表)
1200
+ if (!history[existingIndex].episodes ||
1201
+ !Array.isArray(history[existingIndex].episodes) ||
1202
+ history[existingIndex].episodes.length !== currentEpisodes.length) {
1203
+ history[existingIndex].episodes = [...currentEpisodes]; // 深拷贝
1204
+ console.log(`更新 "${currentVideoTitle}" 的剧集数据: ${currentEpisodes.length}集`);
1205
+ }
1206
+ }
1207
+
1208
+ // 移到最前面
1209
+ const updatedItem = history.splice(existingIndex, 1)[0];
1210
+ history.unshift(updatedItem);
1211
+ } else {
1212
+ // 添加新记录到最前面,但保存完整URL以便能直接打开到正确的集数
1213
+ videoInfo.url = window.location.href;
1214
+ console.log(`创建新的历史记录: "${currentVideoTitle}", ${currentEpisodes.length}集`);
1215
+ history.unshift(videoInfo);
1216
+ }
1217
+
1218
+ // 限制历史记录数量为50条
1219
+ if (history.length > 50) history.splice(50);
1220
+
1221
+ localStorage.setItem('viewingHistory', JSON.stringify(history));
1222
+ } catch (e) {
1223
+ console.error('保存观看历史失败:', e);
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ // 显示恢复位置提示
1229
+ function showPositionRestoreHint(position) {
1230
+ if (!position || position < 10) return;
1231
+
1232
+ // 创建提示元素
1233
+ const hint = document.createElement('div');
1234
+ hint.className = 'position-restore-hint';
1235
+ hint.innerHTML = `
1236
+ <div class="hint-content">
1237
+ 已从 ${formatTime(position)} 继续播放
1238
+ </div>
1239
+ `;
1240
+
1241
+ // 添加到播放器容器
1242
+ const playerContainer = document.querySelector('.player-container');
1243
+ playerContainer.appendChild(hint);
1244
+
1245
+ // 显示提示
1246
+ setTimeout(() => {
1247
+ hint.classList.add('show');
1248
+
1249
+ // 3秒后隐藏
1250
+ setTimeout(() => {
1251
+ hint.classList.remove('show');
1252
+ setTimeout(() => hint.remove(), 300);
1253
+ }, 3000);
1254
+ }, 100);
1255
+ }
1256
+
1257
+ // 格式化时间为 mm:ss 格式
1258
+ function formatTime(seconds) {
1259
+ if (isNaN(seconds)) return '00:00';
1260
+
1261
+ const minutes = Math.floor(seconds / 60);
1262
+ const remainingSeconds = Math.floor(seconds % 60);
1263
+
1264
+ return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
1265
+ }
1266
+
1267
+ // 开始定期保存播放进度
1268
+ function startProgressSaveInterval() {
1269
+ // 清除可能存在的旧计时器
1270
+ if (progressSaveInterval) {
1271
+ clearInterval(progressSaveInterval);
1272
+ }
1273
+
1274
+ // 每30秒保存一次播放进度
1275
+ progressSaveInterval = setInterval(saveCurrentProgress, 30000);
1276
+ }
1277
+
1278
+ // 保存当前播放进度
1279
+ function saveCurrentProgress() {
1280
+ if (!dp || !dp.video) return;
1281
+ const currentTime = dp.video.currentTime;
1282
+ const duration = dp.video.duration;
1283
+ if (!duration || currentTime < 1) return;
1284
+
1285
+ // 在localStorage中保存进度
1286
+ const progressKey = `videoProgress_${getVideoId()}`;
1287
+ const progressData = {
1288
+ position: currentTime,
1289
+ duration: duration,
1290
+ timestamp: Date.now()
1291
+ };
1292
+ try {
1293
+ localStorage.setItem(progressKey, JSON.stringify(progressData));
1294
+ // --- 新增:同步更新 viewingHistory 中的进度 ---
1295
+ try {
1296
+ const historyRaw = localStorage.getItem('viewingHistory');
1297
+ if (historyRaw) {
1298
+ const history = JSON.parse(historyRaw);
1299
+ // 用 title + 集数索引唯一标识
1300
+ const idx = history.findIndex(item =>
1301
+ item.title === currentVideoTitle &&
1302
+ (item.episodeIndex === undefined || item.episodeIndex === currentEpisodeIndex)
1303
+ );
1304
+ if (idx !== -1) {
1305
+ // 只在进度有明显变化时才更新,减少写入
1306
+ if (
1307
+ Math.abs((history[idx].playbackPosition || 0) - currentTime) > 2 ||
1308
+ Math.abs((history[idx].duration || 0) - duration) > 2
1309
+ ) {
1310
+ history[idx].playbackPosition = currentTime;
1311
+ history[idx].duration = duration;
1312
+ history[idx].timestamp = Date.now();
1313
+ localStorage.setItem('viewingHistory', JSON.stringify(history));
1314
+ }
1315
+ }
1316
+ }
1317
+ } catch (e) {
1318
+ // 忽略 viewingHistory 更新错误
1319
+ }
1320
+ } catch (e) {
1321
+ console.error('保存播放进度失败', e);
1322
+ }
1323
+ }
1324
+
1325
+ // 清除视频进度记录
1326
+ function clearVideoProgress() {
1327
+ const progressKey = `videoProgress_${getVideoId()}`;
1328
+ try {
1329
+ localStorage.removeItem(progressKey);
1330
+ console.log('已清除播放进度记录');
1331
+ } catch (e) {
1332
+ console.error('清除播放进度记录失败', e);
1333
+ }
1334
+ }
1335
+
1336
+ // 获取视频唯一标识
1337
+ function getVideoId() {
1338
+ // 使用视频标题和集数索引作为唯一标识
1339
+ return `${encodeURIComponent(currentVideoTitle)}_${currentEpisodeIndex}`;
1340
+ }
1341
+
1342
+ // 简单的Toast消息提示函数
1343
+ function showToast(message, type = 'error') {
1344
+ // 如果已有Toast,先移除它
1345
+ const existingToast = document.getElementById('custom-toast');
1346
+ if (existingToast) {
1347
+ document.body.removeChild(existingToast);
1348
+ }
1349
+
1350
+ // 创建新的Toast元素
1351
+ const toast = document.createElement('div');
1352
+ toast.id = 'custom-toast';
1353
+
1354
+ // 设置Toast样式
1355
+ toast.style.position = 'fixed';
1356
+ toast.style.top = '20px';
1357
+ toast.style.left = '50%';
1358
+ toast.style.transform = 'translateX(-50%)';
1359
+ toast.style.backgroundColor = type === 'error' ? '#f44336' : '#4caf50';
1360
+ toast.style.color = 'white';
1361
+ toast.style.padding = '12px 20px';
1362
+ toast.style.borderRadius = '4px';
1363
+ toast.style.zIndex = '10000';
1364
+ toast.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.3)';
1365
+ toast.style.opacity = '0';
1366
+ toast.style.transition = 'opacity 0.3s ease-in-out';
1367
+
1368
+ // 设置Toast内容
1369
+ toast.textContent = message;
1370
+
1371
+ // 添加到页面
1372
+ document.body.appendChild(toast);
1373
+
1374
+ // 显示Toast
1375
+ setTimeout(() => {
1376
+ toast.style.opacity = '1';
1377
+
1378
+ // 3秒后隐藏并移除
1379
+ setTimeout(() => {
1380
+ toast.style.opacity = '0';
1381
+ setTimeout(() => {
1382
+ if (toast.parentNode) {
1383
+ document.body.removeChild(toast);
1384
+ }
1385
+ }, 300);
1386
+ }, 3000);
1387
+ }, 10);
1388
+ }
1389
+
1390
+ let controlsLocked = false;
1391
+ function toggleControlsLock() {
1392
+ const container = document.getElementById('playerContainer');
1393
+ controlsLocked = !controlsLocked;
1394
+ container.classList.toggle('controls-locked', controlsLocked);
1395
+ const icon = document.getElementById('lockIcon');
1396
+ // 切换图标:锁 / 解锁
1397
+ icon.innerHTML = controlsLocked
1398
+ ? '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=\"M12 15v2m0-8V7a4 4 0 00-8 0v2m8 0H4v8h16v-8h-4z\"/>'
1399
+ : '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d=\"M15 11V7a3 3 0 00-6 0v4m-3 4h12v6H6v-6z\"/>';
1400
+ }
1401
+ </script>
1402
+ </body>
1403
+ </html>
privacy.html ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>隐私政策 - LibreTV</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <link rel="stylesheet" href="css/styles.css">
9
+ </head>
10
+ <body class="page-bg text-white">
11
+ <div class="container mx-auto px-4 py-8">
12
+ <header class="text-center mb-8">
13
+ <h1 class="text-5xl font-bold gradient-text">隐私政策</h1>
14
+ </header>
15
+ <main class="text-center">
16
+ <p class="text-gray-300 mb-4">
17
+ 我们尊重并保护您的隐私。LibreTV 不收集任何个人数据,且不会限制访问或使用本网站。
18
+ </p>
19
+ <p class="text-gray-300">
20
+ 本平台仅用于提供在线视频搜索与播放服务。所有数据均由第三方接口提供,我们不会存储或追踪用户信息。
21
+ </p>
22
+ </main>
23
+ <footer class="mt-12 text-center">
24
+ <a href="index.html" class="text-gray-400 hover:text-white transition-colors">回到首页</a>
25
+ </footer>
26
+ </div>
27
+ </body>
28
+ </html>
proxy.lua ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ -- 解码URL函数
2
+ local function decode_uri(uri)
3
+ local decoded = ngx.unescape_uri(uri)
4
+ return decoded
5
+ end
6
+
7
+ -- 直接从请求URI获取完整URL
8
+ local request_uri = ngx.var.request_uri
9
+ ngx.log(ngx.DEBUG, "完整请求URI: ", request_uri)
10
+
11
+ -- 提取/proxy/后面的部分
12
+ local _, _, target_path = string.find(request_uri, "^/proxy/(.*)")
13
+ ngx.log(ngx.DEBUG, "提取的目标路径: ", target_path or "nil")
14
+
15
+ if not target_path or target_path == "" then
16
+ ngx.status = 400
17
+ ngx.say("错误: 未提供目标URL")
18
+ return ngx.exit(400)
19
+ end
20
+
21
+ -- 解码URL
22
+ local target_url = decode_uri(target_path)
23
+ ngx.log(ngx.DEBUG, "解码后的目标URL: ", target_url)
24
+
25
+ if not target_url or target_url == "" then
26
+ ngx.status = 400
27
+ ngx.say("错误: 无法解析目标URL")
28
+ return ngx.exit(400)
29
+ end
30
+
31
+ -- 记录日志
32
+ ngx.log(ngx.STDERR, "代理请求: ", target_url)
33
+
34
+ -- 设置目标URL变量供Nginx使用
35
+ ngx.var.target_url = target_url
36
+
37
+ -- 继续执行Nginx配置的其余部分
38
+ return
robots.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ User-agent: *
2
+ Allow: /
3
+ Disallow: /js/
4
+ Disallow: /css/
5
+
6
+ Sitemap: https://libretv.is-an.org/sitemap.xml
sitemap.xml ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://libretv.is-an.org/</loc>
5
+ <lastmod>2025-04-06</lastmod>
6
+ <changefreq>weekly</changefreq>
7
+ <priority>1.0</priority>
8
+ </url>
9
+ <url>
10
+ <loc>https://libretv.is-an.org/about.html</loc>
11
+ <lastmod>2025-04-06</lastmod>
12
+ <changefreq>monthly</changefreq>
13
+ <priority>0.8</priority>
14
+ </url>
15
+ <url>
16
+ <loc>https://libretv.is-an.org/privacy.html</loc>
17
+ <lastmod>2025-04-06</lastmod>
18
+ <changefreq>monthly</changefreq>
19
+ <priority>0.5</priority>
20
+ </url>
21
+ </urlset>
vercel.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "rewrites": [
3
+ {
4
+ "source": "/proxy/:path*",
5
+ "destination": "/api/proxy/:path*"
6
+ }
7
+ ]
8
+ }
watch.html ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta http-equiv="refresh" content="0; url=player.html">
6
+ <title>重定向到播放器</title>
7
+ </head>
8
+ <body>
9
+ <p>如果您没有被自动重定向,请<a href="player.html">点击这里</a>前往播放页面。</p>
10
+ </body>
11
+ </html>