Upload 30 files
Browse files- Dockerfile +25 -0
- LICENSE +201 -0
- about.html +47 -0
- api/proxy/[...path].mjs +451 -0
- css/styles.css +784 -0
- docker-compose.yml +10 -0
- docker-entrypoint.sh +31 -0
- functions/_middleware.js +37 -0
- functions/proxy/[[path]].js +519 -0
- index.html +431 -0
- js/api.js +613 -0
- js/app.js +1011 -0
- js/config.js +205 -0
- js/douban.js +361 -0
- js/password.js +179 -0
- js/sha256.js +6 -0
- js/ui.js +621 -0
- middleware.js +48 -0
- netlify.toml +19 -0
- netlify/functions/proxy.mjs +274 -0
- nginx.conf +86 -0
- package-lock.json +106 -0
- package.json +15 -0
- player.html +1403 -0
- privacy.html +28 -0
- proxy.lua +38 -0
- robots.txt +6 -0
- sitemap.xml +21 -0
- vercel.json +8 -0
- 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="关闭历史">×</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="关闭设置">×</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">×</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, '<')
|
| 716 |
+
.replace(/>/g, '>')
|
| 717 |
+
.replace(/"/g, '"');
|
| 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, '"')}"` : '';
|
| 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, '<') ?
|
| 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, '<')}
|
| 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, '<')}
|
| 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, '"')
|
| 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, '"')}', ${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, '<')
|
| 87 |
+
.replace(/>/g, '>')
|
| 88 |
+
.replace(/"/g, '"');
|
| 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, '<')
|
| 109 |
+
.replace(/>/g, '>')
|
| 110 |
+
.replace(/"/g, '"');
|
| 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, '<')
|
| 126 |
+
.replace(/>/g, '>')
|
| 127 |
+
.replace(/"/g, '"');
|
| 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, '<')
|
| 305 |
+
.replace(/>/g, '>')
|
| 306 |
+
.replace(/"/g, '"');
|
| 307 |
+
|
| 308 |
+
const safeRate = (item.rate || "暂无")
|
| 309 |
+
.replace(/</g, '<')
|
| 310 |
+
.replace(/>/g, '>');
|
| 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, '<').replace(/>/g, '>');
|
| 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, '<')
|
| 340 |
+
.replace(/>/g, '>')
|
| 341 |
+
.replace(/"/g, '"');
|
| 342 |
+
|
| 343 |
+
const safeSource = item.sourceName ?
|
| 344 |
+
item.sourceName.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"') :
|
| 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>
|