Delete app.py
Browse files
app.py
DELETED
|
@@ -1,1855 +0,0 @@
|
|
| 1 |
-
CACHE_VERSION = "2026_fix_v3"
|
| 2 |
-
|
| 3 |
-
import os
|
| 4 |
-
import sys
|
| 5 |
-
import logging
|
| 6 |
-
from collections import deque
|
| 7 |
-
import requests
|
| 8 |
-
import json
|
| 9 |
-
import sqlite3
|
| 10 |
-
import threading
|
| 11 |
-
import time
|
| 12 |
-
import signal
|
| 13 |
-
import tarfile
|
| 14 |
-
import random
|
| 15 |
-
import re
|
| 16 |
-
from datetime import datetime, timedelta
|
| 17 |
-
from flask import Flask, request, jsonify, render_template_string, Response
|
| 18 |
-
from apscheduler.schedulers.background import BackgroundScheduler
|
| 19 |
-
|
| 20 |
-
try:
|
| 21 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 22 |
-
except ImportError:
|
| 23 |
-
print("⚠️ 未安装 huggingface_hub,请执行: pip install huggingface_hub")
|
| 24 |
-
sys.exit(1)
|
| 25 |
-
|
| 26 |
-
app = Flask(__name__)
|
| 27 |
-
|
| 28 |
-
# ========== 日志捕获 ==========
|
| 29 |
-
LOG_BUFFER = deque(maxlen=5000)
|
| 30 |
-
|
| 31 |
-
class ListHandler(logging.Handler):
|
| 32 |
-
def emit(self, record):
|
| 33 |
-
try:
|
| 34 |
-
msg = self.format(record)
|
| 35 |
-
LOG_BUFFER.append(msg)
|
| 36 |
-
except Exception:
|
| 37 |
-
self.handleError(record)
|
| 38 |
-
|
| 39 |
-
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 40 |
-
root_logger = logging.getLogger()
|
| 41 |
-
list_handler = ListHandler()
|
| 42 |
-
list_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
|
| 43 |
-
root_logger.addHandler(list_handler)
|
| 44 |
-
|
| 45 |
-
# ========== 配置 ==========
|
| 46 |
-
TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
|
| 47 |
-
HDHIVE_API_KEY = os.environ.get("HDHIVE_API_KEY")
|
| 48 |
-
HF_TOKEN = os.environ.get("HF_TOKEN")
|
| 49 |
-
DATASET_REPO = os.environ.get("DATASET_REPO", "hdhive-cache")
|
| 50 |
-
UNLOCK_PASSWORD = os.environ.get("UNLOCK_PASSWORD", "")
|
| 51 |
-
|
| 52 |
-
BACKUP_INTERVAL = 10 * 60
|
| 53 |
-
DB_PATH = "hdhive_cache.db"
|
| 54 |
-
|
| 55 |
-
next_unlock_time = None
|
| 56 |
-
processed_movies = set()
|
| 57 |
-
scheduler = BackgroundScheduler()
|
| 58 |
-
|
| 59 |
-
logging.info("🔧 启动配置检查:")
|
| 60 |
-
logging.info(f" TMDB_API_KEY: {'✅' if TMDB_API_KEY else '❌'}")
|
| 61 |
-
logging.info(f" HDHIVE_API_KEY: {'✅' if HDHIVE_API_KEY else '❌'}")
|
| 62 |
-
logging.info(f" HF_TOKEN: {'✅' if HF_TOKEN else '❌'}")
|
| 63 |
-
logging.info(f" DATASET_REPO: {DATASET_REPO}")
|
| 64 |
-
logging.info(f" 解锁密码: {'✅ 已设置' if UNLOCK_PASSWORD else '❌ 未设置'}")
|
| 65 |
-
|
| 66 |
-
def get_cache_stats():
|
| 67 |
-
conn = sqlite3.connect(DB_PATH)
|
| 68 |
-
c = conn.cursor()
|
| 69 |
-
c.execute("SELECT COUNT(*) FROM resource_cache")
|
| 70 |
-
movie_count = c.fetchone()[0]
|
| 71 |
-
c.execute("SELECT COUNT(*) FROM unlock_cache")
|
| 72 |
-
unlock_count = c.fetchone()[0]
|
| 73 |
-
conn.close()
|
| 74 |
-
return {'movies': movie_count, 'unlocks': unlock_count}
|
| 75 |
-
|
| 76 |
-
def is_cached(media_type, tmdb_id):
|
| 77 |
-
conn = sqlite3.connect(DB_PATH)
|
| 78 |
-
c = conn.cursor()
|
| 79 |
-
c.execute("SELECT 1 FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
|
| 80 |
-
row = c.fetchone()
|
| 81 |
-
conn.close()
|
| 82 |
-
return row is not None
|
| 83 |
-
|
| 84 |
-
def detect_pan_type(text):
|
| 85 |
-
if not text:
|
| 86 |
-
return 'unknown'
|
| 87 |
-
t = str(text).lower().strip()
|
| 88 |
-
PAN_RULES = {
|
| 89 |
-
'115': ['115.com', '115cdn.com', 'anxia.com', 'v.anxia.com'],
|
| 90 |
-
'baidu': ['pan.baidu.com', '百度网盘'],
|
| 91 |
-
'aliyun': ['alipan.com', 'aliyundrive.com'],
|
| 92 |
-
'quark': ['pan.quark.cn'],
|
| 93 |
-
'uc': ['drive.uc.cn'],
|
| 94 |
-
'tianyi': ['cloud.189.cn'],
|
| 95 |
-
'123pan': ['123684.com', '123865.com'],
|
| 96 |
-
'lanzou': ['lanzou'],
|
| 97 |
-
'pikpak': ['pikpak.me']
|
| 98 |
-
}
|
| 99 |
-
for k, arr in PAN_RULES.items():
|
| 100 |
-
if any(x in t for x in arr):
|
| 101 |
-
return k
|
| 102 |
-
if 'magnet:' in t or 'ed2k://' in t:
|
| 103 |
-
return 'magnet'
|
| 104 |
-
return 'unknown'
|
| 105 |
-
|
| 106 |
-
def init_db():
|
| 107 |
-
conn = sqlite3.connect(DB_PATH)
|
| 108 |
-
c = conn.cursor()
|
| 109 |
-
c.execute('''CREATE TABLE IF NOT EXISTS resource_cache (
|
| 110 |
-
media_type TEXT, tmdb_id TEXT, resource_data TEXT,
|
| 111 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 112 |
-
UNIQUE(media_type, tmdb_id)
|
| 113 |
-
)''')
|
| 114 |
-
c.execute('''CREATE TABLE IF NOT EXISTS unlock_cache (
|
| 115 |
-
slug TEXT PRIMARY KEY, link TEXT, unlock_data TEXT,
|
| 116 |
-
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 117 |
-
)''')
|
| 118 |
-
conn.commit()
|
| 119 |
-
conn.close()
|
| 120 |
-
logging.info("✅ 基础数据库表初始化完成")
|
| 121 |
-
|
| 122 |
-
def ensure_tables():
|
| 123 |
-
conn = sqlite3.connect(DB_PATH)
|
| 124 |
-
c = conn.cursor()
|
| 125 |
-
c.execute('''CREATE TABLE IF NOT EXISTS auto_unlocked (
|
| 126 |
-
slug TEXT PRIMARY KEY,
|
| 127 |
-
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 128 |
-
)''')
|
| 129 |
-
conn.commit()
|
| 130 |
-
conn.close()
|
| 131 |
-
logging.info("✅ 补充表检查完成(auto_unlocked)")
|
| 132 |
-
|
| 133 |
-
def get_cached_resources(media_type, tmdb_id):
|
| 134 |
-
conn = sqlite3.connect(DB_PATH)
|
| 135 |
-
c = conn.cursor()
|
| 136 |
-
c.execute("SELECT resource_data FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
|
| 137 |
-
row = c.fetchone()
|
| 138 |
-
conn.close()
|
| 139 |
-
if row:
|
| 140 |
-
try:
|
| 141 |
-
return json.loads(row[0])
|
| 142 |
-
except Exception:
|
| 143 |
-
return None
|
| 144 |
-
return None
|
| 145 |
-
|
| 146 |
-
def save_cached_resources(media_type, tmdb_id, resource_data):
|
| 147 |
-
if not resource_data:
|
| 148 |
-
print(f"ℹ️ 资源为空,不缓存: {media_type}/{tmdb_id}")
|
| 149 |
-
return
|
| 150 |
-
has_valid = any(
|
| 151 |
-
item.get('link') or item.get('slug') or item.get('url') or
|
| 152 |
-
item.get('download_url') or item.get('pan_url')
|
| 153 |
-
for item in resource_data
|
| 154 |
-
)
|
| 155 |
-
if not has_valid:
|
| 156 |
-
print(f"⚠️ 资源无有效字段,不缓存: {media_type}/{tmdb_id}")
|
| 157 |
-
return
|
| 158 |
-
for item in resource_data:
|
| 159 |
-
if not item.get('pan_type') or item['pan_type'] == 'unknown':
|
| 160 |
-
texts = []
|
| 161 |
-
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
|
| 162 |
-
val = item.get(field)
|
| 163 |
-
if val:
|
| 164 |
-
texts.append(str(val))
|
| 165 |
-
combined = '; '.join(texts)
|
| 166 |
-
item['pan_type'] = detect_pan_type(combined)
|
| 167 |
-
conn = sqlite3.connect(DB_PATH)
|
| 168 |
-
c = conn.cursor()
|
| 169 |
-
c.execute("INSERT OR REPLACE INTO resource_cache (media_type, tmdb_id, resource_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
|
| 170 |
-
(media_type, str(tmdb_id), json.dumps(resource_data, ensure_ascii=False)))
|
| 171 |
-
conn.commit()
|
| 172 |
-
conn.close()
|
| 173 |
-
print(f"💾 缓存保存: {media_type}/{tmdb_id}, 共 {len(resource_data)} 个资源")
|
| 174 |
-
|
| 175 |
-
def delete_cached_resources(media_type, tmdb_id):
|
| 176 |
-
conn = sqlite3.connect(DB_PATH)
|
| 177 |
-
c = conn.cursor()
|
| 178 |
-
c.execute("DELETE FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
|
| 179 |
-
conn.commit()
|
| 180 |
-
conn.close()
|
| 181 |
-
print(f"🗑️ 已删除缓存: {media_type}/{tmdb_id}")
|
| 182 |
-
|
| 183 |
-
def get_cached_unlock(slug):
|
| 184 |
-
conn = sqlite3.connect(DB_PATH)
|
| 185 |
-
c = conn.cursor()
|
| 186 |
-
c.execute("SELECT link, unlock_data FROM unlock_cache WHERE slug=?", (slug,))
|
| 187 |
-
row = c.fetchone()
|
| 188 |
-
conn.close()
|
| 189 |
-
if row and row[0]:
|
| 190 |
-
print(f"📦 [缓存命中] 从数据库获取解锁链接: {slug}")
|
| 191 |
-
return {'link': row[0], 'data': json.loads(row[1]) if row[1] else None}
|
| 192 |
-
print(f"❌ [缓存未命中] 数据库中无记录: {slug}")
|
| 193 |
-
return None
|
| 194 |
-
|
| 195 |
-
def save_cached_unlock(slug, link, unlock_data):
|
| 196 |
-
conn = sqlite3.connect(DB_PATH)
|
| 197 |
-
c = conn.cursor()
|
| 198 |
-
c.execute("INSERT OR REPLACE INTO unlock_cache (slug, link, unlock_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
|
| 199 |
-
(slug, link, json.dumps(unlock_data, ensure_ascii=False) if unlock_data else None))
|
| 200 |
-
conn.commit()
|
| 201 |
-
conn.close()
|
| 202 |
-
print(f"💾 [缓存保存] 解锁链接已存入数据库: {slug} -> {link[:80]}...")
|
| 203 |
-
|
| 204 |
-
api = HfApi()
|
| 205 |
-
|
| 206 |
-
def backup_to_hf_dataset():
|
| 207 |
-
if not HF_TOKEN or not DATASET_REPO or DATASET_REPO == "hdhive-cache":
|
| 208 |
-
return False
|
| 209 |
-
try:
|
| 210 |
-
day = datetime.now().strftime("%Y-%m-%d")
|
| 211 |
-
backup_name = f"backup_{day}.tar.gz"
|
| 212 |
-
with tarfile.open(backup_name, "w:gz") as tar:
|
| 213 |
-
tar.add(DB_PATH, arcname=os.path.basename(DB_PATH))
|
| 214 |
-
api.upload_file(path_or_fileobj=backup_name, path_in_repo=backup_name, repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
|
| 215 |
-
print(f"✅ 备份成功: {backup_name}")
|
| 216 |
-
os.remove(backup_name)
|
| 217 |
-
return True
|
| 218 |
-
except Exception as e:
|
| 219 |
-
print(f"❌ 备份失败: {e}")
|
| 220 |
-
return False
|
| 221 |
-
|
| 222 |
-
def restore_from_hf_dataset():
|
| 223 |
-
if not HF_TOKEN or not DATASET_REPO or DATASET_REPO == "hdhive-cache":
|
| 224 |
-
print("⚠️ 跳过恢复")
|
| 225 |
-
return False
|
| 226 |
-
try:
|
| 227 |
-
files = api.list_repo_files(repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
|
| 228 |
-
now = datetime.now()
|
| 229 |
-
for i in range(5):
|
| 230 |
-
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
|
| 231 |
-
backup_name = f"backup_{day}.tar.gz"
|
| 232 |
-
if backup_name in files:
|
| 233 |
-
print(f"📦 发现备份: {backup_name},下载中...")
|
| 234 |
-
path = hf_hub_download(repo_id=DATASET_REPO, filename=backup_name, repo_type="dataset", token=HF_TOKEN)
|
| 235 |
-
with tarfile.open(path, "r:gz") as tar:
|
| 236 |
-
tar.extractall(path=".")
|
| 237 |
-
print(f"✅ 数据库已恢复")
|
| 238 |
-
return True
|
| 239 |
-
print("📭 未找到备份")
|
| 240 |
-
return False
|
| 241 |
-
except Exception as e:
|
| 242 |
-
print(f"❌ 恢复失败: {e}")
|
| 243 |
-
return False
|
| 244 |
-
|
| 245 |
-
def periodic_backup():
|
| 246 |
-
while True:
|
| 247 |
-
time.sleep(BACKUP_INTERVAL)
|
| 248 |
-
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 执行备份...")
|
| 249 |
-
backup_to_hf_dataset()
|
| 250 |
-
|
| 251 |
-
def exit_backup(signum, frame):
|
| 252 |
-
print(f"收到退出信号,执行最后备份...")
|
| 253 |
-
backup_to_hf_dataset()
|
| 254 |
-
sys.exit(0)
|
| 255 |
-
|
| 256 |
-
signal.signal(signal.SIGTERM, exit_backup)
|
| 257 |
-
signal.signal(signal.SIGINT, exit_backup)
|
| 258 |
-
|
| 259 |
-
init_db()
|
| 260 |
-
restore_from_hf_dataset()
|
| 261 |
-
ensure_tables()
|
| 262 |
-
|
| 263 |
-
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
|
| 264 |
-
backup_thread.start()
|
| 265 |
-
print(f"✅ 定时备份已启动,间隔 {BACKUP_INTERVAL // 60} 分钟")
|
| 266 |
-
|
| 267 |
-
# ========== 图片代理接口 ==========
|
| 268 |
-
@app.route('/api/image/proxy')
|
| 269 |
-
def image_proxy():
|
| 270 |
-
img_url = request.args.get('url', '')
|
| 271 |
-
if not img_url:
|
| 272 |
-
return '', 400
|
| 273 |
-
if not img_url.startswith('https://image.tmdb.org/'):
|
| 274 |
-
return '', 403
|
| 275 |
-
try:
|
| 276 |
-
resp = requests.get(img_url, timeout=30, headers={
|
| 277 |
-
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
| 278 |
-
'Referer': 'https://www.themoviedb.org/',
|
| 279 |
-
})
|
| 280 |
-
if resp.status_code == 200:
|
| 281 |
-
return Response(resp.content, status=200, content_type=resp.headers.get('Content-Type', 'image/jpeg'))
|
| 282 |
-
else:
|
| 283 |
-
print(f"图片代理失败: {img_url}, 状态码: {resp.status_code}")
|
| 284 |
-
return '', 500
|
| 285 |
-
except requests.exceptions.Timeout:
|
| 286 |
-
print(f"图片代理超时: {img_url}")
|
| 287 |
-
return '', 504
|
| 288 |
-
except Exception as e:
|
| 289 |
-
print(f"图片代理异常: {e}, URL: {img_url}")
|
| 290 |
-
return '', 500
|
| 291 |
-
|
| 292 |
-
# ========== 密码验证接口 ==========
|
| 293 |
-
@app.route('/api/verify-password', methods=['POST'])
|
| 294 |
-
def verify_password():
|
| 295 |
-
data = request.json
|
| 296 |
-
password = data.get('password', '')
|
| 297 |
-
if UNLOCK_PASSWORD and password == UNLOCK_PASSWORD:
|
| 298 |
-
return jsonify({'success': True, 'message': '密码正确'})
|
| 299 |
-
elif not UNLOCK_PASSWORD:
|
| 300 |
-
return jsonify({'success': False, 'message': '未设置解锁密码,请联系管理员'})
|
| 301 |
-
else:
|
| 302 |
-
return jsonify({'success': False, 'message': '密码错误'})
|
| 303 |
-
|
| 304 |
-
# ========== 自动解锁统计接口 ==========
|
| 305 |
-
@app.route('/api/auto-unlock-stats', methods=['GET'])
|
| 306 |
-
def auto_unlock_stats():
|
| 307 |
-
conn = sqlite3.connect(DB_PATH)
|
| 308 |
-
c = conn.cursor()
|
| 309 |
-
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='auto_unlocked'")
|
| 310 |
-
if not c.fetchone():
|
| 311 |
-
conn.close()
|
| 312 |
-
return jsonify({'unlocked_resources': 0})
|
| 313 |
-
c.execute("SELECT COUNT(*) FROM auto_unlocked")
|
| 314 |
-
unlocked_resources = c.fetchone()[0]
|
| 315 |
-
conn.close()
|
| 316 |
-
return jsonify({'unlocked_resources': unlocked_resources})
|
| 317 |
-
|
| 318 |
-
@app.route('/api/next-unlock-time', methods=['GET'])
|
| 319 |
-
def get_next_unlock_time():
|
| 320 |
-
global next_unlock_time
|
| 321 |
-
if next_unlock_time:
|
| 322 |
-
remaining = max(0, int(next_unlock_time - time.time()))
|
| 323 |
-
return jsonify({'remaining': remaining})
|
| 324 |
-
return jsonify({'remaining': 0})
|
| 325 |
-
|
| 326 |
-
# ========== 日志接口 ==========
|
| 327 |
-
@app.route('/api/logs')
|
| 328 |
-
def get_logs():
|
| 329 |
-
return jsonify({'logs': list(LOG_BUFFER)})
|
| 330 |
-
|
| 331 |
-
@app.route('/api/logs/clear', methods=['POST'])
|
| 332 |
-
def clear_logs():
|
| 333 |
-
LOG_BUFFER.clear()
|
| 334 |
-
return jsonify({'success': True})
|
| 335 |
-
|
| 336 |
-
# ========== TMDB 动态分类 API (仅 genre) ==========
|
| 337 |
-
@app.route('/api/tmdb/genres/<media_type>')
|
| 338 |
-
def get_tmdb_genres(media_type):
|
| 339 |
-
if media_type not in ['movie', 'tv']:
|
| 340 |
-
return jsonify({'error': 'invalid media type'}), 400
|
| 341 |
-
url = f"https://api.themoviedb.org/3/genre/{media_type}/list?api_key={TMDB_API_KEY}&language=zh-CN"
|
| 342 |
-
try:
|
| 343 |
-
resp = requests.get(url, timeout=10)
|
| 344 |
-
data = resp.json()
|
| 345 |
-
return jsonify(data.get('genres', []))
|
| 346 |
-
except Exception as e:
|
| 347 |
-
return jsonify({'error': str(e)}), 500
|
| 348 |
-
|
| 349 |
-
# ========== API 路由 ==========
|
| 350 |
-
@app.route('/')
|
| 351 |
-
def index():
|
| 352 |
-
return render_template_string(HTML_TEMPLATE)
|
| 353 |
-
|
| 354 |
-
@app.route('/api/cache/stats')
|
| 355 |
-
def cache_stats():
|
| 356 |
-
return jsonify(get_cache_stats())
|
| 357 |
-
|
| 358 |
-
@app.route('/api/discover', methods=['POST'])
|
| 359 |
-
def discover():
|
| 360 |
-
data = request.json
|
| 361 |
-
media_type = data.get('type', 'movie')
|
| 362 |
-
page = data.get('page', 1)
|
| 363 |
-
preset = data.get('preset')
|
| 364 |
-
filters = data.get('filters', {})
|
| 365 |
-
sort_by = filters.get('sort_by', 'latest')
|
| 366 |
-
|
| 367 |
-
def build_discover_url(base_params):
|
| 368 |
-
url = f"https://api.themoviedb.org/3/discover/{media_type}?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
|
| 369 |
-
for k, v in base_params.items():
|
| 370 |
-
url += f"&{k}={v}"
|
| 371 |
-
region = filters.get('region')
|
| 372 |
-
if region:
|
| 373 |
-
region_code = {
|
| 374 |
-
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
|
| 375 |
-
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
|
| 376 |
-
}.get(region, '')
|
| 377 |
-
if region_code:
|
| 378 |
-
if media_type == 'movie':
|
| 379 |
-
url += f"&with_origin_country={region_code}"
|
| 380 |
-
else:
|
| 381 |
-
url += f"&with_origin_country={region_code}"
|
| 382 |
-
year = filters.get('year')
|
| 383 |
-
if year:
|
| 384 |
-
if media_type == 'movie':
|
| 385 |
-
if year == '2010年前':
|
| 386 |
-
url += "&primary_release_date.lte=2009-12-31"
|
| 387 |
-
else:
|
| 388 |
-
url += f"&primary_release_year={year}"
|
| 389 |
-
else:
|
| 390 |
-
if year == '2010年前':
|
| 391 |
-
url += "&first_air_date.lte=2009-12-31"
|
| 392 |
-
else:
|
| 393 |
-
url += f"&first_air_date_year={year}"
|
| 394 |
-
genres = filters.get('genres', [])
|
| 395 |
-
if genres:
|
| 396 |
-
genres_str = '|'.join(str(g) for g in genres)
|
| 397 |
-
url += f"&with_genres={genres_str}"
|
| 398 |
-
return url
|
| 399 |
-
|
| 400 |
-
if preset:
|
| 401 |
-
preset_map_movie = {
|
| 402 |
-
'popular': {'sort_by': 'popularity.desc'},
|
| 403 |
-
'now_playing': {'sort_by': 'primary_release_date.desc'},
|
| 404 |
-
'top_rated': {'sort_by': 'vote_average.desc', 'vote_count.gte': 30},
|
| 405 |
-
'upcoming': {'sort_by': 'primary_release_date.asc'}
|
| 406 |
-
}
|
| 407 |
-
preset_map_tv = {
|
| 408 |
-
'popular': {'sort_by': 'popularity.desc'},
|
| 409 |
-
'top_rated': {'sort_by': 'vote_average.desc', 'vote_count.gte': 30},
|
| 410 |
-
'on_the_air': {'sort_by': 'first_air_date.desc'},
|
| 411 |
-
'airing_today': {'sort_by': 'first_air_date.desc'}
|
| 412 |
-
}
|
| 413 |
-
if media_type == 'movie':
|
| 414 |
-
base_params = preset_map_movie.get(preset, {'sort_by': 'popularity.desc'})
|
| 415 |
-
else:
|
| 416 |
-
base_params = preset_map_tv.get(preset, {'sort_by': 'popularity.desc'})
|
| 417 |
-
if filters.get('sort_by'):
|
| 418 |
-
user_sort = filters['sort_by']
|
| 419 |
-
if user_sort == 'latest':
|
| 420 |
-
base_params['sort_by'] = 'primary_release_date.desc' if media_type == 'movie' else 'first_air_date.desc'
|
| 421 |
-
elif user_sort == 'imdb':
|
| 422 |
-
base_params['sort_by'] = 'vote_average.desc'
|
| 423 |
-
base_params['vote_count.gte'] = 30
|
| 424 |
-
url = build_discover_url(base_params)
|
| 425 |
-
resp = requests.get(url, timeout=15)
|
| 426 |
-
result = resp.json()
|
| 427 |
-
results = result.get('results', [])
|
| 428 |
-
for item in results:
|
| 429 |
-
mtype = media_type if media_type in ['movie', 'tv'] else (item.get('media_type') or 'movie')
|
| 430 |
-
item['cached'] = is_cached(mtype, item['id'])
|
| 431 |
-
result['results'] = results
|
| 432 |
-
return jsonify(result)
|
| 433 |
-
|
| 434 |
-
# 无预设
|
| 435 |
-
if media_type == 'movie':
|
| 436 |
-
base_url = f"https://api.themoviedb.org/3/discover/movie?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
|
| 437 |
-
if sort_by == 'latest':
|
| 438 |
-
base_url += "&sort_by=primary_release_date.desc"
|
| 439 |
-
else:
|
| 440 |
-
base_url += "&sort_by=vote_average.desc&vote_count.gte=30"
|
| 441 |
-
region = filters.get('region')
|
| 442 |
-
if region:
|
| 443 |
-
region_code = {
|
| 444 |
-
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
|
| 445 |
-
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
|
| 446 |
-
}.get(region, '')
|
| 447 |
-
if region_code:
|
| 448 |
-
base_url += f"&with_origin_country={region_code}"
|
| 449 |
-
year = filters.get('year')
|
| 450 |
-
if year:
|
| 451 |
-
if year == '2010年前':
|
| 452 |
-
base_url += "&primary_release_date.lte=2009-12-31"
|
| 453 |
-
else:
|
| 454 |
-
base_url += f"&primary_release_year={year}"
|
| 455 |
-
genres = filters.get('genres', [])
|
| 456 |
-
if genres:
|
| 457 |
-
genres_str = '|'.join(str(g) for g in genres)
|
| 458 |
-
base_url += f"&with_genres={genres_str}"
|
| 459 |
-
else:
|
| 460 |
-
base_url = f"https://api.themoviedb.org/3/discover/tv?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
|
| 461 |
-
if sort_by == 'latest':
|
| 462 |
-
base_url += "&sort_by=first_air_date.desc"
|
| 463 |
-
else:
|
| 464 |
-
base_url += "&sort_by=vote_average.desc&vote_count.gte=30"
|
| 465 |
-
region = filters.get('region')
|
| 466 |
-
if region:
|
| 467 |
-
region_code = {
|
| 468 |
-
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
|
| 469 |
-
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
|
| 470 |
-
}.get(region, '')
|
| 471 |
-
if region_code:
|
| 472 |
-
base_url += f"&with_origin_country={region_code}"
|
| 473 |
-
year = filters.get('year')
|
| 474 |
-
if year:
|
| 475 |
-
if year == '2010年前':
|
| 476 |
-
base_url += "&first_air_date.lte=2009-12-31"
|
| 477 |
-
else:
|
| 478 |
-
base_url += f"&first_air_date_year={year}"
|
| 479 |
-
genres = filters.get('genres', [])
|
| 480 |
-
if genres:
|
| 481 |
-
genres_str = '|'.join(str(g) for g in genres)
|
| 482 |
-
base_url += f"&with_genres={genres_str}"
|
| 483 |
-
resp = requests.get(base_url, timeout=15)
|
| 484 |
-
result = resp.json()
|
| 485 |
-
results = result.get('results', [])
|
| 486 |
-
for item in results:
|
| 487 |
-
mtype = media_type if media_type in ['movie', 'tv'] else (item.get('media_type') or 'movie')
|
| 488 |
-
item['cached'] = is_cached(mtype, item['id'])
|
| 489 |
-
result['results'] = results
|
| 490 |
-
return jsonify(result)
|
| 491 |
-
|
| 492 |
-
@app.route('/api/search', methods=['POST'])
|
| 493 |
-
def search_api():
|
| 494 |
-
data = request.json
|
| 495 |
-
query = data.get('query', '')
|
| 496 |
-
page = data.get('page', 1)
|
| 497 |
-
url = f"https://api.themoviedb.org/3/search/multi?api_key={TMDB_API_KEY}&query={query}&language=zh-CN&page={page}"
|
| 498 |
-
resp = requests.get(url, timeout=15)
|
| 499 |
-
result = resp.json()
|
| 500 |
-
results = result.get('results', [])
|
| 501 |
-
filtered = []
|
| 502 |
-
for item in results:
|
| 503 |
-
if item.get('media_type') in ('movie', 'tv'):
|
| 504 |
-
item['cached'] = is_cached(item['media_type'], item['id'])
|
| 505 |
-
filtered.append(item)
|
| 506 |
-
result['results'] = filtered
|
| 507 |
-
return jsonify(result)
|
| 508 |
-
|
| 509 |
-
@app.route('/api/cache/resources/<type>/<tmdb_id>')
|
| 510 |
-
def cache_resources(type, tmdb_id):
|
| 511 |
-
cached = get_cached_resources(type, tmdb_id)
|
| 512 |
-
if cached:
|
| 513 |
-
return jsonify({'success': True, 'from_cache': True, 'resources': cached})
|
| 514 |
-
if not HDHIVE_API_KEY:
|
| 515 |
-
return jsonify({"success": False, "error": "HDHIVE_API_KEY not configured"}), 500
|
| 516 |
-
url = f"https://hdhive.com/api/open/resources/{type}/{tmdb_id}"
|
| 517 |
-
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
|
| 518 |
-
resp = requests.get(url, headers=headers, timeout=30)
|
| 519 |
-
result = resp.json()
|
| 520 |
-
resources = result.get('data', []) if isinstance(result, dict) else result
|
| 521 |
-
if not resources:
|
| 522 |
-
resources = []
|
| 523 |
-
for item in resources:
|
| 524 |
-
if not item.get('pan_type') or item['pan_type'] == 'unknown':
|
| 525 |
-
texts = []
|
| 526 |
-
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
|
| 527 |
-
val = item.get(field)
|
| 528 |
-
if val:
|
| 529 |
-
texts.append(str(val))
|
| 530 |
-
combined = '; '.join(texts)
|
| 531 |
-
item['pan_type'] = detect_pan_type(combined)
|
| 532 |
-
save_cached_resources(type, tmdb_id, resources)
|
| 533 |
-
return jsonify({'success': True, 'from_cache': False, 'resources': resources})
|
| 534 |
-
|
| 535 |
-
@app.route('/api/cache/refresh/<type>/<tmdb_id>', methods=['POST'])
|
| 536 |
-
def refresh_cache(type, tmdb_id):
|
| 537 |
-
delete_cached_resources(type, tmdb_id)
|
| 538 |
-
if not HDHIVE_API_KEY:
|
| 539 |
-
return jsonify({"success": False, "error": "HDHIVE_API_KEY not configured"}), 500
|
| 540 |
-
url = f"https://hdhive.com/api/open/resources/{type}/{tmdb_id}"
|
| 541 |
-
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
|
| 542 |
-
try:
|
| 543 |
-
resp = requests.get(url, headers=headers, timeout=30)
|
| 544 |
-
result = resp.json()
|
| 545 |
-
resources = result.get('data', []) if isinstance(result, dict) else result
|
| 546 |
-
if not resources:
|
| 547 |
-
resources = []
|
| 548 |
-
for item in resources:
|
| 549 |
-
if not item.get('pan_type') or item['pan_type'] == 'unknown':
|
| 550 |
-
texts = []
|
| 551 |
-
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
|
| 552 |
-
val = item.get(field)
|
| 553 |
-
if val:
|
| 554 |
-
texts.append(str(val))
|
| 555 |
-
combined = '; '.join(texts)
|
| 556 |
-
item['pan_type'] = detect_pan_type(combined)
|
| 557 |
-
save_cached_resources(type, tmdb_id, resources)
|
| 558 |
-
return jsonify({"success": True, "message": "缓存已刷新"})
|
| 559 |
-
except Exception as e:
|
| 560 |
-
return jsonify({"success": False, "error": str(e)}), 500
|
| 561 |
-
|
| 562 |
-
@app.route('/api/cache/unlock', methods=['POST'])
|
| 563 |
-
def cache_unlock():
|
| 564 |
-
data = request.json
|
| 565 |
-
slug = data.get('slug')
|
| 566 |
-
allow = data.get('allow_points', False)
|
| 567 |
-
password = data.get('password', '')
|
| 568 |
-
|
| 569 |
-
print(f"\n{'='*60}")
|
| 570 |
-
print(f"🔓 解锁请求: slug={slug}, allow_points={allow}")
|
| 571 |
-
|
| 572 |
-
# 第一步:检查本地缓存
|
| 573 |
-
cached = get_cached_unlock(slug)
|
| 574 |
-
if cached and cached.get('link'):
|
| 575 |
-
print(f"✅ 返回缓存链接 (不消耗积分)")
|
| 576 |
-
print(f"{'='*60}\n")
|
| 577 |
-
return jsonify({'success': True, 'from_cache': True, 'link': cached['link'], 'data': cached['data']})
|
| 578 |
-
|
| 579 |
-
if not HDHIVE_API_KEY:
|
| 580 |
-
print(f"❌ HDHIVE_API_KEY 未配置")
|
| 581 |
-
print(f"{'='*60}\n")
|
| 582 |
-
return jsonify({"error": "HDHIVE_API_KEY not configured"}), 500
|
| 583 |
-
|
| 584 |
-
# 第二步:缓存未命中,需要调用 HDHive API
|
| 585 |
-
# 先获取资源积分信息(用于判断是否需要密码)
|
| 586 |
-
points = None
|
| 587 |
-
# 从 resource_cache 中查找该 slug 的积分
|
| 588 |
-
conn = sqlite3.connect(DB_PATH)
|
| 589 |
-
c = conn.cursor()
|
| 590 |
-
c.execute("SELECT resource_data FROM resource_cache")
|
| 591 |
-
for rrow in c.fetchall():
|
| 592 |
-
try:
|
| 593 |
-
resources = json.loads(rrow[0])
|
| 594 |
-
for res in resources:
|
| 595 |
-
if res.get('slug') == slug:
|
| 596 |
-
points = res.get('unlock_points', 0)
|
| 597 |
-
break
|
| 598 |
-
if points is not None:
|
| 599 |
-
break
|
| 600 |
-
except:
|
| 601 |
-
continue
|
| 602 |
-
conn.close()
|
| 603 |
-
if points is None:
|
| 604 |
-
# 抓取页面获取积分
|
| 605 |
-
try:
|
| 606 |
-
for prefix in ['115', 'baidu', 'magnet']:
|
| 607 |
-
url = f"https://hdhive.com/resource/{prefix}/{slug}"
|
| 608 |
-
resp = requests.get(url, timeout=10, headers={'User-Agent': 'Mozilla/5.0'})
|
| 609 |
-
if resp.status_code == 200:
|
| 610 |
-
html = resp.text
|
| 611 |
-
match = re.search(r'所需积分[::]\s*(\d+)', html)
|
| 612 |
-
if not match:
|
| 613 |
-
match = re.search(r'unlock_points[::]\s*(\d+)', html, re.IGNORECASE)
|
| 614 |
-
if not match:
|
| 615 |
-
match = re.search(r'<span[^>]*class="[^"]*points?[^"]*"[^>]*>(\d+)</span>', html, re.IGNORECASE)
|
| 616 |
-
if match:
|
| 617 |
-
points = int(match.group(1))
|
| 618 |
-
else:
|
| 619 |
-
points = 0
|
| 620 |
-
break
|
| 621 |
-
except Exception as e:
|
| 622 |
-
print(f"获取 points 失败: {e}")
|
| 623 |
-
points = 0
|
| 624 |
-
|
| 625 |
-
# 如果需要密码(设置了全局密码 且 资源需要积分)
|
| 626 |
-
if UNLOCK_PASSWORD and points and points > 0:
|
| 627 |
-
if not password or password != UNLOCK_PASSWORD:
|
| 628 |
-
print(f"🔐 付费资源需要密码")
|
| 629 |
-
return jsonify({'need_password': True, 'message': '付费资源需要解锁密码'}), 401
|
| 630 |
-
|
| 631 |
-
print(f"🌐 调用 HDHive API 解锁...")
|
| 632 |
-
url = "https://hdhive.com/api/open/resources/unlock"
|
| 633 |
-
headers = {"X-API-Key": HDHIVE_API_KEY, "Content-Type": "application/json"}
|
| 634 |
-
payload = {"slug": slug}
|
| 635 |
-
if allow:
|
| 636 |
-
payload["allow_points"] = True
|
| 637 |
-
|
| 638 |
-
resp = requests.post(url, headers=headers, json=payload, timeout=30)
|
| 639 |
-
result = resp.json()
|
| 640 |
-
|
| 641 |
-
if result.get('code') == 'OPENAPI_COOLDOWN':
|
| 642 |
-
print(f"⏰ API 冷却中: {result}")
|
| 643 |
-
print(f"{'='*60}\n")
|
| 644 |
-
return jsonify(result)
|
| 645 |
-
|
| 646 |
-
link = result.get('data', {}).get('full_url') or result.get('data', {}).get('url') or result.get('url')
|
| 647 |
-
if link:
|
| 648 |
-
save_cached_unlock(slug, link, result)
|
| 649 |
-
print(f"✅ 解锁成功,已缓存 (消耗积分)")
|
| 650 |
-
else:
|
| 651 |
-
print(f"❌ 解锁失败: {result}")
|
| 652 |
-
|
| 653 |
-
print(f"{'='*60}\n")
|
| 654 |
-
return jsonify({'success': True, 'from_cache': False, 'link': link, 'data': result.get('data')})
|
| 655 |
-
|
| 656 |
-
@app.route('/api/backup', methods=['POST'])
|
| 657 |
-
def manual_backup():
|
| 658 |
-
success = backup_to_hf_dataset()
|
| 659 |
-
return jsonify({"message": "备份成功" if success else "备份失败"}), 200 if success else 500
|
| 660 |
-
|
| 661 |
-
# ========== 后台自动浏览并解锁免费资源 ==========
|
| 662 |
-
MOVIE_GENRES = {
|
| 663 |
-
28: "动作", 12: "冒险", 16: "动画", 35: "喜剧", 80: "犯罪", 99: "纪录片",
|
| 664 |
-
18: "剧情", 10751: "家庭", 14: "奇幻", 36: "历史", 27: "恐怖", 10402: "音乐",
|
| 665 |
-
9648: "悬疑", 10749: "爱情", 878: "科幻", 53: "惊悚", 10752: "战争", 37: "西部"
|
| 666 |
-
}
|
| 667 |
-
TV_GENRES = {
|
| 668 |
-
10759: "动作冒险", 16: "动画", 35: "喜剧", 80: "犯罪", 99: "纪录片",
|
| 669 |
-
18: "剧情", 10751: "家庭", 10762: "儿童", 9648: "悬疑", 10763: "新闻",
|
| 670 |
-
10764: "真人秀", 10765: "科幻奇幻", 10766: "肥皂剧", 10767: "脱口秀", 10768: "政治"
|
| 671 |
-
}
|
| 672 |
-
|
| 673 |
-
def get_random_movie_from_tmdb():
|
| 674 |
-
global processed_movies
|
| 675 |
-
max_attempts = 10
|
| 676 |
-
for attempt in range(max_attempts):
|
| 677 |
-
year = random.randint(1900, 2026)
|
| 678 |
-
genre_id = random.choice(list(MOVIE_GENRES.keys()))
|
| 679 |
-
sort_options = ['popularity.desc', 'vote_average.desc', 'release_date.desc', 'primary_release_date.desc']
|
| 680 |
-
sort_by = random.choice(sort_options)
|
| 681 |
-
page = random.randint(1, 1000)
|
| 682 |
-
url = f"https://api.themoviedb.org/3/discover/movie?api_key={TMDB_API_KEY}&language=zh-CN&sort_by={sort_by}&page={page}&primary_release_year={year}&with_genres={genre_id}&vote_count.gte=1"
|
| 683 |
-
print(f"[DEBUG] 电影尝试 {attempt+1}: year={year}, genre={genre_id}, sort={sort_by}, page={page}")
|
| 684 |
-
try:
|
| 685 |
-
resp = requests.get(url, timeout=15)
|
| 686 |
-
data = resp.json()
|
| 687 |
-
results = data.get('results', [])
|
| 688 |
-
if not results:
|
| 689 |
-
continue
|
| 690 |
-
random.shuffle(results)
|
| 691 |
-
for movie in results:
|
| 692 |
-
tmdb_id = movie['id']
|
| 693 |
-
key = f"movie_{tmdb_id}"
|
| 694 |
-
if key in processed_movies:
|
| 695 |
-
continue
|
| 696 |
-
conn = sqlite3.connect(DB_PATH)
|
| 697 |
-
c = conn.cursor()
|
| 698 |
-
c.execute("SELECT 1 FROM resource_cache WHERE media_type='movie' AND tmdb_id=?", (str(tmdb_id),))
|
| 699 |
-
exists = c.fetchone() is not None
|
| 700 |
-
conn.close()
|
| 701 |
-
if not exists:
|
| 702 |
-
processed_movies.add(key)
|
| 703 |
-
print(f"✅ 找到未缓存电影: {movie.get('title', '未知')} (ID: {tmdb_id})")
|
| 704 |
-
return {
|
| 705 |
-
'media_type': 'movie',
|
| 706 |
-
'tmdb_id': tmdb_id,
|
| 707 |
-
'title': movie.get('title', '未知'),
|
| 708 |
-
'year': year,
|
| 709 |
-
'genre': MOVIE_GENRES.get(genre_id, '未知')
|
| 710 |
-
}
|
| 711 |
-
except Exception as e:
|
| 712 |
-
print(f"❌ 电影尝试 {attempt+1} 失败: {e}")
|
| 713 |
-
try:
|
| 714 |
-
url = f"https://api.themoviedb.org/3/movie/now_playing?api_key={TMDB_API_KEY}&language=zh-CN&page={random.randint(1,5)}"
|
| 715 |
-
resp = requests.get(url, timeout=15)
|
| 716 |
-
data = resp.json()
|
| 717 |
-
results = data.get('results', [])
|
| 718 |
-
random.shuffle(results)
|
| 719 |
-
for movie in results:
|
| 720 |
-
tmdb_id = movie['id']
|
| 721 |
-
key = f"movie_{tmdb_id}"
|
| 722 |
-
if key in processed_movies:
|
| 723 |
-
continue
|
| 724 |
-
conn = sqlite3.connect(DB_PATH)
|
| 725 |
-
c = conn.cursor()
|
| 726 |
-
c.execute("SELECT 1 FROM resource_cache WHERE media_type='movie' AND tmdb_id=?", (str(tmdb_id),))
|
| 727 |
-
exists = c.fetchone() is not None
|
| 728 |
-
conn.close()
|
| 729 |
-
if not exists:
|
| 730 |
-
processed_movies.add(key)
|
| 731 |
-
print(f"✅ [降级] 找到未缓存电影: {movie.get('title', '未知')} (ID: {tmdb_id})")
|
| 732 |
-
return {
|
| 733 |
-
'media_type': 'movie',
|
| 734 |
-
'tmdb_id': tmdb_id,
|
| 735 |
-
'title': movie.get('title', '未知'),
|
| 736 |
-
'year': movie.get('release_date', '')[:4] if movie.get('release_date') else '',
|
| 737 |
-
'genre': '未知'
|
| 738 |
-
}
|
| 739 |
-
except Exception as e:
|
| 740 |
-
print(f"❌ 降级获取电影失败: {e}")
|
| 741 |
-
return None
|
| 742 |
-
|
| 743 |
-
def get_random_tv_from_tmdb():
|
| 744 |
-
global processed_movies
|
| 745 |
-
max_attempts = 10
|
| 746 |
-
for attempt in range(max_attempts):
|
| 747 |
-
year = random.randint(1900, 2026)
|
| 748 |
-
genre_id = random.choice(list(TV_GENRES.keys()))
|
| 749 |
-
sort_options = ['popularity.desc', 'vote_average.desc', 'first_air_date.desc', 'name.asc']
|
| 750 |
-
sort_by = random.choice(sort_options)
|
| 751 |
-
page = random.randint(1, 1000)
|
| 752 |
-
url = f"https://api.themoviedb.org/3/discover/tv?api_key={TMDB_API_KEY}&language=zh-CN&sort_by={sort_by}&page={page}&first_air_date_year={year}&with_genres={genre_id}&vote_count.gte=1"
|
| 753 |
-
print(f"[DEBUG] 电视剧尝试 {attempt+1}: year={year}, genre={genre_id}, sort={sort_by}, page={page}")
|
| 754 |
-
try:
|
| 755 |
-
resp = requests.get(url, timeout=15)
|
| 756 |
-
data = resp.json()
|
| 757 |
-
results = data.get('results', [])
|
| 758 |
-
if not results:
|
| 759 |
-
continue
|
| 760 |
-
random.shuffle(results)
|
| 761 |
-
for tv in results:
|
| 762 |
-
tmdb_id = tv['id']
|
| 763 |
-
key = f"tv_{tmdb_id}"
|
| 764 |
-
if key in processed_movies:
|
| 765 |
-
continue
|
| 766 |
-
conn = sqlite3.connect(DB_PATH)
|
| 767 |
-
c = conn.cursor()
|
| 768 |
-
c.execute("SELECT 1 FROM resource_cache WHERE media_type='tv' AND tmdb_id=?", (str(tmdb_id),))
|
| 769 |
-
exists = c.fetchone() is not None
|
| 770 |
-
conn.close()
|
| 771 |
-
if not exists:
|
| 772 |
-
processed_movies.add(key)
|
| 773 |
-
print(f"✅ 找到未缓存电视剧: {tv.get('name', '未知')} (ID: {tmdb_id})")
|
| 774 |
-
return {
|
| 775 |
-
'media_type': 'tv',
|
| 776 |
-
'tmdb_id': tmdb_id,
|
| 777 |
-
'title': tv.get('name', '未知'),
|
| 778 |
-
'year': year,
|
| 779 |
-
'genre': TV_GENRES.get(genre_id, '未知')
|
| 780 |
-
}
|
| 781 |
-
except Exception as e:
|
| 782 |
-
print(f"❌ 电视剧尝试 {attempt+1} 失败: {e}")
|
| 783 |
-
try:
|
| 784 |
-
url = f"https://api.themoviedb.org/3/tv/on_the_air?api_key={TMDB_API_KEY}&language=zh-CN&page={random.randint(1,5)}"
|
| 785 |
-
resp = requests.get(url, timeout=15)
|
| 786 |
-
data = resp.json()
|
| 787 |
-
results = data.get('results', [])
|
| 788 |
-
random.shuffle(results)
|
| 789 |
-
for tv in results:
|
| 790 |
-
tmdb_id = tv['id']
|
| 791 |
-
key = f"tv_{tmdb_id}"
|
| 792 |
-
if key in processed_movies:
|
| 793 |
-
continue
|
| 794 |
-
conn = sqlite3.connect(DB_PATH)
|
| 795 |
-
c = conn.cursor()
|
| 796 |
-
c.execute("SELECT 1 FROM resource_cache WHERE media_type='tv' AND tmdb_id=?", (str(tmdb_id),))
|
| 797 |
-
exists = c.fetchone() is not None
|
| 798 |
-
conn.close()
|
| 799 |
-
if not exists:
|
| 800 |
-
processed_movies.add(key)
|
| 801 |
-
print(f"✅ [降级] 找到未缓存电视剧: {tv.get('name', '未知')} (ID: {tmdb_id})")
|
| 802 |
-
return {
|
| 803 |
-
'media_type': 'tv',
|
| 804 |
-
'tmdb_id': tmdb_id,
|
| 805 |
-
'title': tv.get('name', '未知'),
|
| 806 |
-
'year': tv.get('first_air_date', '')[:4] if tv.get('first_air_date') else '',
|
| 807 |
-
'genre': '未知'
|
| 808 |
-
}
|
| 809 |
-
except Exception as e:
|
| 810 |
-
print(f"❌ 降级获取电视剧失败: {e}")
|
| 811 |
-
return None
|
| 812 |
-
|
| 813 |
-
def auto_browse_and_unlock():
|
| 814 |
-
global next_unlock_time
|
| 815 |
-
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 🔄 [后台] 进入自动解锁任务")
|
| 816 |
-
if not HDHIVE_API_KEY:
|
| 817 |
-
print("❌ [后台] HDHIVE_API_KEY 缺失")
|
| 818 |
-
schedule_next_job()
|
| 819 |
-
return {'success': False, 'message': 'HDHIVE_API_KEY not configured'}
|
| 820 |
-
|
| 821 |
-
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 🔄 [后台] 开始随机获取未缓存影片...")
|
| 822 |
-
|
| 823 |
-
media_type_choice = random.choice(['movie', 'tv'])
|
| 824 |
-
if media_type_choice == 'movie':
|
| 825 |
-
info = get_random_movie_from_tmdb()
|
| 826 |
-
if not info:
|
| 827 |
-
print(f"📭 [后台] 未找到新的未缓存电影")
|
| 828 |
-
schedule_next_job()
|
| 829 |
-
return {'success': False, 'message': '未找到新的未缓存电影'}
|
| 830 |
-
media_type, tmdb_id, title = 'movie', info['tmdb_id'], info['title']
|
| 831 |
-
print(f"🎲 [后台] 选中未缓存电影: {title} (ID: {tmdb_id})")
|
| 832 |
-
else:
|
| 833 |
-
info = get_random_tv_from_tmdb()
|
| 834 |
-
if not info:
|
| 835 |
-
print(f"📭 [后台] 未找到新的未缓存电视剧")
|
| 836 |
-
schedule_next_job()
|
| 837 |
-
return {'success': False, 'message': '未找到新的未缓存电视剧'}
|
| 838 |
-
media_type, tmdb_id, title = 'tv', info['tmdb_id'], info['title']
|
| 839 |
-
print(f"🎲 [后台] 选中未缓存电视剧: {title} (ID: {tmdb_id})")
|
| 840 |
-
|
| 841 |
-
url = f"https://hdhive.com/api/open/resources/{media_type}/{tmdb_id}"
|
| 842 |
-
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
|
| 843 |
-
|
| 844 |
-
try:
|
| 845 |
-
resp = requests.get(url, headers=headers, timeout=30)
|
| 846 |
-
result = resp.json()
|
| 847 |
-
resources = result.get('data', []) if isinstance(result, dict) else result
|
| 848 |
-
|
| 849 |
-
if not resources:
|
| 850 |
-
print(f"📭 [后台] {title} 没有资源")
|
| 851 |
-
schedule_next_job()
|
| 852 |
-
return {'success': False, 'message': f'{title} 没有资源'}
|
| 853 |
-
|
| 854 |
-
print(f"📊 [后台] {title} 共有 {len(resources)} 个资源")
|
| 855 |
-
free_count = sum(1 for r in resources if r.get('unlock_points', 0) == 0)
|
| 856 |
-
print(f"📊 [后台] 其中免费资源: {free_count} 个")
|
| 857 |
-
|
| 858 |
-
save_cached_resources(media_type, tmdb_id, resources)
|
| 859 |
-
print(f"💾 [后台] 已缓存 {media_type}/{tmdb_id}, 共 {len(resources)} 个资源")
|
| 860 |
-
|
| 861 |
-
conn = sqlite3.connect(DB_PATH)
|
| 862 |
-
c = conn.cursor()
|
| 863 |
-
c.execute("CREATE TABLE IF NOT EXISTS auto_unlocked (slug TEXT PRIMARY KEY, unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
|
| 864 |
-
|
| 865 |
-
unlocked_any = False
|
| 866 |
-
for res in resources:
|
| 867 |
-
points = res.get('unlock_points', 0)
|
| 868 |
-
slug = res.get('slug')
|
| 869 |
-
if slug and points == 0:
|
| 870 |
-
c.execute("SELECT 1 FROM auto_unlocked WHERE slug=?", (slug,))
|
| 871 |
-
if not c.fetchone():
|
| 872 |
-
print(f"🔓 [后台] 发现未解锁免费资源,开始解锁: {slug}")
|
| 873 |
-
unlock_url = "https://hdhive.com/api/open/resources/unlock"
|
| 874 |
-
unlock_payload = {"slug": slug, "allow_points": True}
|
| 875 |
-
unlock_resp = requests.post(unlock_url, headers=headers, json=unlock_payload, timeout=30)
|
| 876 |
-
unlock_result = unlock_resp.json()
|
| 877 |
-
link = unlock_result.get('data', {}).get('full_url') or unlock_result.get('data', {}).get('url') or unlock_result.get('url')
|
| 878 |
-
if link:
|
| 879 |
-
c.execute("INSERT OR REPLACE INTO unlock_cache (slug, link, unlock_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
|
| 880 |
-
(slug, link, json.dumps(unlock_result, ensure_ascii=False)))
|
| 881 |
-
c.execute("INSERT INTO auto_unlocked (slug) VALUES (?)", (slug,))
|
| 882 |
-
conn.commit()
|
| 883 |
-
print(f"✅ [后台] 自动解锁成功: {title} - {slug}")
|
| 884 |
-
unlocked_any = True
|
| 885 |
-
break
|
| 886 |
-
else:
|
| 887 |
-
print(f"⚠️ [后台] 解锁失败: {slug} (无链接)")
|
| 888 |
-
else:
|
| 889 |
-
print(f"⏭️ [后台] 资源已解锁过,跳过: {slug}")
|
| 890 |
-
|
| 891 |
-
conn.close()
|
| 892 |
-
if unlocked_any:
|
| 893 |
-
result = {'success': True, 'message': f'解锁成功: {title}', 'title': title}
|
| 894 |
-
else:
|
| 895 |
-
print(f"📭 [后台] {title} 没有可解锁的免费资源(可能已全部解锁)")
|
| 896 |
-
result = {'success': False, 'message': f'{title} 没有可解锁的免费资源'}
|
| 897 |
-
|
| 898 |
-
schedule_next_job()
|
| 899 |
-
return result
|
| 900 |
-
|
| 901 |
-
except Exception as e:
|
| 902 |
-
print(f"❌ [后台] 处理失败: {e}")
|
| 903 |
-
schedule_next_job()
|
| 904 |
-
return {'success': False, 'message': str(e)}
|
| 905 |
-
|
| 906 |
-
def schedule_next_job():
|
| 907 |
-
interval_minutes = random.randint(1, 3)
|
| 908 |
-
next_run = datetime.now() + timedelta(minutes=interval_minutes)
|
| 909 |
-
global next_unlock_time
|
| 910 |
-
next_unlock_time = next_run.timestamp()
|
| 911 |
-
print(f"⏰ [后台] 下次浏览将在 {interval_minutes} 分钟后执行({next_run.strftime('%H:%M:%S')})")
|
| 912 |
-
if scheduler.get_job('auto_unlock_job'):
|
| 913 |
-
scheduler.remove_job('auto_unlock_job')
|
| 914 |
-
scheduler.add_job(auto_browse_and_unlock, 'date', run_date=next_run, id='auto_unlock_job')
|
| 915 |
-
|
| 916 |
-
schedule_next_job()
|
| 917 |
-
scheduler.start()
|
| 918 |
-
print(f"✅ 后台自动浏览已启动,随机间隔 1-3 分钟,从 TMDB 所有影片中随机选择未缓存内容")
|
| 919 |
-
|
| 920 |
-
# ========== HTML 模板 ==========
|
| 921 |
-
HTML_TEMPLATE = r"""<!DOCTYPE html>
|
| 922 |
-
<html>
|
| 923 |
-
<head>
|
| 924 |
-
<meta charset="UTF-8">
|
| 925 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes">
|
| 926 |
-
<title>HDHive 影视资源</title>
|
| 927 |
-
<style>
|
| 928 |
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 929 |
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; padding: 16px; }
|
| 930 |
-
.container { max-width: 1200px; margin: 0 auto; }
|
| 931 |
-
.header { text-align: center; margin-bottom: 20px; }
|
| 932 |
-
h1 { font-size: 28px; font-weight: 600; margin-bottom: 6px; }
|
| 933 |
-
.sub { font-size: 14px; color: #aaa; }
|
| 934 |
-
.stats-row { display: flex; justify-content: center; gap: 32px; background: #1a1a2e; border-radius: 12px; padding: 10px 20px; margin-bottom: 20px; border: 1px solid #2a2a4a; flex-wrap: wrap; }
|
| 935 |
-
.stat-item { text-align: center; }
|
| 936 |
-
.stat-number { font-size: 24px; font-weight: 700; color: #e50914; }
|
| 937 |
-
.stat-label { font-size: 13px; color: #aaa; }
|
| 938 |
-
.filter-notice { background: #1a1a2e; border-radius: 12px; padding: 8px 16px; margin-bottom: 16px; text-align: center; font-size: 13px; border: 1px solid #2a2a4a; }
|
| 939 |
-
.categories-section { background: #1a1a1a; border-radius: 14px; padding: 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; }
|
| 940 |
-
.type-switch { display: flex; gap: 12px; justify-content: center; margin-bottom: 20px; }
|
| 941 |
-
.type-btn { padding: 8px 24px; border-radius: 30px; background: #2a2a2a; border: none; color: #ccc; cursor: pointer; font-size: 15px; font-weight: 500; }
|
| 942 |
-
.type-btn.active { background: #e50914; color: #fff; }
|
| 943 |
-
.preset-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #2a2a2a; }
|
| 944 |
-
.preset-btn { padding: 6px 18px; border-radius: 30px; background: #2a2a2a; border: none; color: #ccc; cursor: pointer; font-size: 13px; transition: 0.2s; }
|
| 945 |
-
.preset-btn.active { background: #e50914; color: #fff; }
|
| 946 |
-
.filter-collapse { margin-top: 8px; border-top: 1px solid #2a2a2a; }
|
| 947 |
-
.filter-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; cursor: pointer; user-select: none; color: #e50914; font-size: 14px; font-weight: 500; }
|
| 948 |
-
.filter-header:hover { opacity: 0.8; }
|
| 949 |
-
.filter-header span:first-child { display: flex; align-items: center; gap: 8px; }
|
| 950 |
-
.toggle-icon { font-size: 18px; transition: transform 0.2s; }
|
| 951 |
-
.filter-content { transition: all 0.2s ease; overflow: hidden; }
|
| 952 |
-
.filter-content.collapsed { display: none; }
|
| 953 |
-
.movie-cats, .tv-cats { display: flex; flex-direction: column; gap: 15px; }
|
| 954 |
-
.filter-row { margin-bottom: 15px; }
|
| 955 |
-
.filter-label { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; font-weight: 600; }
|
| 956 |
-
.filter-buttons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
|
| 957 |
-
.filter-btn-item { padding: 6px 16px; border-radius: 30px; background: #2a2a2a; border: none; color: #aaa; cursor: pointer; font-size: 13px; transition: 0.2s; }
|
| 958 |
-
.filter-btn-item.active { background: #e50914; color: #fff; }
|
| 959 |
-
.search-section { background: #1a1a1a; border-radius: 14px; padding: 14px 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; text-align: center; }
|
| 960 |
-
.search-bar { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
|
| 961 |
-
.search-bar input { width: 300px; padding: 12px 16px; border-radius: 40px; background: #2a2a2a; border: none; color: #fff; font-size: 15px; }
|
| 962 |
-
.search-bar button { padding: 12px 24px; border-radius: 40px; background: #e50914; border: none; color: #fff; font-weight: 600; cursor: pointer; font-size: 15px; }
|
| 963 |
-
/* 快捷解锁面板 */
|
| 964 |
-
.quick-unlock { background: #1a1a1a; border-radius: 14px; padding: 14px 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; }
|
| 965 |
-
.quick-unlock-header { display: flex; justify-content: space-between; align-items: center; cursor: pointer; color: #e50914; font-size: 14px; font-weight: 500; margin-bottom: 12px; }
|
| 966 |
-
.quick-unlock-content { transition: all 0.2s; }
|
| 967 |
-
.quick-unlock-content.collapsed { display: none; }
|
| 968 |
-
.url-input-group { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
| 969 |
-
.url-input-group input { flex: 1; min-width: 200px; padding: 10px 14px; border-radius: 40px; background: #2a2a2a; border: none; color: #fff; font-size: 14px; }
|
| 970 |
-
.url-input-group button { padding: 10px 24px; border-radius: 40px; background: #e50914; border: none; color: #fff; font-weight: 600; cursor: pointer; }
|
| 971 |
-
.unlock-result { background: #0f0f0f; border-radius: 12px; padding: 12px; margin-top: 12px; font-size: 13px; word-break: break-all; border-left: 3px solid #e50914; }
|
| 972 |
-
.unlock-result a { color: #e50914; text-decoration: none; }
|
| 973 |
-
.copy-result-btn { background: #555; border: none; border-radius: 16px; padding: 4px 12px; color: #fff; cursor: pointer; font-size: 11px; margin-left: 10px; }
|
| 974 |
-
.resource-detail { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
|
| 975 |
-
.resource-detail .pan-badge { font-size: 12px; }
|
| 976 |
-
.two-columns { display: flex; gap: 20px; flex-wrap: wrap; }
|
| 977 |
-
.left-panel, .right-panel { flex: 1; min-width: 280px; background: #141414; border-radius: 16px; border: 1px solid #2a2a2a; overflow: visible; }
|
| 978 |
-
.panel-header { padding: 14px 18px; border-bottom: 1px solid #2a2a2a; display: flex; justify-content: space-between; align-items: baseline; }
|
| 979 |
-
.panel-title { font-size: 16px; font-weight: 600; }
|
| 980 |
-
.panel-count { font-size: 13px; color: #888; }
|
| 981 |
-
.movies-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; }
|
| 982 |
-
@media (max-width: 700px) { .movies-grid { grid-template-columns: repeat(2, 1fr); } }
|
| 983 |
-
.movie-card { background: #1e1e1e; border-radius: 12px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: 0.2s; position: relative; }
|
| 984 |
-
.movie-card.selected { border-color: #e50914; background: #2a2a2a; }
|
| 985 |
-
.movie-poster { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #2a2a2a; display: block; }
|
| 986 |
-
.movie-info { padding: 10px; }
|
| 987 |
-
.movie-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
| 988 |
-
.movie-year { font-size: 12px; color: #aaa; margin-top: 2px; }
|
| 989 |
-
.refresh-cache-btn {
|
| 990 |
-
position: absolute;
|
| 991 |
-
top: 6px;
|
| 992 |
-
right: 6px;
|
| 993 |
-
background: rgba(0,0,0,0.6);
|
| 994 |
-
border: none;
|
| 995 |
-
border-radius: 20px;
|
| 996 |
-
padding: 4px 8px;
|
| 997 |
-
font-size: 12px;
|
| 998 |
-
cursor: pointer;
|
| 999 |
-
color: #fff;
|
| 1000 |
-
z-index: 10;
|
| 1001 |
-
backdrop-filter: blur(4px);
|
| 1002 |
-
transition: 0.2s;
|
| 1003 |
-
}
|
| 1004 |
-
.refresh-cache-btn:hover { background: #e50914; transform: scale(1.05); }
|
| 1005 |
-
.selected-media { padding: 14px; border-bottom: 1px solid #2a2a2a; }
|
| 1006 |
-
.media-info { display: flex; gap: 14px; }
|
| 1007 |
-
.media-poster { width: 70px; height: 105px; object-fit: cover; border-radius: 8px; background: #2a2a2a; }
|
| 1008 |
-
.media-details { flex: 1; }
|
| 1009 |
-
.media-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
|
| 1010 |
-
.media-meta { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
|
| 1011 |
-
.media-badge { background: #e50914; padding: 4px 10px; border-radius: 16px; font-size: 12px; }
|
| 1012 |
-
.media-overview { font-size: 13px; color: #aaa; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; overflow: hidden; }
|
| 1013 |
-
.resource-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid #2a2a2a; }
|
| 1014 |
-
.checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
|
| 1015 |
-
.resource-list { display: flex; flex-direction: column; gap: 12px; padding: 14px; }
|
| 1016 |
-
.resource-card { background: #1e1e1e; border-radius: 12px; padding: 12px; border-left: 3px solid; }
|
| 1017 |
-
.resource-card.free { border-left-color: #2ecc71; }
|
| 1018 |
-
.resource-card.paid { border-left-color: #e50914; }
|
| 1019 |
-
.pan-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 16px; font-size: 11px; font-weight: 600; background: #333; }
|
| 1020 |
-
.pan-badge-baidu { background: #f39c12; color: #000; }
|
| 1021 |
-
.pan-badge-115 { background: #4caf50; color: #fff; }
|
| 1022 |
-
.pan-badge-magnet { background: #9e9e9e; color: #fff; }
|
| 1023 |
-
.pan-badge-unknown { background: #555; color: #fff; }
|
| 1024 |
-
.resource-title { font-weight: 600; margin-bottom: 8px; font-size: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
| 1025 |
-
.resource-meta { font-size: 12px; color: #aaa; margin-bottom: 10px; display: flex; gap: 12px; flex-wrap: wrap; }
|
| 1026 |
-
.resource-link { background: #2a2a2a; border-radius: 8px; padding: 10px; font-size: 12px; word-break: break-all; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
| 1027 |
-
.resource-link a { color: #e50914; text-decoration: none; flex: 1; }
|
| 1028 |
-
.copy-btn { background: #555; border: none; border-radius: 16px; padding: 4px 12px; color: #fff; cursor: pointer; font-size: 11px; transition: 0.2s; }
|
| 1029 |
-
.copy-btn:hover { background: #e50914; }
|
| 1030 |
-
.unlock-btn { background: #e50914; border: none; border-radius: 30px; padding: 8px 14px; color: white; font-weight: 600; cursor: pointer; width: 100%; font-size: 13px; }
|
| 1031 |
-
.pagination { display: flex; justify-content: center; gap: 8px; padding: 14px; border-top: 1px solid #2a2a2a; flex-wrap: wrap; }
|
| 1032 |
-
.page-btn { padding: 6px 14px; border-radius: 30px; background: #2a2a2a; border: none; color: #fff; cursor: pointer; font-size: 13px; }
|
| 1033 |
-
.page-btn.active { background: #e50914; }
|
| 1034 |
-
.loading, .empty { text-align: center; padding: 40px; color: #aaa; font-size: 14px; }
|
| 1035 |
-
.cache-badge { background: #2ecc71; color: #000; padding: 2px 8px; border-radius: 12px; font-size: 10px; margin-left: 8px; }
|
| 1036 |
-
.backup-btn { background: #2ecc71; border: none; border-radius: 30px; padding: 6px 14px; color: #000; cursor: pointer; font-size: 13px; margin: 0 6px; }
|
| 1037 |
-
.status-msg { margin-top: 8px; font-size: 13px; color: #aaa; }
|
| 1038 |
-
.bottom-bar { text-align: center; margin: 20px 0; }
|
| 1039 |
-
.debug-btn { background: #e50914; border: none; border-radius: 30px; padding: 6px 14px; color: #fff; cursor: pointer; font-size: 13px; margin: 0 6px; }
|
| 1040 |
-
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); }
|
| 1041 |
-
.modal-content { background-color: #1e1e1e; margin: 5% auto; padding: 0; border: 1px solid #e50914; border-radius: 12px; width: 90%; max-width: 600px; max-height: 80%; display: flex; flex-direction: column; overflow: hidden; color: #e0e0e0; font-family: monospace; font-size: 12px; }
|
| 1042 |
-
.modal-header { position: sticky; top: 0; background: #1e1e1e; z-index: 1; display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; border-bottom: 1px solid #333; }
|
| 1043 |
-
.modal-title { font-size: 18px; font-weight: bold; color: #e50914; }
|
| 1044 |
-
.close-modal, .clear-logs { background: #333; border: none; padding: 5px 12px; border-radius: 20px; cursor: pointer; color: #fff; margin-left: 8px; }
|
| 1045 |
-
.close-modal:hover, .clear-logs:hover { background: #e50914; }
|
| 1046 |
-
.modal-body { flex: 1; overflow-y: auto; padding: 12px 20px; }
|
| 1047 |
-
.log-entry { margin: 5px 0; padding: 4px; border-left: 3px solid; word-break: break-word; }
|
| 1048 |
-
.log-success { border-left-color: #2ecc71; }
|
| 1049 |
-
.log-error { border-left-color: #e74c3c; }
|
| 1050 |
-
.log-warning { border-left-color: #f39c12; }
|
| 1051 |
-
.log-info { border-left-color: #3498db; }
|
| 1052 |
-
.password-modal { display: none; position: fixed; z-index: 1001; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); }
|
| 1053 |
-
.password-modal-content { background-color: #1e1e1e; margin: 20% auto; padding: 20px; border: 1px solid #e50914; border-radius: 12px; width: 90%; max-width: 350px; text-align: center; }
|
| 1054 |
-
.password-modal-content input { width: 100%; padding: 10px; margin: 15px 0; background: #2a2a2a; border: 1px solid #555; border-radius: 8px; color: #fff; font-size: 16px; }
|
| 1055 |
-
.password-modal-content button { background: #e50914; border: none; padding: 10px 20px; border-radius: 30px; color: #fff; cursor: pointer; margin: 5px; }
|
| 1056 |
-
.toast-msg {
|
| 1057 |
-
position: fixed;
|
| 1058 |
-
top: 20px;
|
| 1059 |
-
left: 50%;
|
| 1060 |
-
transform: translateX(-50%);
|
| 1061 |
-
background: rgba(0,0,0,0.85);
|
| 1062 |
-
color: #fff;
|
| 1063 |
-
padding: 8px 20px;
|
| 1064 |
-
border-radius: 40px;
|
| 1065 |
-
font-size: 14px;
|
| 1066 |
-
z-index: 2000;
|
| 1067 |
-
backdrop-filter: blur(8px);
|
| 1068 |
-
border: 1px solid #e50914;
|
| 1069 |
-
pointer-events: none;
|
| 1070 |
-
transition: opacity 0.3s;
|
| 1071 |
-
opacity: 0;
|
| 1072 |
-
}
|
| 1073 |
-
</style>
|
| 1074 |
-
</head>
|
| 1075 |
-
<body>
|
| 1076 |
-
<div class="container">
|
| 1077 |
-
<div class="header">
|
| 1078 |
-
<h1>🎬 HDHive 影视资源</h1>
|
| 1079 |
-
<div class="sub">选择分类 → 浏览影片 → 获取资源链接</div>
|
| 1080 |
-
</div>
|
| 1081 |
-
<div class="stats-row">
|
| 1082 |
-
<div class="stat-item"><span class="stat-number" id="stat-movies">-</span><span class="stat-label"> 缓存影片</span></div>
|
| 1083 |
-
<div class="stat-item"><span class="stat-number" id="stat-unlocks">-</span><span class="stat-label"> 缓存资源</span></div>
|
| 1084 |
-
<div class="stat-item"><span class="stat-number" id="stat-auto-unlocked">-</span><span class="stat-label"> 自动解锁资源</span></div>
|
| 1085 |
-
<div class="stat-item"><span class="stat-number" id="next-unlock-countdown">--:--</span><span class="stat-label"> 下次浏览</span></div>
|
| 1086 |
-
</div>
|
| 1087 |
-
<div class="filter-notice">📌 仅显示 <strong>115网盘、百度网盘、磁力链接</strong> 资源 | 图片通过服务器代理</div>
|
| 1088 |
-
|
| 1089 |
-
<!-- 快捷解锁面板 -->
|
| 1090 |
-
<div class="quick-unlock">
|
| 1091 |
-
<div class="quick-unlock-header" onclick="toggleQuickUnlock()">
|
| 1092 |
-
<span>🔓 快捷解锁(直接输入 HDHive 资源链接或 slug)</span>
|
| 1093 |
-
<span class="toggle-icon" id="quickUnlockIcon">▼</span>
|
| 1094 |
-
</div>
|
| 1095 |
-
<div class="quick-unlock-content" id="quickUnlockContent">
|
| 1096 |
-
<div class="url-input-group">
|
| 1097 |
-
<input type="text" id="quickSlugInput" placeholder="粘贴链接或输入 slug,例如: https://hdhive.com/resource/115/6d09726061d24c69b98b58941e2c8a1e 或 6d09726061d24c69b98b58941e2c8a1e">
|
| 1098 |
-
<button onclick="quickUnlock()">🚀 解锁</button>
|
| 1099 |
-
</div>
|
| 1100 |
-
<div id="quickUnlockResult" class="unlock-result" style="display: none;"></div>
|
| 1101 |
-
</div>
|
| 1102 |
-
</div>
|
| 1103 |
-
|
| 1104 |
-
<div class="categories-section">
|
| 1105 |
-
<div class="type-switch">
|
| 1106 |
-
<button class="type-btn active" id="btn-movie" onclick="switchType('movie')">🎬 电影</button>
|
| 1107 |
-
<button class="type-btn" id="btn-tv" onclick="switchType('tv')">📺 电视剧</button>
|
| 1108 |
-
</div>
|
| 1109 |
-
<!-- 快捷榜单栏 -->
|
| 1110 |
-
<div id="movie-presets" class="preset-bar" style="display: flex;">
|
| 1111 |
-
<button class="preset-btn active" data-preset="popular">🔥 热门电影</button>
|
| 1112 |
-
<button class="preset-btn" data-preset="now_playing">🎬 正在热映</button>
|
| 1113 |
-
<button class="preset-btn" data-preset="top_rated">⭐ 评分最高</button>
|
| 1114 |
-
<button class="preset-btn" data-preset="upcoming">📅 即将上映</button>
|
| 1115 |
-
</div>
|
| 1116 |
-
<div id="tv-presets" class="preset-bar" style="display: none;">
|
| 1117 |
-
<button class="preset-btn active" data-preset="popular">🔥 热门剧集</button>
|
| 1118 |
-
<button class="preset-btn" data-preset="top_rated">⭐ 评分最高</button>
|
| 1119 |
-
<button class="preset-btn" data-preset="on_the_air">📺 正在播出</button>
|
| 1120 |
-
<button class="preset-btn" data-preset="airing_today">📅 今日播出</button>
|
| 1121 |
-
</div>
|
| 1122 |
-
<!-- 折叠面板 -->
|
| 1123 |
-
<div class="filter-collapse">
|
| 1124 |
-
<div class="filter-header" onclick="toggleFilterPanel()">
|
| 1125 |
-
<span>🔽 筛选条件 (地区/年份/类型/排序)</span>
|
| 1126 |
-
<span class="toggle-icon" id="filterToggleIcon">▼</span>
|
| 1127 |
-
</div>
|
| 1128 |
-
<div class="filter-content" id="filterContent">
|
| 1129 |
-
<div id="movie-cats" class="movie-cats">
|
| 1130 |
-
<div class="filter-row"><div class="filter-label">🌍 地区</div><div class="filter-buttons" id="movie-region"></div></div>
|
| 1131 |
-
<div class="filter-row"><div class="filter-label">📅 年份</div><div class="filter-buttons" id="movie-year"></div></div>
|
| 1132 |
-
<div class="filter-row"><div class="filter-label">🎭 类型 (TMDB)</div><div class="filter-buttons" id="movie-genre"></div></div>
|
| 1133 |
-
<div class="filter-row"><div class="filter-label">📊 排序</div><div class="filter-buttons" id="movie-sort"></div></div>
|
| 1134 |
-
</div>
|
| 1135 |
-
<div id="tv-cats" class="tv-cats" style="display: none;">
|
| 1136 |
-
<div class="filter-row"><div class="filter-label">🌍 地区</div><div class="filter-buttons" id="tv-region"></div></div>
|
| 1137 |
-
<div class="filter-row"><div class="filter-label">📅 年份</div><div class="filter-buttons" id="tv-year"></div></div>
|
| 1138 |
-
<div class="filter-row"><div class="filter-label">🎭 类型 (TMDB)</div><div class="filter-buttons" id="tv-genre"></div></div>
|
| 1139 |
-
<div class="filter-row"><div class="filter-label">📊 排序</div><div class="filter-buttons" id="tv-sort"></div></div>
|
| 1140 |
-
</div>
|
| 1141 |
-
</div>
|
| 1142 |
-
</div>
|
| 1143 |
-
</div>
|
| 1144 |
-
<div class="search-section">
|
| 1145 |
-
<div class="search-bar">
|
| 1146 |
-
<input type="text" id="search-input" placeholder="输入电影/剧集名称..." onkeypress="if(event.key==='Enter') searchByKeyword()">
|
| 1147 |
-
<button onclick="searchByKeyword()">🔍 搜索</button>
|
| 1148 |
-
</div>
|
| 1149 |
-
</div>
|
| 1150 |
-
<div class="two-columns">
|
| 1151 |
-
<div class="left-panel">
|
| 1152 |
-
<div class="panel-header"><div class="panel-title" id="panel-title">🔥 热门电影</div><div class="panel-count" id="panel-count">共 0 部</div></div>
|
| 1153 |
-
<div class="movies-grid" id="movies-grid"><div class="loading">加载中...</div></div>
|
| 1154 |
-
<div class="pagination" id="pagination"></div>
|
| 1155 |
-
</div>
|
| 1156 |
-
<div class="right-panel">
|
| 1157 |
-
<div class="selected-media" id="selected-media"><div class="empty">点击上方影视获取资源</div></div>
|
| 1158 |
-
<div class="resource-header"><div class="panel-title">📥 资源列表</div><label class="checkbox-label"><input type="checkbox" id="show-paid" checked><span>显示付费资源</span></label></div>
|
| 1159 |
-
<div id="resource-list" class="resource-list"><div class="empty">请先选择影视</div></div>
|
| 1160 |
-
</div>
|
| 1161 |
-
</div>
|
| 1162 |
-
<div class="bottom-bar">
|
| 1163 |
-
<button class="backup-btn" onclick="manualBackup()">💾 手动备份</button>
|
| 1164 |
-
<button class="debug-btn" onclick="showDebugModal()">🐛 调试日志</button>
|
| 1165 |
-
<span id="backupMsg" class="status-msg"></span>
|
| 1166 |
-
</div>
|
| 1167 |
-
</div>
|
| 1168 |
-
<div id="passwordModal" class="password-modal">
|
| 1169 |
-
<div class="password-modal-content">
|
| 1170 |
-
<h3>🔐 请输入解锁密码</h3>
|
| 1171 |
-
<input type="password" id="unlockPassword" placeholder="输入密码" autocomplete="off">
|
| 1172 |
-
<div>
|
| 1173 |
-
<button onclick="submitPassword()">确认</button>
|
| 1174 |
-
<button onclick="closePasswordModal()">取消</button>
|
| 1175 |
-
</div>
|
| 1176 |
-
</div>
|
| 1177 |
-
</div>
|
| 1178 |
-
<div id="debugModal" class="modal">
|
| 1179 |
-
<div class="modal-content">
|
| 1180 |
-
<div class="modal-header">
|
| 1181 |
-
<span class="modal-title">📋 系统调试日志</span>
|
| 1182 |
-
<div>
|
| 1183 |
-
<button class="clear-logs" onclick="clearAllLogs()">清除日志</button>
|
| 1184 |
-
<button class="close-modal" onclick="closeDebugModal()">关闭</button>
|
| 1185 |
-
</div>
|
| 1186 |
-
</div>
|
| 1187 |
-
<div class="modal-body" id="debugLogsList"></div>
|
| 1188 |
-
</div>
|
| 1189 |
-
</div>
|
| 1190 |
-
<div id="toastMsg" class="toast-msg"></div>
|
| 1191 |
-
|
| 1192 |
-
<script>
|
| 1193 |
-
// 折叠面板状态
|
| 1194 |
-
let filterPanelCollapsed = true;
|
| 1195 |
-
function toggleFilterPanel() {
|
| 1196 |
-
const content = document.getElementById('filterContent');
|
| 1197 |
-
const icon = document.getElementById('filterToggleIcon');
|
| 1198 |
-
if (filterPanelCollapsed) {
|
| 1199 |
-
content.classList.remove('collapsed');
|
| 1200 |
-
content.style.display = 'block';
|
| 1201 |
-
icon.innerHTML = '▲';
|
| 1202 |
-
filterPanelCollapsed = false;
|
| 1203 |
-
} else {
|
| 1204 |
-
content.classList.add('collapsed');
|
| 1205 |
-
content.style.display = 'none';
|
| 1206 |
-
icon.innerHTML = '▼';
|
| 1207 |
-
filterPanelCollapsed = true;
|
| 1208 |
-
}
|
| 1209 |
-
}
|
| 1210 |
-
// 快捷解锁折叠
|
| 1211 |
-
let quickUnlockCollapsed = false;
|
| 1212 |
-
function toggleQuickUnlock() {
|
| 1213 |
-
const content = document.getElementById('quickUnlockContent');
|
| 1214 |
-
const icon = document.getElementById('quickUnlockIcon');
|
| 1215 |
-
if (quickUnlockCollapsed) {
|
| 1216 |
-
content.classList.remove('collapsed');
|
| 1217 |
-
content.style.display = 'block';
|
| 1218 |
-
icon.innerHTML = '▼';
|
| 1219 |
-
quickUnlockCollapsed = false;
|
| 1220 |
-
} else {
|
| 1221 |
-
content.classList.add('collapsed');
|
| 1222 |
-
content.style.display = 'none';
|
| 1223 |
-
icon.innerHTML = '▶';
|
| 1224 |
-
quickUnlockCollapsed = true;
|
| 1225 |
-
}
|
| 1226 |
-
}
|
| 1227 |
-
// 初始化折叠状态
|
| 1228 |
-
document.addEventListener('DOMContentLoaded', function() {
|
| 1229 |
-
const filterContent = document.getElementById('filterContent');
|
| 1230 |
-
filterContent.classList.add('collapsed');
|
| 1231 |
-
filterContent.style.display = 'none';
|
| 1232 |
-
const quickContent = document.getElementById('quickUnlockContent');
|
| 1233 |
-
quickContent.classList.remove('collapsed');
|
| 1234 |
-
quickContent.style.display = 'block';
|
| 1235 |
-
quickUnlockCollapsed = false;
|
| 1236 |
-
});
|
| 1237 |
-
|
| 1238 |
-
// 提示函数:改为 alert
|
| 1239 |
-
function showToast(msg, isError = false) {
|
| 1240 |
-
alert(msg);
|
| 1241 |
-
}
|
| 1242 |
-
|
| 1243 |
-
// 获取网盘徽章 HTML
|
| 1244 |
-
function getPanBadgeHtml(panType) {
|
| 1245 |
-
const map = {
|
| 1246 |
-
baidu: ['百度网盘', '🐻', 'pan-badge-baidu'],
|
| 1247 |
-
115: ['115网盘', '☁️', 'pan-badge-115'],
|
| 1248 |
-
magnet: ['磁力链接', '🧲', 'pan-badge-magnet']
|
| 1249 |
-
};
|
| 1250 |
-
let [name, icon, cls] = map[panType] || ['其他网盘', '📎', 'pan-badge-unknown'];
|
| 1251 |
-
return `<span class="pan-badge ${cls}">${icon} ${name}</span>`;
|
| 1252 |
-
}
|
| 1253 |
-
|
| 1254 |
-
// 增强版链接网盘类型检测(支持 115cdn.com, anxia.com 等)
|
| 1255 |
-
function detectPanTypeFromLink(link) {
|
| 1256 |
-
if (!link) return 'unknown';
|
| 1257 |
-
let l = link.toLowerCase();
|
| 1258 |
-
if (l.includes('pan.baidu.com') || l.includes('baidu.com/s/')) return 'baidu';
|
| 1259 |
-
if (l.includes('115.com') || l.includes('115cdn.com') || l.includes('anxia.com') || l.includes('v.anxia.com')) return '115';
|
| 1260 |
-
if (l.includes('magnet:') || l.includes('ed2k://')) return 'magnet';
|
| 1261 |
-
return 'unknown';
|
| 1262 |
-
}
|
| 1263 |
-
|
| 1264 |
-
// 日志相关
|
| 1265 |
-
let logRefreshInterval = null;
|
| 1266 |
-
async function fetchLogs() {
|
| 1267 |
-
try {
|
| 1268 |
-
let resp = await fetch('/api/logs');
|
| 1269 |
-
let data = await resp.json();
|
| 1270 |
-
let container = document.getElementById('debugLogsList');
|
| 1271 |
-
if (!container) return;
|
| 1272 |
-
let logs = data.logs || [];
|
| 1273 |
-
if (logs.length === 0) {
|
| 1274 |
-
container.innerHTML = '<div style="text-align:center;color:#aaa;">暂无日志</div>';
|
| 1275 |
-
return;
|
| 1276 |
-
}
|
| 1277 |
-
let wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 10;
|
| 1278 |
-
container.innerHTML = logs.map(line => {
|
| 1279 |
-
let cls = 'log-info';
|
| 1280 |
-
let lowerLine = line.toLowerCase();
|
| 1281 |
-
if (lowerLine.includes('error') || lowerLine.includes('exception') || lowerLine.includes('失败')) cls = 'log-error';
|
| 1282 |
-
else if (lowerLine.includes('warning') || lowerLine.includes('警告')) cls = 'log-warning';
|
| 1283 |
-
else if (lowerLine.includes('✅') || lowerLine.includes('成功')) cls = 'log-success';
|
| 1284 |
-
return `<div class="log-entry ${cls}">${escapeHtml(line)}</div>`;
|
| 1285 |
-
}).join('');
|
| 1286 |
-
if (wasAtBottom) setTimeout(() => container.scrollTop = container.scrollHeight, 10);
|
| 1287 |
-
} catch(e) { console.error(e); }
|
| 1288 |
-
}
|
| 1289 |
-
function showDebugModal() {
|
| 1290 |
-
let modal = document.getElementById('debugModal');
|
| 1291 |
-
modal.style.display = 'block';
|
| 1292 |
-
fetchLogs();
|
| 1293 |
-
if (logRefreshInterval) clearInterval(logRefreshInterval);
|
| 1294 |
-
logRefreshInterval = setInterval(fetchLogs, 2000);
|
| 1295 |
-
setTimeout(() => { let c = document.getElementById('debugLogsList'); if(c) c.scrollTop = c.scrollHeight; }, 100);
|
| 1296 |
-
}
|
| 1297 |
-
function closeDebugModal() {
|
| 1298 |
-
document.getElementById('debugModal').style.display = 'none';
|
| 1299 |
-
if (logRefreshInterval) clearInterval(logRefreshInterval);
|
| 1300 |
-
}
|
| 1301 |
-
async function clearAllLogs() {
|
| 1302 |
-
await fetch('/api/logs/clear', { method: 'POST' });
|
| 1303 |
-
fetchLogs();
|
| 1304 |
-
}
|
| 1305 |
-
// 通用函数
|
| 1306 |
-
let pendingAction = null;
|
| 1307 |
-
function showPasswordModal(action) { pendingAction=action; document.getElementById('passwordModal').style.display='block'; document.getElementById('unlockPassword').focus(); }
|
| 1308 |
-
function closePasswordModal() { document.getElementById('passwordModal').style.display='none'; pendingAction=null; }
|
| 1309 |
-
async function submitPassword() {
|
| 1310 |
-
let pwd=document.getElementById('unlockPassword').value;
|
| 1311 |
-
if(!pwd){ alert('请输入密码'); return; }
|
| 1312 |
-
try {
|
| 1313 |
-
let r=await fetch('/api/verify-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
|
| 1314 |
-
let d=await r.json();
|
| 1315 |
-
if(d.success) {
|
| 1316 |
-
document.getElementById('passwordModal').style.display='none';
|
| 1317 |
-
if(pendingAction) {
|
| 1318 |
-
if(pendingAction.type === 'unlock') await doUnlockResource(pendingAction.slug, pendingAction.points, pwd);
|
| 1319 |
-
else if(pendingAction.type === 'refresh') await doRefreshCache(pendingAction.mediaType, pendingAction.tmdbId, pendingAction.btn);
|
| 1320 |
-
else if(pendingAction.type === 'quickUnlock') await doQuickUnlock(pendingAction.slug, pwd);
|
| 1321 |
-
}
|
| 1322 |
-
pendingAction = null;
|
| 1323 |
-
} else { alert(d.message); }
|
| 1324 |
-
} catch(e){ alert(`验证失败: ${e.message}`); }
|
| 1325 |
-
}
|
| 1326 |
-
function getPosterUrl(path) { if(!path) return ''; let tmdbUrl=`https://image.tmdb.org/t/p/w342${path}`; return `/api/image/proxy?url=${encodeURIComponent(tmdbUrl)}`; }
|
| 1327 |
-
function attachImageListeners(img,path) { img.addEventListener('error',()=>{ img.style.display='none'; if(img.parentElement) img.parentElement.style.background='#2a2a2a'; }); }
|
| 1328 |
-
let currentType='movie', currentPage=1, totalPages=1;
|
| 1329 |
-
let currentMovies=[], selectedMovie=null, showPaid=true, currentQuery='';
|
| 1330 |
-
let movieFilters={ region:null, year:null, genres:[], sort_by:'latest' };
|
| 1331 |
-
let tvFilters={ region:null, year:null, genres:[], sort_by:'latest' };
|
| 1332 |
-
let currentPreset = 'popular';
|
| 1333 |
-
let movieGenres = [];
|
| 1334 |
-
let tvGenres = [];
|
| 1335 |
-
const regionOptions = ['大陆','香港','台湾','日本','韩国','欧美'];
|
| 1336 |
-
const currentYear = new Date().getFullYear();
|
| 1337 |
-
const yearList = (() => {
|
| 1338 |
-
let years = [];
|
| 1339 |
-
for (let y = currentYear; y >= 2010; y--) years.push(y.toString());
|
| 1340 |
-
years.push('2010年前');
|
| 1341 |
-
return years;
|
| 1342 |
-
})();
|
| 1343 |
-
const sortOptions = [{value:'latest', label:'最新'},{value:'imdb', label:'IMDb 评分'},{value:'douban', label:'豆瓣评分'}];
|
| 1344 |
-
const ALLOWED_PAN_TYPES=['115','baidu','magnet'];
|
| 1345 |
-
function extractLinkFromResource(r){return r.link||r.download_url||r.url||r.pan_url||'';}
|
| 1346 |
-
// 加载 TMDB 类型
|
| 1347 |
-
async function loadMovieGenres() {
|
| 1348 |
-
try { let resp = await fetch('/api/tmdb/genres/movie'); let data = await resp.json(); movieGenres = data; } catch(e) { console.error(e); movieGenres = []; }
|
| 1349 |
-
}
|
| 1350 |
-
async function loadTVGenres() {
|
| 1351 |
-
try { let resp = await fetch('/api/tmdb/genres/tv'); let data = await resp.json(); tvGenres = data; } catch(e) { console.error(e); tvGenres = []; }
|
| 1352 |
-
}
|
| 1353 |
-
// 渲染静态筛选按钮
|
| 1354 |
-
function renderMovieRegionButtons() {
|
| 1355 |
-
const container = document.getElementById('movie-region');
|
| 1356 |
-
if (!container) return;
|
| 1357 |
-
container.innerHTML = regionOptions.map(r => `<button class="filter-btn-item ${movieFilters.region===r?'active':''}" onclick="selectMovieRegion('${r}')">${r}</button>`).join('');
|
| 1358 |
-
}
|
| 1359 |
-
function renderTVRegionButtons() {
|
| 1360 |
-
const container = document.getElementById('tv-region');
|
| 1361 |
-
if (!container) return;
|
| 1362 |
-
container.innerHTML = regionOptions.map(r => `<button class="filter-btn-item ${tvFilters.region===r?'active':''}" onclick="selectTVRegion('${r}')">${r}</button>`).join('');
|
| 1363 |
-
}
|
| 1364 |
-
function renderMovieYearButtons() {
|
| 1365 |
-
const container = document.getElementById('movie-year');
|
| 1366 |
-
if (!container) return;
|
| 1367 |
-
container.innerHTML = yearList.map(y => `<button class="filter-btn-item ${movieFilters.year===y?'active':''}" onclick="selectMovieYear('${y}')">${y}</button>`).join('');
|
| 1368 |
-
}
|
| 1369 |
-
function renderTVYearButtons() {
|
| 1370 |
-
const container = document.getElementById('tv-year');
|
| 1371 |
-
if (!container) return;
|
| 1372 |
-
container.innerHTML = yearList.map(y => `<button class="filter-btn-item ${tvFilters.year===y?'active':''}" onclick="selectTVYear('${y}')">${y}</button>`).join('');
|
| 1373 |
-
}
|
| 1374 |
-
function renderMovieGenreButtons() {
|
| 1375 |
-
const container = document.getElementById('movie-genre');
|
| 1376 |
-
if (!container) return;
|
| 1377 |
-
container.innerHTML = movieGenres.map(g => `<button class="filter-btn-item ${movieFilters.genres.includes(g.id)?'active':''}" onclick="toggleMovieGenre(${g.id})">${g.name}</button>`).join('');
|
| 1378 |
-
}
|
| 1379 |
-
function renderTVGenreButtons() {
|
| 1380 |
-
const container = document.getElementById('tv-genre');
|
| 1381 |
-
if (!container) return;
|
| 1382 |
-
container.innerHTML = tvGenres.map(g => `<button class="filter-btn-item ${tvFilters.genres.includes(g.id)?'active':''}" onclick="toggleTVGenre(${g.id})">${g.name}</button>`).join('');
|
| 1383 |
-
}
|
| 1384 |
-
function renderMovieSortButtons() {
|
| 1385 |
-
const container = document.getElementById('movie-sort');
|
| 1386 |
-
if (!container) return;
|
| 1387 |
-
container.innerHTML = sortOptions.map(s => `<button class="filter-btn-item ${movieFilters.sort_by===s.value?'active':''}" onclick="selectMovieSort('${s.value}')">${s.label}</button>`).join('');
|
| 1388 |
-
}
|
| 1389 |
-
function renderTVSortButtons() {
|
| 1390 |
-
const container = document.getElementById('tv-sort');
|
| 1391 |
-
if (!container) return;
|
| 1392 |
-
container.innerHTML = sortOptions.map(s => `<button class="filter-btn-item ${tvFilters.sort_by===s.value?'active':''}" onclick="selectTVSort('${s.value}')">${s.label}</button>`).join('');
|
| 1393 |
-
}
|
| 1394 |
-
function updatePresetButtons() {
|
| 1395 |
-
const moviePresetDiv = document.getElementById('movie-presets');
|
| 1396 |
-
const tvPresetDiv = document.getElementById('tv-presets');
|
| 1397 |
-
if (currentType === 'movie') {
|
| 1398 |
-
const btns = moviePresetDiv.querySelectorAll('.preset-btn');
|
| 1399 |
-
btns.forEach(btn => {
|
| 1400 |
-
if (btn.dataset.preset === currentPreset) btn.classList.add('active');
|
| 1401 |
-
else btn.classList.remove('active');
|
| 1402 |
-
});
|
| 1403 |
-
} else {
|
| 1404 |
-
const btns = tvPresetDiv.querySelectorAll('.preset-btn');
|
| 1405 |
-
btns.forEach(btn => {
|
| 1406 |
-
if (btn.dataset.preset === currentPreset) btn.classList.add('active');
|
| 1407 |
-
else btn.classList.remove('active');
|
| 1408 |
-
});
|
| 1409 |
-
}
|
| 1410 |
-
}
|
| 1411 |
-
function resetFilters() {
|
| 1412 |
-
if (currentType === 'movie') {
|
| 1413 |
-
movieFilters = { region:null, year:null, genres:[], sort_by:'latest' };
|
| 1414 |
-
renderMovieRegionButtons();
|
| 1415 |
-
renderMovieYearButtons();
|
| 1416 |
-
renderMovieGenreButtons();
|
| 1417 |
-
renderMovieSortButtons();
|
| 1418 |
-
} else {
|
| 1419 |
-
tvFilters = { region:null, year:null, genres:[], sort_by:'latest' };
|
| 1420 |
-
renderTVRegionButtons();
|
| 1421 |
-
renderTVYearButtons();
|
| 1422 |
-
renderTVGenreButtons();
|
| 1423 |
-
renderTVSortButtons();
|
| 1424 |
-
}
|
| 1425 |
-
}
|
| 1426 |
-
function selectPreset(preset) {
|
| 1427 |
-
if (currentPreset === preset) {
|
| 1428 |
-
currentPreset = null;
|
| 1429 |
-
} else {
|
| 1430 |
-
currentPreset = preset;
|
| 1431 |
-
}
|
| 1432 |
-
updatePresetButtons();
|
| 1433 |
-
currentPage = 1;
|
| 1434 |
-
selectedMovie = null;
|
| 1435 |
-
loadMovies();
|
| 1436 |
-
}
|
| 1437 |
-
function selectMovieRegion(code) {
|
| 1438 |
-
movieFilters.region = (movieFilters.region===code ? null : code);
|
| 1439 |
-
renderMovieRegionButtons();
|
| 1440 |
-
loadMovies();
|
| 1441 |
-
}
|
| 1442 |
-
function selectMovieYear(year) {
|
| 1443 |
-
movieFilters.year = (movieFilters.year===year ? null : year);
|
| 1444 |
-
renderMovieYearButtons();
|
| 1445 |
-
loadMovies();
|
| 1446 |
-
}
|
| 1447 |
-
function toggleMovieGenre(id) {
|
| 1448 |
-
if(movieFilters.genres.includes(id)) movieFilters.genres = movieFilters.genres.filter(g=>g!==id);
|
| 1449 |
-
else movieFilters.genres.push(id);
|
| 1450 |
-
renderMovieGenreButtons();
|
| 1451 |
-
loadMovies();
|
| 1452 |
-
}
|
| 1453 |
-
function selectMovieSort(sort) {
|
| 1454 |
-
movieFilters.sort_by = sort;
|
| 1455 |
-
renderMovieSortButtons();
|
| 1456 |
-
loadMovies();
|
| 1457 |
-
}
|
| 1458 |
-
function selectTVRegion(code) {
|
| 1459 |
-
tvFilters.region = (tvFilters.region===code ? null : code);
|
| 1460 |
-
renderTVRegionButtons();
|
| 1461 |
-
loadMovies();
|
| 1462 |
-
}
|
| 1463 |
-
function selectTVYear(year) {
|
| 1464 |
-
tvFilters.year = (tvFilters.year===year ? null : year);
|
| 1465 |
-
renderTVYearButtons();
|
| 1466 |
-
loadMovies();
|
| 1467 |
-
}
|
| 1468 |
-
function toggleTVGenre(id) {
|
| 1469 |
-
if(tvFilters.genres.includes(id)) tvFilters.genres = tvFilters.genres.filter(g=>g!==id);
|
| 1470 |
-
else tvFilters.genres.push(id);
|
| 1471 |
-
renderTVGenreButtons();
|
| 1472 |
-
loadMovies();
|
| 1473 |
-
}
|
| 1474 |
-
function selectTVSort(sort) {
|
| 1475 |
-
tvFilters.sort_by = sort;
|
| 1476 |
-
renderTVSortButtons();
|
| 1477 |
-
loadMovies();
|
| 1478 |
-
}
|
| 1479 |
-
async function switchType(type){
|
| 1480 |
-
currentType=type; currentPage=1; currentQuery=''; selectedMovie=null;
|
| 1481 |
-
currentPreset = 'popular';
|
| 1482 |
-
resetFilters();
|
| 1483 |
-
document.getElementById('search-input').value='';
|
| 1484 |
-
document.getElementById('btn-movie').classList.toggle('active',type==='movie');
|
| 1485 |
-
document.getElementById('btn-tv').classList.toggle('active',type==='tv');
|
| 1486 |
-
document.getElementById('movie-presets').style.display = type==='movie' ? 'flex' : 'none';
|
| 1487 |
-
document.getElementById('tv-presets').style.display = type==='tv' ? 'flex' : 'none';
|
| 1488 |
-
if(type==='movie') {
|
| 1489 |
-
document.getElementById('movie-cats').style.display='flex';
|
| 1490 |
-
document.getElementById('tv-cats').style.display='none';
|
| 1491 |
-
renderMovieRegionButtons(); renderMovieYearButtons(); renderMovieGenreButtons(); renderMovieSortButtons();
|
| 1492 |
-
} else {
|
| 1493 |
-
document.getElementById('movie-cats').style.display='none';
|
| 1494 |
-
document.getElementById('tv-cats').style.display='block';
|
| 1495 |
-
renderTVRegionButtons(); renderTVYearButtons(); renderTVGenreButtons(); renderTVSortButtons();
|
| 1496 |
-
}
|
| 1497 |
-
updatePresetButtons();
|
| 1498 |
-
loadMovies();
|
| 1499 |
-
document.getElementById('selected-media').innerHTML='<div class="empty">点击上方影视获取资源</div>';
|
| 1500 |
-
document.getElementById('resource-list').innerHTML='<div class="empty">请先选择影视</div>';
|
| 1501 |
-
document.querySelectorAll('.movie-card').forEach(c=>c.classList.remove('selected'));
|
| 1502 |
-
}
|
| 1503 |
-
async function loadMovies(){
|
| 1504 |
-
document.getElementById('movies-grid').innerHTML='<div class="loading">加载中...</div>';
|
| 1505 |
-
try {
|
| 1506 |
-
let params, resp;
|
| 1507 |
-
if(currentQuery) {
|
| 1508 |
-
params = {query: currentQuery, page: currentPage};
|
| 1509 |
-
resp = await fetch('/api/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});
|
| 1510 |
-
let data = await resp.json();
|
| 1511 |
-
currentMovies = data.results || [];
|
| 1512 |
-
totalPages = data.total_pages || 1;
|
| 1513 |
-
document.getElementById('panel-title').innerHTML = `🔍 搜索结果: ${currentQuery}`;
|
| 1514 |
-
} else {
|
| 1515 |
-
let filters = (currentType === 'movie') ? { ...movieFilters } : { ...tvFilters };
|
| 1516 |
-
params = {type: currentType, page: currentPage, filters: filters};
|
| 1517 |
-
if (currentPreset) {
|
| 1518 |
-
params.preset = currentPreset;
|
| 1519 |
-
}
|
| 1520 |
-
resp = await fetch('/api/discover',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});
|
| 1521 |
-
let data = await resp.json();
|
| 1522 |
-
currentMovies = data.results || [];
|
| 1523 |
-
totalPages = data.total_pages || 1;
|
| 1524 |
-
let title = '';
|
| 1525 |
-
if (currentPreset) {
|
| 1526 |
-
const presetNames = {
|
| 1527 |
-
popular: '热门', now_playing: '正在热映', top_rated: '评分最高', upcoming: '即将上映',
|
| 1528 |
-
on_the_air: '正在播出', airing_today: '今日播出'
|
| 1529 |
-
};
|
| 1530 |
-
title = presetNames[currentPreset] || (currentType==='movie'?'电影':'电视剧');
|
| 1531 |
-
} else {
|
| 1532 |
-
if (currentType === 'movie') {
|
| 1533 |
-
if(movieFilters.region) title += movieFilters.region + ' ';
|
| 1534 |
-
if(movieFilters.year) title += (movieFilters.year==='2010年前'?'2010年前':movieFilters.year+'年') + ' ';
|
| 1535 |
-
if(movieFilters.genres.length) title += movieGenres.find(g=>g.id===movieFilters.genres[0])?.name || '';
|
| 1536 |
-
if(!title) title = '电影';
|
| 1537 |
-
} else {
|
| 1538 |
-
if(tvFilters.region) title += tvFilters.region + ' ';
|
| 1539 |
-
if(tvFilters.year) title += (tvFilters.year==='2010年前'?'2010年前':tvFilters.year+'年') + ' ';
|
| 1540 |
-
if(tvFilters.genres.length) title += tvGenres.find(g=>g.id===tvFilters.genres[0])?.name || '';
|
| 1541 |
-
if(!title) title = '电视剧';
|
| 1542 |
-
}
|
| 1543 |
-
}
|
| 1544 |
-
document.getElementById('panel-title').innerHTML = title;
|
| 1545 |
-
}
|
| 1546 |
-
renderMovies();
|
| 1547 |
-
renderPagination();
|
| 1548 |
-
document.getElementById('panel-count').innerHTML = `共 ${currentMovies.length} 部`;
|
| 1549 |
-
} catch(e){
|
| 1550 |
-
document.getElementById('movies-grid').innerHTML='<div class="empty">❌ 加载失败</div>';
|
| 1551 |
-
console.error(e);
|
| 1552 |
-
}
|
| 1553 |
-
}
|
| 1554 |
-
async function searchByKeyword(){
|
| 1555 |
-
let kw=document.getElementById('search-input').value.trim();
|
| 1556 |
-
if(!kw) return;
|
| 1557 |
-
currentQuery=kw; currentPage=1; selectedMovie=null;
|
| 1558 |
-
currentPreset = null;
|
| 1559 |
-
resetFilters();
|
| 1560 |
-
updatePresetButtons();
|
| 1561 |
-
await loadMovies();
|
| 1562 |
-
document.getElementById('selected-media').innerHTML='<div class="empty">点击上方影视获取资源</div>';
|
| 1563 |
-
document.getElementById('resource-list').innerHTML='<div class="empty">请先选择影视</div>';
|
| 1564 |
-
document.querySelectorAll('.movie-card').forEach(c=>c.classList.remove('selected'));
|
| 1565 |
-
}
|
| 1566 |
-
function renderMovies(){
|
| 1567 |
-
if(!currentMovies.length){ document.getElementById('movies-grid').innerHTML='<div class="empty">暂无数据</div>'; return; }
|
| 1568 |
-
document.getElementById('movies-grid').innerHTML=currentMovies.map(m=>{
|
| 1569 |
-
let posterUrl=m.poster_path?getPosterUrl(m.poster_path):'';
|
| 1570 |
-
let title=m.title||m.name||'未知';
|
| 1571 |
-
let year=(m.release_date||m.first_air_date||'').slice(0,4);
|
| 1572 |
-
let isSelected=selectedMovie&&selectedMovie.id===m.id;
|
| 1573 |
-
let mediaType=m.media_type||(m.title?'movie':'tv');
|
| 1574 |
-
let cached=m.cached===true;
|
| 1575 |
-
let refreshBtnHtml=cached?`<button class="refresh-cache-btn" onclick="event.stopPropagation(); refreshCache('${mediaType}',${m.id},this)">🔄</button>`:'';
|
| 1576 |
-
return `<div class="movie-card ${isSelected?'selected':''}" data-id="${m.id}" data-type="${mediaType}">
|
| 1577 |
-
${refreshBtnHtml}
|
| 1578 |
-
${posterUrl?`<img class="movie-poster" src="${posterUrl}" data-poster-path="${m.poster_path}" loading="lazy">`:'<div class="movie-poster" style="background:#2a2a2a;"></div>'}
|
| 1579 |
-
<div class="movie-info"><div class="movie-title">${escapeHtml(title)}</div><div class="movie-year">${year}</div><div class="movie-year" style="font-size:10px;color:#e50914;">${mediaType==='movie'?'电影':'电视剧'}</div></div>
|
| 1580 |
-
</div>`;
|
| 1581 |
-
}).join('');
|
| 1582 |
-
document.querySelectorAll('.movie-poster').forEach(img=>{let path=img.getAttribute('data-poster-path');if(path)attachImageListeners(img,path);});
|
| 1583 |
-
document.querySelectorAll('.movie-card').forEach(card=>card.addEventListener('click',(e)=>{if(e.target.classList.contains('refresh-cache-btn'))return;let id=parseInt(card.dataset.id);let type=card.dataset.type;selectMovie(id,type);}));
|
| 1584 |
-
}
|
| 1585 |
-
async function refreshCache(mediaType,tmdbId,btn){
|
| 1586 |
-
showPasswordModal({type:'refresh', mediaType, tmdbId, btn});
|
| 1587 |
-
}
|
| 1588 |
-
async function doRefreshCache(mediaType,tmdbId,btn){
|
| 1589 |
-
if(!confirm('确定刷新缓存?')) return;
|
| 1590 |
-
let msgDiv=document.getElementById('backupMsg');
|
| 1591 |
-
msgDiv.innerText='🔄 刷新中...';
|
| 1592 |
-
try{
|
| 1593 |
-
let resp=await fetch(`/api/cache/refresh/${mediaType}/${tmdbId}`,{method:'POST'});
|
| 1594 |
-
let data=await resp.json();
|
| 1595 |
-
if(data.success){
|
| 1596 |
-
msgDiv.innerText='✅ 刷新成功!';
|
| 1597 |
-
setTimeout(()=>{if(msgDiv.innerText==='✅ 刷新成功!')msgDiv.innerText='';},3000);
|
| 1598 |
-
if(selectedMovie&&selectedMovie.id===tmdbId) await selectMovie(tmdbId,mediaType);
|
| 1599 |
-
await loadMovies();
|
| 1600 |
-
await updateAllStats();
|
| 1601 |
-
} else { msgDiv.innerText=`❌ 失败: ${data.error||'未知'}`; }
|
| 1602 |
-
}catch(e){ msgDiv.innerText=`❌ 错误: ${e.message}`; }
|
| 1603 |
-
}
|
| 1604 |
-
async function selectMovie(id,forcedType){
|
| 1605 |
-
let movie=currentMovies.find(m=>m.id===id);
|
| 1606 |
-
if(!movie) return;
|
| 1607 |
-
selectedMovie=movie;
|
| 1608 |
-
let mediaType=forcedType||movie.media_type||(movie.title?'movie':'tv');
|
| 1609 |
-
selectedMovie.media_type = mediaType;
|
| 1610 |
-
document.querySelectorAll('.movie-card').forEach(c=>c.classList.toggle('selected',parseInt(c.dataset.id)===id));
|
| 1611 |
-
let poster=movie.poster_path?getPosterUrl(movie.poster_path):'';
|
| 1612 |
-
let title=movie.title||movie.name||'未知';
|
| 1613 |
-
let year=(movie.release_date||movie.first_air_date||'').slice(0,4);
|
| 1614 |
-
let overview=movie.overview||'暂无简介';
|
| 1615 |
-
document.getElementById('selected-media').innerHTML=`
|
| 1616 |
-
<div class="media-info">
|
| 1617 |
-
${poster?`<img class="media-poster" src="${poster}" onerror="this.style.display='none'">`:'<div class="media-poster" style="background:#2a2a2a;"></div>'}
|
| 1618 |
-
<div class="media-details">
|
| 1619 |
-
<div class="media-title">${escapeHtml(title)}</div>
|
| 1620 |
-
<div class="media-meta"><span class="media-badge">${mediaType==='movie'?'电影':'电视剧'}</span><span class="media-badge">${year||'未知'}</span><span class="media-badge">ID: ${movie.id}</span></div>
|
| 1621 |
-
<div class="media-overview">${escapeHtml(overview)}</div>
|
| 1622 |
-
</div>
|
| 1623 |
-
</div>`;
|
| 1624 |
-
document.getElementById('resource-list').innerHTML='<div class="loading">获取资源中...</div>';
|
| 1625 |
-
try{
|
| 1626 |
-
let resp=await fetch(`/api/cache/resources/${mediaType}/${movie.id}`);
|
| 1627 |
-
let data=await resp.json();
|
| 1628 |
-
if(!data.success){document.getElementById('resource-list').innerHTML='<div class="empty">暂无资源</div>';return;}
|
| 1629 |
-
let resources=data.resources||[];
|
| 1630 |
-
if(resources.length===0){document.getElementById('resource-list').innerHTML='<div class="empty">暂无资源</div>';return;}
|
| 1631 |
-
let freeItems=[],paidItems=[];
|
| 1632 |
-
for(let r of resources){
|
| 1633 |
-
let points=r.unlock_points||0;
|
| 1634 |
-
let link=extractLinkFromResource(r);
|
| 1635 |
-
let panType=detectPanTypeFromLink(link);
|
| 1636 |
-
if(panType==='unknown') panType=(r.pan_type||'unknown').toLowerCase();
|
| 1637 |
-
if(!ALLOWED_PAN_TYPES.includes(panType)) continue;
|
| 1638 |
-
if(points===0||r.is_free_for_user===true) freeItems.push({...r,panType,link});
|
| 1639 |
-
else paidItems.push({...r,panType,link});
|
| 1640 |
-
}
|
| 1641 |
-
let allItems=[...freeItems,...(showPaid?paidItems:[])];
|
| 1642 |
-
if(allItems.length===0){document.getElementById('resource-list').innerHTML='<div class="empty">暂无115、百度、磁力资源</div>';return;}
|
| 1643 |
-
let html=`<div style="margin-bottom:10px;"><strong>🎁 资源列表</strong> ${data.from_cache?'<span class="cache-badge">📦 缓存</span>':''}</div>`;
|
| 1644 |
-
for(let r of allItems){
|
| 1645 |
-
let points=r.unlock_points||0;
|
| 1646 |
-
let isFree=points===0||r.is_free_for_user===true;
|
| 1647 |
-
let link=r.link;
|
| 1648 |
-
if(!link&&r.slug) link=localStorage.getItem(`unlock_${r.slug}`);
|
| 1649 |
-
let panBadge=getPanBadgeHtml(r.panType);
|
| 1650 |
-
if(link){
|
| 1651 |
-
html+=`<div class="resource-card free"><div class="resource-title">${escapeHtml(r.title||r.name||'未命名')} ${panBadge}</div><div class="resource-meta">${r.share_size?`📦 ${r.share_size}`:''} ${r.video_resolution?`🎬 ${r.video_resolution}`:''}${!isFree?`<span>💰 ${points} 积分</span>`:''}</div><div class="resource-link">📥 <a href="${link}" target="_blank">${link}</a><button class="copy-btn" onclick="copyToClipboard('${link.replace(/'/g,"\\'")}',this)">📋 复制</button></div></div>`;
|
| 1652 |
-
}else{
|
| 1653 |
-
html+=`<div class="resource-card ${isFree?'free':'paid'}"><div class="resource-title">${escapeHtml(r.title||r.name||'未命名')} ${panBadge}</div><div class="resource-meta">${r.share_size?`📦 ${r.share_size}`:''} ${r.video_resolution?`🎬 ${r.video_resolution}`:''}${!isFree?`<span>💰 ${points} 积分</span>`:''}</div><button class="unlock-btn" onclick="handleUnlockClick('${r.slug}',${points})">🔓 获取链接${!isFree?'(消耗积分)':''}</button></div>`;
|
| 1654 |
-
}
|
| 1655 |
-
}
|
| 1656 |
-
document.getElementById('resource-list').innerHTML=html;
|
| 1657 |
-
let rightPanel=document.querySelector('.right-panel');
|
| 1658 |
-
if(rightPanel) rightPanel.scrollIntoView({behavior:'smooth',block:'start'});
|
| 1659 |
-
}catch(e){document.getElementById('resource-list').innerHTML=`<div class="empty">❌ 获取失败: ${e.message}</div>`; console.error(e);}
|
| 1660 |
-
}
|
| 1661 |
-
|
| 1662 |
-
// ========== 修改后的解锁逻辑 ==========
|
| 1663 |
-
// 左侧资源列表的解锁处理
|
| 1664 |
-
function handleUnlockClick(slug, points) {
|
| 1665 |
-
// 直接尝试解锁(先查缓存),不再预先判断 points
|
| 1666 |
-
doUnlockResource(slug, points, null);
|
| 1667 |
-
}
|
| 1668 |
-
|
| 1669 |
-
async function doUnlockResource(slug, points, password = null) {
|
| 1670 |
-
try {
|
| 1671 |
-
let body = {slug: slug, allow_points: true};
|
| 1672 |
-
if (password) {
|
| 1673 |
-
body.password = password;
|
| 1674 |
-
}
|
| 1675 |
-
let resp = await fetch('/api/cache/unlock', {
|
| 1676 |
-
method: 'POST',
|
| 1677 |
-
headers: {'Content-Type': 'application/json'},
|
| 1678 |
-
body: JSON.stringify(body)
|
| 1679 |
-
});
|
| 1680 |
-
let data = await resp.json();
|
| 1681 |
-
if (data.need_password) {
|
| 1682 |
-
showPasswordModal({type:'unlock', slug, points});
|
| 1683 |
-
return;
|
| 1684 |
-
}
|
| 1685 |
-
if (data.from_cache === true) alert('📦 来自缓存,不消耗积分');
|
| 1686 |
-
if (data.code === 'OPENAPI_COOLDOWN') {
|
| 1687 |
-
let wait = data.retry_after_seconds || 60;
|
| 1688 |
-
alert(`请等待 ${wait} 秒`);
|
| 1689 |
-
setTimeout(() => { if(selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type); }, wait * 1000);
|
| 1690 |
-
return;
|
| 1691 |
-
}
|
| 1692 |
-
let link = data.link || data.data?.full_url || data.data?.url || data.url;
|
| 1693 |
-
if (link) {
|
| 1694 |
-
localStorage.setItem(`unlock_${slug}`, link);
|
| 1695 |
-
alert('✅ 解锁成功!');
|
| 1696 |
-
} else {
|
| 1697 |
-
alert('❌ 解锁失败');
|
| 1698 |
-
}
|
| 1699 |
-
if (selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type);
|
| 1700 |
-
} catch(e) {
|
| 1701 |
-
alert(`错误: ${e.message}`);
|
| 1702 |
-
if (selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type);
|
| 1703 |
-
}
|
| 1704 |
-
}
|
| 1705 |
-
|
| 1706 |
-
// 快捷解锁
|
| 1707 |
-
function extractSlugFromUrl(input) {
|
| 1708 |
-
let trimmed = input.trim();
|
| 1709 |
-
if (/^[a-f0-9]{32}$/i.test(trimmed)) return trimmed;
|
| 1710 |
-
let match = trimmed.match(/\/resource\/(?:[^\/]+\/)?([a-f0-9]{32})/i);
|
| 1711 |
-
if (match) return match[1];
|
| 1712 |
-
match = trimmed.match(/([a-f0-9]{32})/i);
|
| 1713 |
-
return match ? match[1] : null;
|
| 1714 |
-
}
|
| 1715 |
-
|
| 1716 |
-
async function quickUnlock() {
|
| 1717 |
-
let raw = document.getElementById('quickSlugInput').value;
|
| 1718 |
-
if (!raw.trim()) {
|
| 1719 |
-
alert('请输入资源链接或 slug');
|
| 1720 |
-
return;
|
| 1721 |
-
}
|
| 1722 |
-
let slug = extractSlugFromUrl(raw);
|
| 1723 |
-
if (!slug) {
|
| 1724 |
-
alert('未能提取到有效的 slug (32位十六进制)');
|
| 1725 |
-
return;
|
| 1726 |
-
}
|
| 1727 |
-
let resultDiv = document.getElementById('quickUnlockResult');
|
| 1728 |
-
resultDiv.style.display = 'block';
|
| 1729 |
-
resultDiv.innerHTML = '<div style="color:#aaa;">🔓 解锁中...</div>';
|
| 1730 |
-
// 直接调用解锁(不带密码),后端会先查缓存,再根据是否需要密码返回 need_password
|
| 1731 |
-
doQuickUnlock(slug, null);
|
| 1732 |
-
}
|
| 1733 |
-
|
| 1734 |
-
async function doQuickUnlock(slug, password) {
|
| 1735 |
-
let resultDiv = document.getElementById('quickUnlockResult');
|
| 1736 |
-
resultDiv.innerHTML = '<div style="color:#aaa;">🔓 解锁中...</div>';
|
| 1737 |
-
try {
|
| 1738 |
-
let body = {slug: slug, allow_points: true};
|
| 1739 |
-
if (password) {
|
| 1740 |
-
body.password = password;
|
| 1741 |
-
}
|
| 1742 |
-
let resp = await fetch('/api/cache/unlock', {
|
| 1743 |
-
method: 'POST',
|
| 1744 |
-
headers: {'Content-Type': 'application/json'},
|
| 1745 |
-
body: JSON.stringify(body)
|
| 1746 |
-
});
|
| 1747 |
-
let data = await resp.json();
|
| 1748 |
-
if (data.need_password) {
|
| 1749 |
-
showPasswordModal({type:'quickUnlock', slug: slug});
|
| 1750 |
-
return;
|
| 1751 |
-
}
|
| 1752 |
-
if (data.code === 'OPENAPI_COOLDOWN') {
|
| 1753 |
-
let wait = data.retry_after_seconds || 60;
|
| 1754 |
-
resultDiv.innerHTML = `<div style="color:#f39c12;">⏰ API 冷却中,请等待 ${wait} 秒后重试。</div>`;
|
| 1755 |
-
alert(`冷却中,请等待 ${wait} 秒`);
|
| 1756 |
-
return;
|
| 1757 |
-
}
|
| 1758 |
-
let link = data.link || data.data?.full_url || data.data?.url || data.url;
|
| 1759 |
-
if (link) {
|
| 1760 |
-
localStorage.setItem(`unlock_${slug}`, link);
|
| 1761 |
-
let cacheInfo = '';
|
| 1762 |
-
if (data.from_cache === true) {
|
| 1763 |
-
alert('📦 来自缓存,不消耗积分');
|
| 1764 |
-
cacheInfo = '<span style="color:#2ecc71;">📦 来自缓存,不消耗积分</span>';
|
| 1765 |
-
}
|
| 1766 |
-
let resourceTitle = data.data?.title || data.data?.name || data.data?.resource?.title || data.data?.resource?.name;
|
| 1767 |
-
if (!resourceTitle) {
|
| 1768 |
-
resourceTitle = `资源 ${slug.substring(0,8)}...`;
|
| 1769 |
-
}
|
| 1770 |
-
let panType = data.data?.pan_type || data.data?.resource?.pan_type || 'unknown';
|
| 1771 |
-
if (panType === 'unknown' && link) {
|
| 1772 |
-
panType = detectPanTypeFromLink(link);
|
| 1773 |
-
}
|
| 1774 |
-
let panBadge = getPanBadgeHtml(panType);
|
| 1775 |
-
resultDiv.innerHTML = `
|
| 1776 |
-
<div class="resource-detail">
|
| 1777 |
-
<strong>📽️ ${escapeHtml(resourceTitle)}</strong> ${panBadge}
|
| 1778 |
-
</div>
|
| 1779 |
-
<div><strong>✅ 解锁成功</strong> ${cacheInfo}</div>
|
| 1780 |
-
<div style="margin-top:8px; word-break:break-all;">
|
| 1781 |
-
<a href="${link}" target="_blank">${link}</a>
|
| 1782 |
-
<button class="copy-result-btn" onclick="copyToClipboard('${link.replace(/'/g,"\\'")}', this)">📋 复制</button>
|
| 1783 |
-
</div>
|
| 1784 |
-
`;
|
| 1785 |
-
alert('✅ 解锁成功!');
|
| 1786 |
-
} else {
|
| 1787 |
-
resultDiv.innerHTML = `<div style="color:#e74c3c;">❌ 解锁失败:${data.message || '无返回链接'}</div>`;
|
| 1788 |
-
alert('解锁失败');
|
| 1789 |
-
}
|
| 1790 |
-
} catch(e) {
|
| 1791 |
-
let resultDiv = document.getElementById('quickUnlockResult');
|
| 1792 |
-
resultDiv.innerHTML = `<div style="color:#e74c3c;">❌ 请求异常: ${e.message}</div>`;
|
| 1793 |
-
alert(`错误: ${e.message}`);
|
| 1794 |
-
}
|
| 1795 |
-
}
|
| 1796 |
-
|
| 1797 |
-
function toggleShowPaid(){ showPaid=document.getElementById('show-paid').checked; if(selectedMovie) selectMovie(selectedMovie.id,selectedMovie.media_type); }
|
| 1798 |
-
function renderPagination(){
|
| 1799 |
-
if(totalPages<=1){document.getElementById('pagination').innerHTML='';return;}
|
| 1800 |
-
let html='';
|
| 1801 |
-
if(currentPage>1) html+=`<button class="page-btn" onclick="goToPage(${currentPage-1})">◀</button>`;
|
| 1802 |
-
for(let i=Math.max(1,currentPage-2);i<=Math.min(totalPages,currentPage+2);i++) html+=`<button class="page-btn ${i===currentPage?'active':''}" onclick="goToPage(${i})">${i}</button>`;
|
| 1803 |
-
if(currentPage<totalPages) html+=`<button class="page-btn" onclick="goToPage(${currentPage+1})">▶</button>`;
|
| 1804 |
-
document.getElementById('pagination').innerHTML=html;
|
| 1805 |
-
}
|
| 1806 |
-
function goToPage(page){currentPage=page;loadMovies();window.scrollTo({top:0});}
|
| 1807 |
-
function escapeHtml(str){if(!str)return '';return str.replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'})[m]);}
|
| 1808 |
-
async function copyToClipboard(text,btn){
|
| 1809 |
-
try { await navigator.clipboard.writeText(text); let o=btn.innerText; btn.innerText='✅ 已复制'; setTimeout(()=>btn.innerText=o,1500); }
|
| 1810 |
-
catch(e){ let ta=document.createElement('textarea'); ta.value=text; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); let o=btn.innerText; btn.innerText='✅ 已复制'; setTimeout(()=>btn.innerText=o,1500); }
|
| 1811 |
-
}
|
| 1812 |
-
async function manualBackup(){
|
| 1813 |
-
let btn=event.target; let msg=document.getElementById('backupMsg');
|
| 1814 |
-
btn.disabled=true; msg.innerText='备份中...';
|
| 1815 |
-
try{let data=await fetch('/api/backup',{method:'POST'}).then(r=>r.json());msg.innerText=data.message;setTimeout(()=>msg.innerText='',3000);}catch(e){msg.innerText='失败';}finally{btn.disabled=false;}
|
| 1816 |
-
}
|
| 1817 |
-
async function updateAllStats(){
|
| 1818 |
-
try { let r=await fetch('/api/cache/stats'); let s=await r.json(); document.getElementById('stat-movies').innerText=s.movies||0; document.getElementById('stat-unlocks').innerText=s.unlocks||0; } catch(e){}
|
| 1819 |
-
try { let r=await fetch('/api/auto-unlock-stats'); let d=await r.json(); document.getElementById('stat-auto-unlocked').innerText=d.unlocked_resources||0; } catch(e){}
|
| 1820 |
-
}
|
| 1821 |
-
async function updateCountdown(){
|
| 1822 |
-
try { let r=await fetch('/api/next-unlock-time'); let d=await r.json(); let rem=d.remaining||0; if(rem>0){ let m=Math.floor(rem/60); let s=rem%60; document.getElementById('next-unlock-countdown').innerText=`${m}:${s.toString().padStart(2,'0')}`; } else document.getElementById('next-unlock-countdown').innerText='0:00'; } catch(e){}
|
| 1823 |
-
}
|
| 1824 |
-
// 初始化
|
| 1825 |
-
(async function init() {
|
| 1826 |
-
await Promise.all([loadMovieGenres(), loadTVGenres()]);
|
| 1827 |
-
document.getElementById('movie-presets').querySelectorAll('.preset-btn').forEach(btn => {
|
| 1828 |
-
btn.addEventListener('click', () => selectPreset(btn.dataset.preset));
|
| 1829 |
-
});
|
| 1830 |
-
document.getElementById('tv-presets').querySelectorAll('.preset-btn').forEach(btn => {
|
| 1831 |
-
btn.addEventListener('click', () => selectPreset(btn.dataset.preset));
|
| 1832 |
-
});
|
| 1833 |
-
await switchType('movie');
|
| 1834 |
-
setInterval(updateAllStats, 5000);
|
| 1835 |
-
setInterval(updateCountdown, 1000);
|
| 1836 |
-
updateAllStats();
|
| 1837 |
-
updateCountdown();
|
| 1838 |
-
})();
|
| 1839 |
-
</script>
|
| 1840 |
-
</body>
|
| 1841 |
-
</html>
|
| 1842 |
-
"""
|
| 1843 |
-
|
| 1844 |
-
if __name__ == '__main__':
|
| 1845 |
-
app.run(host='0.0.0.0', port=7860)
|
| 1846 |
-
|
| 1847 |
-
# SQLite WAL 优化
|
| 1848 |
-
try:
|
| 1849 |
-
conn = sqlite3.connect(DB_PATH)
|
| 1850 |
-
conn.execute("PRAGMA journal_mode=WAL")
|
| 1851 |
-
conn.execute("PRAGMA synchronous=NORMAL")
|
| 1852 |
-
conn.close()
|
| 1853 |
-
print("✅ SQLite WAL 已启用")
|
| 1854 |
-
except Exception as e:
|
| 1855 |
-
print("WAL 初始化失败:", e)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|