hdhive / app.py
wd23's picture
Update app.py
8e3096c verified
Raw
History Blame Contribute Delete
95.3 kB
CACHE_VERSION = "2026_fix_v3"
import os
import sys
import logging
from collections import deque
import requests
import json
import sqlite3
import threading
import time
import signal
import tarfile
import random
import re
from datetime import datetime, timedelta
from flask import Flask, request, jsonify, render_template_string, Response
from apscheduler.schedulers.background import BackgroundScheduler
try:
from huggingface_hub import HfApi, hf_hub_download
except ImportError:
print("⚠️ 未安装 huggingface_hub,请执行: pip install huggingface_hub")
sys.exit(1)
app = Flask(__name__)
# ========== 日志捕获 ==========
LOG_BUFFER = deque(maxlen=5000)
class ListHandler(logging.Handler):
def emit(self, record):
try:
msg = self.format(record)
LOG_BUFFER.append(msg)
except Exception:
self.handleError(record)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
root_logger = logging.getLogger()
list_handler = ListHandler()
list_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
root_logger.addHandler(list_handler)
# ========== 配置 ==========
TMDB_API_KEY = os.environ.get("TMDB_API_KEY")
HDHIVE_API_KEY = os.environ.get("HDHIVE_API_KEY")
HF_TOKEN = os.environ.get("HF_TOKEN")
DATASET_REPO = os.environ.get("DATASET_REPO", "hdhive-cache")
UNLOCK_PASSWORD = os.environ.get("UNLOCK_PASSWORD", "")
BACKUP_INTERVAL = 10 * 60
DB_PATH = "hdhive_cache.db"
next_unlock_time = None
processed_movies = set()
scheduler = BackgroundScheduler()
logging.info("🔧 启动配置检查:")
logging.info(f" TMDB_API_KEY: {'✅' if TMDB_API_KEY else '❌'}")
logging.info(f" HDHIVE_API_KEY: {'✅' if HDHIVE_API_KEY else '❌'}")
logging.info(f" HF_TOKEN: {'✅' if HF_TOKEN else '❌'}")
logging.info(f" DATASET_REPO: {DATASET_REPO}")
logging.info(f" 解锁密码: {'✅ 已设置' if UNLOCK_PASSWORD else '❌ 未设置'}")
def get_cache_stats():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT COUNT(*) FROM resource_cache")
movie_count = c.fetchone()[0]
c.execute("SELECT COUNT(*) FROM unlock_cache")
unlock_count = c.fetchone()[0]
conn.close()
return {'movies': movie_count, 'unlocks': unlock_count}
def is_cached(media_type, tmdb_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
row = c.fetchone()
conn.close()
return row is not None
def detect_pan_type(text):
if not text:
return 'unknown'
t = str(text).lower().strip()
PAN_RULES = {
'115': ['115.com', '115cdn.com', 'anxia.com', 'v.anxia.com'],
'baidu': ['pan.baidu.com', '百度网盘'],
'aliyun': ['alipan.com', 'aliyundrive.com'],
'quark': ['pan.quark.cn'],
'uc': ['drive.uc.cn'],
'tianyi': ['cloud.189.cn'],
'123pan': ['123684.com', '123865.com'],
'lanzou': ['lanzou'],
'pikpak': ['pikpak.me']
}
for k, arr in PAN_RULES.items():
if any(x in t for x in arr):
return k
if 'magnet:' in t or 'ed2k://' in t:
return 'magnet'
return 'unknown'
def init_db():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS resource_cache (
media_type TEXT, tmdb_id TEXT, resource_data TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(media_type, tmdb_id)
)''')
c.execute('''CREATE TABLE IF NOT EXISTS unlock_cache (
slug TEXT PRIMARY KEY, link TEXT, unlock_data TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
conn.commit()
conn.close()
logging.info("✅ 基础数据库表初始化完成")
def ensure_tables():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute('''CREATE TABLE IF NOT EXISTS auto_unlocked (
slug TEXT PRIMARY KEY,
unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)''')
conn.commit()
conn.close()
logging.info("✅ 补充表检查完成(auto_unlocked)")
def get_cached_resources(media_type, tmdb_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT resource_data FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
row = c.fetchone()
conn.close()
if row:
try:
return json.loads(row[0])
except Exception:
return None
return None
def save_cached_resources(media_type, tmdb_id, resource_data):
if not resource_data:
print(f"ℹ️ 资源为空,不缓存: {media_type}/{tmdb_id}")
return
has_valid = any(
item.get('link') or item.get('slug') or item.get('url') or
item.get('download_url') or item.get('pan_url')
for item in resource_data
)
if not has_valid:
print(f"⚠️ 资源无有效字段,不缓存: {media_type}/{tmdb_id}")
return
for item in resource_data:
if not item.get('pan_type') or item['pan_type'] == 'unknown':
texts = []
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
val = item.get(field)
if val:
texts.append(str(val))
combined = '; '.join(texts)
item['pan_type'] = detect_pan_type(combined)
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR REPLACE INTO resource_cache (media_type, tmdb_id, resource_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
(media_type, str(tmdb_id), json.dumps(resource_data, ensure_ascii=False)))
conn.commit()
conn.close()
print(f"💾 缓存保存: {media_type}/{tmdb_id}, 共 {len(resource_data)} 个资源")
def delete_cached_resources(media_type, tmdb_id):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("DELETE FROM resource_cache WHERE media_type=? AND tmdb_id=?", (media_type, str(tmdb_id)))
conn.commit()
conn.close()
print(f"🗑️ 已删除缓存: {media_type}/{tmdb_id}")
def get_cached_unlock(slug):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT link, unlock_data FROM unlock_cache WHERE slug=?", (slug,))
row = c.fetchone()
conn.close()
if row and row[0]:
print(f"📦 [缓存命中] 从数据库获取解锁链接: {slug}")
return {'link': row[0], 'data': json.loads(row[1]) if row[1] else None}
print(f"❌ [缓存未命中] 数据库中无记录: {slug}")
return None
def save_cached_unlock(slug, link, unlock_data):
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("INSERT OR REPLACE INTO unlock_cache (slug, link, unlock_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
(slug, link, json.dumps(unlock_data, ensure_ascii=False) if unlock_data else None))
conn.commit()
conn.close()
print(f"💾 [缓存保存] 解锁链接已存入数据库: {slug} -> {link[:80]}...")
api = HfApi()
def backup_to_hf_dataset():
if not HF_TOKEN or not DATASET_REPO or DATASET_REPO == "hdhive-cache":
return False
try:
day = datetime.now().strftime("%Y-%m-%d")
backup_name = f"backup_{day}.tar.gz"
with tarfile.open(backup_name, "w:gz") as tar:
tar.add(DB_PATH, arcname=os.path.basename(DB_PATH))
api.upload_file(path_or_fileobj=backup_name, path_in_repo=backup_name, repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
print(f"✅ 备份成功: {backup_name}")
os.remove(backup_name)
return True
except Exception as e:
print(f"❌ 备份失败: {e}")
return False
def restore_from_hf_dataset():
if not HF_TOKEN or not DATASET_REPO or DATASET_REPO == "hdhive-cache":
print("⚠️ 跳过恢复")
return False
try:
files = api.list_repo_files(repo_id=DATASET_REPO, repo_type="dataset", token=HF_TOKEN)
now = datetime.now()
for i in range(5):
day = (now - timedelta(days=i)).strftime("%Y-%m-%d")
backup_name = f"backup_{day}.tar.gz"
if backup_name in files:
print(f"📦 发现备份: {backup_name},下载中...")
path = hf_hub_download(repo_id=DATASET_REPO, filename=backup_name, repo_type="dataset", token=HF_TOKEN)
with tarfile.open(path, "r:gz") as tar:
tar.extractall(path=".")
print(f"✅ 数据库已恢复")
return True
print("📭 未找到备份")
return False
except Exception as e:
print(f"❌ 恢复失败: {e}")
return False
def periodic_backup():
while True:
time.sleep(BACKUP_INTERVAL)
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 执行备份...")
backup_to_hf_dataset()
def exit_backup(signum, frame):
print(f"收到退出信号,执行最后备份...")
backup_to_hf_dataset()
sys.exit(0)
signal.signal(signal.SIGTERM, exit_backup)
signal.signal(signal.SIGINT, exit_backup)
init_db()
restore_from_hf_dataset()
ensure_tables()
backup_thread = threading.Thread(target=periodic_backup, daemon=True)
backup_thread.start()
print(f"✅ 定时备份已启动,间隔 {BACKUP_INTERVAL // 60} 分钟")
# ========== 图片代理接口 ==========
@app.route('/api/image/proxy')
def image_proxy():
img_url = request.args.get('url', '')
if not img_url:
return '', 400
if not img_url.startswith('https://image.tmdb.org/'):
return '', 403
try:
resp = requests.get(img_url, timeout=30, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://www.themoviedb.org/',
})
if resp.status_code == 200:
return Response(resp.content, status=200, content_type=resp.headers.get('Content-Type', 'image/jpeg'))
else:
print(f"图片代理失败: {img_url}, 状态码: {resp.status_code}")
return '', 500
except requests.exceptions.Timeout:
print(f"图片代理超时: {img_url}")
return '', 504
except Exception as e:
print(f"图片代理异常: {e}, URL: {img_url}")
return '', 500
# ========== 密码验证接口 ==========
@app.route('/api/verify-password', methods=['POST'])
def verify_password():
data = request.json
password = data.get('password', '')
if UNLOCK_PASSWORD and password == UNLOCK_PASSWORD:
return jsonify({'success': True, 'message': '密码正确'})
elif not UNLOCK_PASSWORD:
return jsonify({'success': False, 'message': '未设置解锁密码,请联系管理员'})
else:
return jsonify({'success': False, 'message': '密码错误'})
# ========== 自动解锁统计接口 ==========
@app.route('/api/auto-unlock-stats', methods=['GET'])
def auto_unlock_stats():
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='auto_unlocked'")
if not c.fetchone():
conn.close()
return jsonify({'unlocked_resources': 0})
c.execute("SELECT COUNT(*) FROM auto_unlocked")
unlocked_resources = c.fetchone()[0]
conn.close()
return jsonify({'unlocked_resources': unlocked_resources})
@app.route('/api/next-unlock-time', methods=['GET'])
def get_next_unlock_time():
global next_unlock_time
if next_unlock_time:
remaining = max(0, int(next_unlock_time - time.time()))
return jsonify({'remaining': remaining})
return jsonify({'remaining': 0})
# ========== 日志接口 ==========
@app.route('/api/logs')
def get_logs():
return jsonify({'logs': list(LOG_BUFFER)})
@app.route('/api/logs/clear', methods=['POST'])
def clear_logs():
LOG_BUFFER.clear()
return jsonify({'success': True})
# ========== TMDB 动态分类 API (仅 genre) ==========
@app.route('/api/tmdb/genres/<media_type>')
def get_tmdb_genres(media_type):
if media_type not in ['movie', 'tv']:
return jsonify({'error': 'invalid media type'}), 400
url = f"https://api.themoviedb.org/3/genre/{media_type}/list?api_key={TMDB_API_KEY}&language=zh-CN"
try:
resp = requests.get(url, timeout=10)
data = resp.json()
return jsonify(data.get('genres', []))
except Exception as e:
return jsonify({'error': str(e)}), 500
# ========== API 路由 ==========
@app.route('/')
def index():
return render_template_string(HTML_TEMPLATE)
@app.route('/api/cache/stats')
def cache_stats():
return jsonify(get_cache_stats())
@app.route('/api/discover', methods=['POST'])
def discover():
data = request.json
media_type = data.get('type', 'movie')
page = data.get('page', 1)
preset = data.get('preset')
filters = data.get('filters', {})
sort_by = filters.get('sort_by', 'latest')
def build_discover_url(base_params):
url = f"https://api.themoviedb.org/3/discover/{media_type}?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
for k, v in base_params.items():
url += f"&{k}={v}"
region = filters.get('region')
if region:
region_code = {
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
}.get(region, '')
if region_code:
if media_type == 'movie':
url += f"&with_origin_country={region_code}"
else:
url += f"&with_origin_country={region_code}"
year = filters.get('year')
if year:
if media_type == 'movie':
if year == '2010年前':
url += "&primary_release_date.lte=2009-12-31"
else:
url += f"&primary_release_year={year}"
else:
if year == '2010年前':
url += "&first_air_date.lte=2009-12-31"
else:
url += f"&first_air_date_year={year}"
genres = filters.get('genres', [])
if genres:
genres_str = '|'.join(str(g) for g in genres)
url += f"&with_genres={genres_str}"
return url
if preset:
preset_map_movie = {
'popular': {'sort_by': 'popularity.desc'},
'now_playing': {'sort_by': 'primary_release_date.desc'},
'top_rated': {'sort_by': 'vote_average.desc', 'vote_count.gte': 30},
'upcoming': {'sort_by': 'primary_release_date.asc'}
}
preset_map_tv = {
'popular': {'sort_by': 'popularity.desc'},
'top_rated': {'sort_by': 'vote_average.desc', 'vote_count.gte': 30},
'on_the_air': {'sort_by': 'first_air_date.desc'},
'airing_today': {'sort_by': 'first_air_date.desc'}
}
if media_type == 'movie':
base_params = preset_map_movie.get(preset, {'sort_by': 'popularity.desc'})
else:
base_params = preset_map_tv.get(preset, {'sort_by': 'popularity.desc'})
if filters.get('sort_by'):
user_sort = filters['sort_by']
if user_sort == 'latest':
base_params['sort_by'] = 'primary_release_date.desc' if media_type == 'movie' else 'first_air_date.desc'
elif user_sort == 'imdb':
base_params['sort_by'] = 'vote_average.desc'
base_params['vote_count.gte'] = 30
url = build_discover_url(base_params)
resp = requests.get(url, timeout=15)
result = resp.json()
results = result.get('results', [])
for item in results:
mtype = media_type if media_type in ['movie', 'tv'] else (item.get('media_type') or 'movie')
item['cached'] = is_cached(mtype, item['id'])
result['results'] = results
return jsonify(result)
# 无预设
if media_type == 'movie':
base_url = f"https://api.themoviedb.org/3/discover/movie?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
if sort_by == 'latest':
base_url += "&sort_by=primary_release_date.desc"
else:
base_url += "&sort_by=vote_average.desc&vote_count.gte=30"
region = filters.get('region')
if region:
region_code = {
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
}.get(region, '')
if region_code:
base_url += f"&with_origin_country={region_code}"
year = filters.get('year')
if year:
if year == '2010年前':
base_url += "&primary_release_date.lte=2009-12-31"
else:
base_url += f"&primary_release_year={year}"
genres = filters.get('genres', [])
if genres:
genres_str = '|'.join(str(g) for g in genres)
base_url += f"&with_genres={genres_str}"
else:
base_url = f"https://api.themoviedb.org/3/discover/tv?api_key={TMDB_API_KEY}&language=zh-CN&page={page}"
if sort_by == 'latest':
base_url += "&sort_by=first_air_date.desc"
else:
base_url += "&sort_by=vote_average.desc&vote_count.gte=30"
region = filters.get('region')
if region:
region_code = {
'大陆': 'CN', '香港': 'HK', '台湾': 'TW',
'日本': 'JP', '韩国': 'KR', '欧美': 'US'
}.get(region, '')
if region_code:
base_url += f"&with_origin_country={region_code}"
year = filters.get('year')
if year:
if year == '2010年前':
base_url += "&first_air_date.lte=2009-12-31"
else:
base_url += f"&first_air_date_year={year}"
genres = filters.get('genres', [])
if genres:
genres_str = '|'.join(str(g) for g in genres)
base_url += f"&with_genres={genres_str}"
resp = requests.get(base_url, timeout=15)
result = resp.json()
results = result.get('results', [])
for item in results:
mtype = media_type if media_type in ['movie', 'tv'] else (item.get('media_type') or 'movie')
item['cached'] = is_cached(mtype, item['id'])
result['results'] = results
return jsonify(result)
@app.route('/api/search', methods=['POST'])
def search_api():
data = request.json
query = data.get('query', '')
page = data.get('page', 1)
url = f"https://api.themoviedb.org/3/search/multi?api_key={TMDB_API_KEY}&query={query}&language=zh-CN&page={page}"
resp = requests.get(url, timeout=15)
result = resp.json()
results = result.get('results', [])
filtered = []
for item in results:
if item.get('media_type') in ('movie', 'tv'):
item['cached'] = is_cached(item['media_type'], item['id'])
filtered.append(item)
result['results'] = filtered
return jsonify(result)
@app.route('/api/cache/resources/<type>/<tmdb_id>')
def cache_resources(type, tmdb_id):
cached = get_cached_resources(type, tmdb_id)
if cached:
return jsonify({'success': True, 'from_cache': True, 'resources': cached})
if not HDHIVE_API_KEY:
return jsonify({"success": False, "error": "HDHIVE_API_KEY not configured"}), 500
url = f"https://hdhive.com/api/open/resources/{type}/{tmdb_id}"
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
resp = requests.get(url, headers=headers, timeout=30)
result = resp.json()
resources = result.get('data', []) if isinstance(result, dict) else result
if not resources:
resources = []
for item in resources:
if not item.get('pan_type') or item['pan_type'] == 'unknown':
texts = []
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
val = item.get(field)
if val:
texts.append(str(val))
combined = '; '.join(texts)
item['pan_type'] = detect_pan_type(combined)
save_cached_resources(type, tmdb_id, resources)
return jsonify({'success': True, 'from_cache': False, 'resources': resources})
@app.route('/api/cache/refresh/<type>/<tmdb_id>', methods=['POST'])
def refresh_cache(type, tmdb_id):
delete_cached_resources(type, tmdb_id)
if not HDHIVE_API_KEY:
return jsonify({"success": False, "error": "HDHIVE_API_KEY not configured"}), 500
url = f"https://hdhive.com/api/open/resources/{type}/{tmdb_id}"
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
try:
resp = requests.get(url, headers=headers, timeout=30)
result = resp.json()
resources = result.get('data', []) if isinstance(result, dict) else result
if not resources:
resources = []
for item in resources:
if not item.get('pan_type') or item['pan_type'] == 'unknown':
texts = []
for field in ['link', 'slug', 'title', 'name', 'download_url', 'url', 'pan_url']:
val = item.get(field)
if val:
texts.append(str(val))
combined = '; '.join(texts)
item['pan_type'] = detect_pan_type(combined)
save_cached_resources(type, tmdb_id, resources)
return jsonify({"success": True, "message": "缓存已刷新"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/cache/unlock', methods=['POST'])
def cache_unlock():
data = request.json
slug = data.get('slug')
allow = data.get('allow_points', False)
password = data.get('password', '')
print(f"\n{'='*60}")
print(f"🔓 解锁请求: slug={slug}, allow_points={allow}")
# 第一步:检查本地缓存
cached = get_cached_unlock(slug)
if cached and cached.get('link'):
print(f"✅ 返回缓存链接 (不消耗积分)")
print(f"{'='*60}\n")
return jsonify({'success': True, 'from_cache': True, 'link': cached['link'], 'data': cached['data']})
if not HDHIVE_API_KEY:
print(f"❌ HDHIVE_API_KEY 未配置")
print(f"{'='*60}\n")
return jsonify({"error": "HDHIVE_API_KEY not configured"}), 500
# 第二步:缓存未命中,需要调用 HDHive API
# 先获取资源积分信息(用于判断是否需要密码)
points = None
# 从 resource_cache 中查找该 slug 的积分
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT resource_data FROM resource_cache")
for rrow in c.fetchall():
try:
resources = json.loads(rrow[0])
for res in resources:
if res.get('slug') == slug:
points = res.get('unlock_points', 0)
break
if points is not None:
break
except:
continue
conn.close()
if points is None:
# 抓取页面获取积分
try:
for prefix in ['115', 'baidu', 'magnet']:
url = f"https://hdhive.com/resource/{prefix}/{slug}"
resp = requests.get(url, timeout=10, headers={'User-Agent': 'Mozilla/5.0'})
if resp.status_code == 200:
html = resp.text
match = re.search(r'所需积分[::]\s*(\d+)', html)
if not match:
match = re.search(r'unlock_points[::]\s*(\d+)', html, re.IGNORECASE)
if not match:
match = re.search(r'<span[^>]*class="[^"]*points?[^"]*"[^>]*>(\d+)</span>', html, re.IGNORECASE)
if match:
points = int(match.group(1))
else:
points = 0
break
except Exception as e:
print(f"获取 points 失败: {e}")
points = 0
# 如果需要密码(设置了全局密码 且 资源需要积分)
if UNLOCK_PASSWORD and points and points > 0:
if not password or password != UNLOCK_PASSWORD:
print(f"🔐 付费资源需要密码")
return jsonify({'need_password': True, 'message': '付费资源需要解锁密码'}), 401
print(f"🌐 调用 HDHive API 解锁...")
url = "https://hdhive.com/api/open/resources/unlock"
headers = {"X-API-Key": HDHIVE_API_KEY, "Content-Type": "application/json"}
payload = {"slug": slug}
if allow:
payload["allow_points"] = True
resp = requests.post(url, headers=headers, json=payload, timeout=30)
result = resp.json()
if result.get('code') == 'OPENAPI_COOLDOWN':
print(f"⏰ API 冷却中: {result}")
print(f"{'='*60}\n")
return jsonify(result)
link = result.get('data', {}).get('full_url') or result.get('data', {}).get('url') or result.get('url')
if link:
save_cached_unlock(slug, link, result)
print(f"✅ 解锁成功,已缓存 (消耗积分)")
else:
print(f"❌ 解锁失败: {result}")
print(f"{'='*60}\n")
return jsonify({'success': True, 'from_cache': False, 'link': link, 'data': result.get('data')})
@app.route('/api/backup', methods=['POST'])
def manual_backup():
success = backup_to_hf_dataset()
return jsonify({"message": "备份成功" if success else "备份失败"}), 200 if success else 500
# ========== 后台自动浏览并解锁免费资源 ==========
MOVIE_GENRES = {
28: "动作", 12: "冒险", 16: "动画", 35: "喜剧", 80: "犯罪", 99: "纪录片",
18: "剧情", 10751: "家庭", 14: "奇幻", 36: "历史", 27: "恐怖", 10402: "音乐",
9648: "悬疑", 10749: "爱情", 878: "科幻", 53: "惊悚", 10752: "战争", 37: "西部"
}
TV_GENRES = {
10759: "动作冒险", 16: "动画", 35: "喜剧", 80: "犯罪", 99: "纪录片",
18: "剧情", 10751: "家庭", 10762: "儿童", 9648: "悬疑", 10763: "新闻",
10764: "真人秀", 10765: "科幻奇幻", 10766: "肥皂剧", 10767: "脱口秀", 10768: "政治"
}
def get_random_movie_from_tmdb():
global processed_movies
max_attempts = 10
for attempt in range(max_attempts):
year = random.randint(1900, 2026)
genre_id = random.choice(list(MOVIE_GENRES.keys()))
sort_options = ['popularity.desc', 'vote_average.desc', 'release_date.desc', 'primary_release_date.desc']
sort_by = random.choice(sort_options)
page = random.randint(1, 1000)
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"
print(f"[DEBUG] 电影尝试 {attempt+1}: year={year}, genre={genre_id}, sort={sort_by}, page={page}")
try:
resp = requests.get(url, timeout=15)
data = resp.json()
results = data.get('results', [])
if not results:
continue
random.shuffle(results)
for movie in results:
tmdb_id = movie['id']
key = f"movie_{tmdb_id}"
if key in processed_movies:
continue
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM resource_cache WHERE media_type='movie' AND tmdb_id=?", (str(tmdb_id),))
exists = c.fetchone() is not None
conn.close()
if not exists:
processed_movies.add(key)
print(f"✅ 找到未缓存电影: {movie.get('title', '未知')} (ID: {tmdb_id})")
return {
'media_type': 'movie',
'tmdb_id': tmdb_id,
'title': movie.get('title', '未知'),
'year': year,
'genre': MOVIE_GENRES.get(genre_id, '未知')
}
except Exception as e:
print(f"❌ 电影尝试 {attempt+1} 失败: {e}")
try:
url = f"https://api.themoviedb.org/3/movie/now_playing?api_key={TMDB_API_KEY}&language=zh-CN&page={random.randint(1,5)}"
resp = requests.get(url, timeout=15)
data = resp.json()
results = data.get('results', [])
random.shuffle(results)
for movie in results:
tmdb_id = movie['id']
key = f"movie_{tmdb_id}"
if key in processed_movies:
continue
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM resource_cache WHERE media_type='movie' AND tmdb_id=?", (str(tmdb_id),))
exists = c.fetchone() is not None
conn.close()
if not exists:
processed_movies.add(key)
print(f"✅ [降级] 找到未缓存电影: {movie.get('title', '未知')} (ID: {tmdb_id})")
return {
'media_type': 'movie',
'tmdb_id': tmdb_id,
'title': movie.get('title', '未知'),
'year': movie.get('release_date', '')[:4] if movie.get('release_date') else '',
'genre': '未知'
}
except Exception as e:
print(f"❌ 降级获取电影失败: {e}")
return None
def get_random_tv_from_tmdb():
global processed_movies
max_attempts = 10
for attempt in range(max_attempts):
year = random.randint(1900, 2026)
genre_id = random.choice(list(TV_GENRES.keys()))
sort_options = ['popularity.desc', 'vote_average.desc', 'first_air_date.desc', 'name.asc']
sort_by = random.choice(sort_options)
page = random.randint(1, 1000)
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"
print(f"[DEBUG] 电视剧尝试 {attempt+1}: year={year}, genre={genre_id}, sort={sort_by}, page={page}")
try:
resp = requests.get(url, timeout=15)
data = resp.json()
results = data.get('results', [])
if not results:
continue
random.shuffle(results)
for tv in results:
tmdb_id = tv['id']
key = f"tv_{tmdb_id}"
if key in processed_movies:
continue
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM resource_cache WHERE media_type='tv' AND tmdb_id=?", (str(tmdb_id),))
exists = c.fetchone() is not None
conn.close()
if not exists:
processed_movies.add(key)
print(f"✅ 找到未缓存电视剧: {tv.get('name', '未知')} (ID: {tmdb_id})")
return {
'media_type': 'tv',
'tmdb_id': tmdb_id,
'title': tv.get('name', '未知'),
'year': year,
'genre': TV_GENRES.get(genre_id, '未知')
}
except Exception as e:
print(f"❌ 电视剧尝试 {attempt+1} 失败: {e}")
try:
url = f"https://api.themoviedb.org/3/tv/on_the_air?api_key={TMDB_API_KEY}&language=zh-CN&page={random.randint(1,5)}"
resp = requests.get(url, timeout=15)
data = resp.json()
results = data.get('results', [])
random.shuffle(results)
for tv in results:
tmdb_id = tv['id']
key = f"tv_{tmdb_id}"
if key in processed_movies:
continue
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("SELECT 1 FROM resource_cache WHERE media_type='tv' AND tmdb_id=?", (str(tmdb_id),))
exists = c.fetchone() is not None
conn.close()
if not exists:
processed_movies.add(key)
print(f"✅ [降级] 找到未缓存电视剧: {tv.get('name', '未知')} (ID: {tmdb_id})")
return {
'media_type': 'tv',
'tmdb_id': tmdb_id,
'title': tv.get('name', '未知'),
'year': tv.get('first_air_date', '')[:4] if tv.get('first_air_date') else '',
'genre': '未知'
}
except Exception as e:
print(f"❌ 降级获取电视剧失败: {e}")
return None
def auto_browse_and_unlock():
global next_unlock_time
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 🔄 [后台] 进入自动解锁任务")
if not HDHIVE_API_KEY:
print("❌ [后台] HDHIVE_API_KEY 缺失")
schedule_next_job()
return {'success': False, 'message': 'HDHIVE_API_KEY not configured'}
print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] 🔄 [后台] 开始随机获取未缓存影片...")
media_type_choice = random.choice(['movie', 'tv'])
if media_type_choice == 'movie':
info = get_random_movie_from_tmdb()
if not info:
print(f"📭 [后台] 未找到新的未缓存电影")
schedule_next_job()
return {'success': False, 'message': '未找到新的未缓存电影'}
media_type, tmdb_id, title = 'movie', info['tmdb_id'], info['title']
print(f"🎲 [后台] 选中未缓存电影: {title} (ID: {tmdb_id})")
else:
info = get_random_tv_from_tmdb()
if not info:
print(f"📭 [后台] 未找到新的未缓存电视剧")
schedule_next_job()
return {'success': False, 'message': '未找到新的未缓存电视剧'}
media_type, tmdb_id, title = 'tv', info['tmdb_id'], info['title']
print(f"🎲 [后台] 选中未缓存电视剧: {title} (ID: {tmdb_id})")
url = f"https://hdhive.com/api/open/resources/{media_type}/{tmdb_id}"
headers = {"X-API-Key": HDHIVE_API_KEY, "Accept": "application/json"}
try:
resp = requests.get(url, headers=headers, timeout=30)
result = resp.json()
resources = result.get('data', []) if isinstance(result, dict) else result
if not resources:
print(f"📭 [后台] {title} 没有资源")
schedule_next_job()
return {'success': False, 'message': f'{title} 没有资源'}
print(f"📊 [后台] {title} 共有 {len(resources)} 个资源")
free_count = sum(1 for r in resources if r.get('unlock_points', 0) == 0)
print(f"📊 [后台] 其中免费资源: {free_count} 个")
save_cached_resources(media_type, tmdb_id, resources)
print(f"💾 [后台] 已缓存 {media_type}/{tmdb_id}, 共 {len(resources)} 个资源")
conn = sqlite3.connect(DB_PATH)
c = conn.cursor()
c.execute("CREATE TABLE IF NOT EXISTS auto_unlocked (slug TEXT PRIMARY KEY, unlocked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
unlocked_any = False
for res in resources:
points = res.get('unlock_points', 0)
slug = res.get('slug')
if slug and points == 0:
c.execute("SELECT 1 FROM auto_unlocked WHERE slug=?", (slug,))
if not c.fetchone():
print(f"🔓 [后台] 发现未解锁免费资源,开始解锁: {slug}")
unlock_url = "https://hdhive.com/api/open/resources/unlock"
unlock_payload = {"slug": slug, "allow_points": True}
unlock_resp = requests.post(unlock_url, headers=headers, json=unlock_payload, timeout=30)
unlock_result = unlock_resp.json()
link = unlock_result.get('data', {}).get('full_url') or unlock_result.get('data', {}).get('url') or unlock_result.get('url')
if link:
c.execute("INSERT OR REPLACE INTO unlock_cache (slug, link, unlock_data, updated_at) VALUES (?,?,?, CURRENT_TIMESTAMP)",
(slug, link, json.dumps(unlock_result, ensure_ascii=False)))
c.execute("INSERT INTO auto_unlocked (slug) VALUES (?)", (slug,))
conn.commit()
print(f"✅ [后台] 自动解锁成功: {title} - {slug}")
unlocked_any = True
break
else:
print(f"⚠️ [后台] 解锁失败: {slug} (无链接)")
else:
print(f"⏭️ [后台] 资源已解锁过,跳过: {slug}")
conn.close()
if unlocked_any:
result = {'success': True, 'message': f'解锁成功: {title}', 'title': title}
else:
print(f"📭 [后台] {title} 没有可解锁的免费资源(可能已全部解锁)")
result = {'success': False, 'message': f'{title} 没有可解锁的免费资源'}
schedule_next_job()
return result
except Exception as e:
print(f"❌ [后台] 处理失败: {e}")
schedule_next_job()
return {'success': False, 'message': str(e)}
def schedule_next_job():
interval_minutes = random.randint(1, 3)
next_run = datetime.now() + timedelta(minutes=interval_minutes)
global next_unlock_time
next_unlock_time = next_run.timestamp()
print(f"⏰ [后台] 下次浏览将在 {interval_minutes} 分钟后执行({next_run.strftime('%H:%M:%S')})")
if scheduler.get_job('auto_unlock_job'):
scheduler.remove_job('auto_unlock_job')
scheduler.add_job(auto_browse_and_unlock, 'date', run_date=next_run, id='auto_unlock_job')
schedule_next_job()
scheduler.start()
print(f"✅ 后台自动浏览已启动,随机间隔 1-3 分钟,从 TMDB 所有影片中随机选择未缓存内容")
# ========== HTML 模板 ==========
HTML_TEMPLATE = r"""<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=yes">
<title>HDHive 影视资源</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f0f0f; color: #e0e0e0; padding: 16px; }
.container { max-width: 1200px; margin: 0 auto; }
.header { text-align: center; margin-bottom: 20px; }
h1 { font-size: 28px; font-weight: 600; margin-bottom: 6px; }
.sub { font-size: 14px; color: #aaa; }
.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; }
.stat-item { text-align: center; }
.stat-number { font-size: 24px; font-weight: 700; color: #e50914; }
.stat-label { font-size: 13px; color: #aaa; }
.filter-notice { background: #1a1a2e; border-radius: 12px; padding: 8px 16px; margin-bottom: 16px; text-align: center; font-size: 13px; border: 1px solid #2a2a4a; }
.categories-section { background: #1a1a1a; border-radius: 14px; padding: 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; }
.type-switch { display: flex; gap: 12px; justify-content: center; margin-bottom: 20px; }
.type-btn { padding: 8px 24px; border-radius: 30px; background: #2a2a2a; border: none; color: #ccc; cursor: pointer; font-size: 15px; font-weight: 500; }
.type-btn.active { background: #e50914; color: #fff; }
.preset-bar { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid #2a2a2a; }
.preset-btn { padding: 6px 18px; border-radius: 30px; background: #2a2a2a; border: none; color: #ccc; cursor: pointer; font-size: 13px; transition: 0.2s; }
.preset-btn.active { background: #e50914; color: #fff; }
.filter-collapse { margin-top: 8px; border-top: 1px solid #2a2a2a; }
.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; }
.filter-header:hover { opacity: 0.8; }
.filter-header span:first-child { display: flex; align-items: center; gap: 8px; }
.toggle-icon { font-size: 18px; transition: transform 0.2s; }
.filter-content { transition: all 0.2s ease; overflow: hidden; }
.filter-content.collapsed { display: none; }
.movie-cats, .tv-cats { display: flex; flex-direction: column; gap: 15px; }
.filter-row { margin-bottom: 15px; }
.filter-label { font-size: 13px; color: #aaa; margin-bottom: 8px; text-align: center; font-weight: 600; }
.filter-buttons { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; }
.filter-btn-item { padding: 6px 16px; border-radius: 30px; background: #2a2a2a; border: none; color: #aaa; cursor: pointer; font-size: 13px; transition: 0.2s; }
.filter-btn-item.active { background: #e50914; color: #fff; }
.search-section { background: #1a1a1a; border-radius: 14px; padding: 14px 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; text-align: center; }
.search-bar { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; }
.search-bar input { width: 300px; padding: 12px 16px; border-radius: 40px; background: #2a2a2a; border: none; color: #fff; font-size: 15px; }
.search-bar button { padding: 12px 24px; border-radius: 40px; background: #e50914; border: none; color: #fff; font-weight: 600; cursor: pointer; font-size: 15px; }
/* 快捷解锁面板 */
.quick-unlock { background: #1a1a1a; border-radius: 14px; padding: 14px 16px; margin-bottom: 16px; border: 1px solid #2a2a2a; }
.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; }
.quick-unlock-content { transition: all 0.2s; }
.quick-unlock-content.collapsed { display: none; }
.url-input-group { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.url-input-group input { flex: 1; min-width: 200px; padding: 10px 14px; border-radius: 40px; background: #2a2a2a; border: none; color: #fff; font-size: 14px; }
.url-input-group button { padding: 10px 24px; border-radius: 40px; background: #e50914; border: none; color: #fff; font-weight: 600; cursor: pointer; }
.unlock-result { background: #0f0f0f; border-radius: 12px; padding: 12px; margin-top: 12px; font-size: 13px; word-break: break-all; border-left: 3px solid #e50914; }
.unlock-result a { color: #e50914; text-decoration: none; }
.copy-result-btn { background: #555; border: none; border-radius: 16px; padding: 4px 12px; color: #fff; cursor: pointer; font-size: 11px; margin-left: 10px; }
.resource-detail { display: flex; align-items: center; gap: 12px; margin-bottom: 10px; flex-wrap: wrap; }
.resource-detail .pan-badge { font-size: 12px; }
.two-columns { display: flex; gap: 20px; flex-wrap: wrap; }
.left-panel, .right-panel { flex: 1; min-width: 280px; background: #141414; border-radius: 16px; border: 1px solid #2a2a2a; overflow: visible; }
.panel-header { padding: 14px 18px; border-bottom: 1px solid #2a2a2a; display: flex; justify-content: space-between; align-items: baseline; }
.panel-title { font-size: 16px; font-weight: 600; }
.panel-count { font-size: 13px; color: #888; }
.movies-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; padding: 16px; }
@media (max-width: 700px) { .movies-grid { grid-template-columns: repeat(2, 1fr); } }
.movie-card { background: #1e1e1e; border-radius: 12px; overflow: hidden; cursor: pointer; border: 2px solid transparent; transition: 0.2s; position: relative; }
.movie-card.selected { border-color: #e50914; background: #2a2a2a; }
.movie-poster { width: 100%; aspect-ratio: 2/3; object-fit: cover; background: #2a2a2a; display: block; }
.movie-info { padding: 10px; }
.movie-title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.movie-year { font-size: 12px; color: #aaa; margin-top: 2px; }
.refresh-cache-btn {
position: absolute;
top: 6px;
right: 6px;
background: rgba(0,0,0,0.6);
border: none;
border-radius: 20px;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
color: #fff;
z-index: 10;
backdrop-filter: blur(4px);
transition: 0.2s;
}
.refresh-cache-btn:hover { background: #e50914; transform: scale(1.05); }
.selected-media { padding: 14px; border-bottom: 1px solid #2a2a2a; }
.media-info { display: flex; gap: 14px; }
.media-poster { width: 70px; height: 105px; object-fit: cover; border-radius: 8px; background: #2a2a2a; }
.media-details { flex: 1; }
.media-title { font-size: 18px; font-weight: 700; margin-bottom: 6px; }
.media-meta { display: flex; gap: 8px; margin-bottom: 8px; flex-wrap: wrap; }
.media-badge { background: #e50914; padding: 4px 10px; border-radius: 16px; font-size: 12px; }
.media-overview { font-size: 13px; color: #aaa; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 3; overflow: hidden; }
.resource-header { display: flex; justify-content: space-between; align-items: center; padding: 12px 14px; border-bottom: 1px solid #2a2a2a; }
.checkbox-label { display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
.resource-list { display: flex; flex-direction: column; gap: 12px; padding: 14px; }
.resource-card { background: #1e1e1e; border-radius: 12px; padding: 12px; border-left: 3px solid; }
.resource-card.free { border-left-color: #2ecc71; }
.resource-card.paid { border-left-color: #e50914; }
.pan-badge { display: inline-flex; align-items: center; gap: 4px; padding: 3px 10px; border-radius: 16px; font-size: 11px; font-weight: 600; background: #333; }
.pan-badge-baidu { background: #f39c12; color: #000; }
.pan-badge-115 { background: #4caf50; color: #fff; }
.pan-badge-magnet { background: #9e9e9e; color: #fff; }
.pan-badge-unknown { background: #555; color: #fff; }
.resource-title { font-weight: 600; margin-bottom: 8px; font-size: 14px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.resource-meta { font-size: 12px; color: #aaa; margin-bottom: 10px; display: flex; gap: 12px; flex-wrap: wrap; }
.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; }
.resource-link a { color: #e50914; text-decoration: none; flex: 1; }
.copy-btn { background: #555; border: none; border-radius: 16px; padding: 4px 12px; color: #fff; cursor: pointer; font-size: 11px; transition: 0.2s; }
.copy-btn:hover { background: #e50914; }
.unlock-btn { background: #e50914; border: none; border-radius: 30px; padding: 8px 14px; color: white; font-weight: 600; cursor: pointer; width: 100%; font-size: 13px; }
.pagination { display: flex; justify-content: center; gap: 8px; padding: 14px; border-top: 1px solid #2a2a2a; flex-wrap: wrap; }
.page-btn { padding: 6px 14px; border-radius: 30px; background: #2a2a2a; border: none; color: #fff; cursor: pointer; font-size: 13px; }
.page-btn.active { background: #e50914; }
.loading, .empty { text-align: center; padding: 40px; color: #aaa; font-size: 14px; }
.cache-badge { background: #2ecc71; color: #000; padding: 2px 8px; border-radius: 12px; font-size: 10px; margin-left: 8px; }
.backup-btn { background: #2ecc71; border: none; border-radius: 30px; padding: 6px 14px; color: #000; cursor: pointer; font-size: 13px; margin: 0 6px; }
.status-msg { margin-top: 8px; font-size: 13px; color: #aaa; }
.bottom-bar { text-align: center; margin: 20px 0; }
.debug-btn { background: #e50914; border: none; border-radius: 30px; padding: 6px 14px; color: #fff; cursor: pointer; font-size: 13px; margin: 0 6px; }
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.7); }
.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; }
.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; }
.modal-title { font-size: 18px; font-weight: bold; color: #e50914; }
.close-modal, .clear-logs { background: #333; border: none; padding: 5px 12px; border-radius: 20px; cursor: pointer; color: #fff; margin-left: 8px; }
.close-modal:hover, .clear-logs:hover { background: #e50914; }
.modal-body { flex: 1; overflow-y: auto; padding: 12px 20px; }
.log-entry { margin: 5px 0; padding: 4px; border-left: 3px solid; word-break: break-word; }
.log-success { border-left-color: #2ecc71; }
.log-error { border-left-color: #e74c3c; }
.log-warning { border-left-color: #f39c12; }
.log-info { border-left-color: #3498db; }
.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); }
.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; }
.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; }
.password-modal-content button { background: #e50914; border: none; padding: 10px 20px; border-radius: 30px; color: #fff; cursor: pointer; margin: 5px; }
.toast-msg {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.85);
color: #fff;
padding: 8px 20px;
border-radius: 40px;
font-size: 14px;
z-index: 2000;
backdrop-filter: blur(8px);
border: 1px solid #e50914;
pointer-events: none;
transition: opacity 0.3s;
opacity: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🎬 HDHive 影视资源</h1>
<div class="sub">选择分类 → 浏览影片 → 获取资源链接</div>
</div>
<div class="stats-row">
<div class="stat-item"><span class="stat-number" id="stat-movies">-</span><span class="stat-label"> 缓存影片</span></div>
<div class="stat-item"><span class="stat-number" id="stat-unlocks">-</span><span class="stat-label"> 缓存资源</span></div>
<div class="stat-item"><span class="stat-number" id="stat-auto-unlocked">-</span><span class="stat-label"> 自动解锁资源</span></div>
<div class="stat-item"><span class="stat-number" id="next-unlock-countdown">--:--</span><span class="stat-label"> 下次浏览</span></div>
</div>
<div class="filter-notice">📌 仅显示 <strong>115网盘、百度网盘、磁力链接</strong> 资源 | 图片通过服务器代理</div>
<!-- 快捷解锁面板 -->
<div class="quick-unlock">
<div class="quick-unlock-header" onclick="toggleQuickUnlock()">
<span>🔓 快捷解锁(直接输入 HDHive 资源链接或 slug)</span>
<span class="toggle-icon" id="quickUnlockIcon">▼</span>
</div>
<div class="quick-unlock-content" id="quickUnlockContent">
<div class="url-input-group">
<input type="text" id="quickSlugInput" placeholder="粘贴链接或输入 slug,例如: https://hdhive.com/resource/115/6d09726061d24c69b98b58941e2c8a1e 或 6d09726061d24c69b98b58941e2c8a1e">
<button onclick="quickUnlock()">🚀 解锁</button>
</div>
<div id="quickUnlockResult" class="unlock-result" style="display: none;"></div>
</div>
</div>
<div class="categories-section">
<div class="type-switch">
<button class="type-btn active" id="btn-movie" onclick="switchType('movie')">🎬 电影</button>
<button class="type-btn" id="btn-tv" onclick="switchType('tv')">📺 电视剧</button>
</div>
<!-- 快捷榜单栏 -->
<div id="movie-presets" class="preset-bar" style="display: flex;">
<button class="preset-btn active" data-preset="popular">🔥 热门电影</button>
<button class="preset-btn" data-preset="now_playing">🎬 正在热映</button>
<button class="preset-btn" data-preset="top_rated">⭐ 评分最高</button>
<button class="preset-btn" data-preset="upcoming">📅 即将上映</button>
</div>
<div id="tv-presets" class="preset-bar" style="display: none;">
<button class="preset-btn active" data-preset="popular">🔥 热门剧集</button>
<button class="preset-btn" data-preset="top_rated">⭐ 评分最高</button>
<button class="preset-btn" data-preset="on_the_air">📺 正在播出</button>
<button class="preset-btn" data-preset="airing_today">📅 今日播出</button>
</div>
<!-- 折叠面板 -->
<div class="filter-collapse">
<div class="filter-header" onclick="toggleFilterPanel()">
<span>🔽 筛选条件 (地区/年份/类型/排序)</span>
<span class="toggle-icon" id="filterToggleIcon">▼</span>
</div>
<div class="filter-content" id="filterContent">
<div id="movie-cats" class="movie-cats">
<div class="filter-row"><div class="filter-label">🌍 地区</div><div class="filter-buttons" id="movie-region"></div></div>
<div class="filter-row"><div class="filter-label">📅 年份</div><div class="filter-buttons" id="movie-year"></div></div>
<div class="filter-row"><div class="filter-label">🎭 类型 (TMDB)</div><div class="filter-buttons" id="movie-genre"></div></div>
<div class="filter-row"><div class="filter-label">📊 排序</div><div class="filter-buttons" id="movie-sort"></div></div>
</div>
<div id="tv-cats" class="tv-cats" style="display: none;">
<div class="filter-row"><div class="filter-label">🌍 地区</div><div class="filter-buttons" id="tv-region"></div></div>
<div class="filter-row"><div class="filter-label">📅 年份</div><div class="filter-buttons" id="tv-year"></div></div>
<div class="filter-row"><div class="filter-label">🎭 类型 (TMDB)</div><div class="filter-buttons" id="tv-genre"></div></div>
<div class="filter-row"><div class="filter-label">📊 排序</div><div class="filter-buttons" id="tv-sort"></div></div>
</div>
</div>
</div>
</div>
<div class="search-section">
<div class="search-bar">
<input type="text" id="search-input" placeholder="输入电影/剧集名称..." onkeypress="if(event.key==='Enter') searchByKeyword()">
<button onclick="searchByKeyword()">🔍 搜索</button>
</div>
</div>
<div class="two-columns">
<div class="left-panel">
<div class="panel-header"><div class="panel-title" id="panel-title">🔥 热门电影</div><div class="panel-count" id="panel-count">共 0 部</div></div>
<div class="movies-grid" id="movies-grid"><div class="loading">加载中...</div></div>
<div class="pagination" id="pagination"></div>
</div>
<div class="right-panel">
<div class="selected-media" id="selected-media"><div class="empty">点击上方影视获取资源</div></div>
<div class="resource-header"><div class="panel-title">📥 资源列表</div><label class="checkbox-label"><input type="checkbox" id="show-paid" checked><span>显示付费资源</span></label></div>
<div id="resource-list" class="resource-list"><div class="empty">请先选择影视</div></div>
</div>
</div>
<div class="bottom-bar">
<button class="backup-btn" onclick="manualBackup()">💾 手动备份</button>
<button class="debug-btn" onclick="showDebugModal()">🐛 调试日志</button>
<span id="backupMsg" class="status-msg"></span>
</div>
</div>
<div id="passwordModal" class="password-modal">
<div class="password-modal-content">
<h3>🔐 请输入解锁密码</h3>
<input type="password" id="unlockPassword" placeholder="输入密码" autocomplete="off">
<div>
<button onclick="submitPassword()">确认</button>
<button onclick="closePasswordModal()">取消</button>
</div>
</div>
</div>
<div id="debugModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">📋 系统调试日志</span>
<div>
<button class="clear-logs" onclick="clearAllLogs()">清除日志</button>
<button class="close-modal" onclick="closeDebugModal()">关闭</button>
</div>
</div>
<div class="modal-body" id="debugLogsList"></div>
</div>
</div>
<div id="toastMsg" class="toast-msg"></div>
<script>
// 折叠面板状态
let filterPanelCollapsed = true;
function toggleFilterPanel() {
const content = document.getElementById('filterContent');
const icon = document.getElementById('filterToggleIcon');
if (filterPanelCollapsed) {
content.classList.remove('collapsed');
content.style.display = 'block';
icon.innerHTML = '▲';
filterPanelCollapsed = false;
} else {
content.classList.add('collapsed');
content.style.display = 'none';
icon.innerHTML = '▼';
filterPanelCollapsed = true;
}
}
// 快捷解锁折叠
let quickUnlockCollapsed = false;
function toggleQuickUnlock() {
const content = document.getElementById('quickUnlockContent');
const icon = document.getElementById('quickUnlockIcon');
if (quickUnlockCollapsed) {
content.classList.remove('collapsed');
content.style.display = 'block';
icon.innerHTML = '▼';
quickUnlockCollapsed = false;
} else {
content.classList.add('collapsed');
content.style.display = 'none';
icon.innerHTML = '▶';
quickUnlockCollapsed = true;
}
}
// 初始化折叠状态
document.addEventListener('DOMContentLoaded', function() {
const filterContent = document.getElementById('filterContent');
filterContent.classList.add('collapsed');
filterContent.style.display = 'none';
const quickContent = document.getElementById('quickUnlockContent');
quickContent.classList.remove('collapsed');
quickContent.style.display = 'block';
quickUnlockCollapsed = false;
});
// 提示函数:改为 alert
function showToast(msg, isError = false) {
alert(msg);
}
// 获取网盘徽章 HTML
function getPanBadgeHtml(panType) {
const map = {
baidu: ['百度网盘', '🐻', 'pan-badge-baidu'],
115: ['115网盘', '☁️', 'pan-badge-115'],
magnet: ['磁力链接', '🧲', 'pan-badge-magnet']
};
let [name, icon, cls] = map[panType] || ['其他网盘', '📎', 'pan-badge-unknown'];
return `<span class="pan-badge ${cls}">${icon} ${name}</span>`;
}
// 增强版链接网盘类型检测(支持 115cdn.com, anxia.com 等)
function detectPanTypeFromLink(link) {
if (!link) return 'unknown';
let l = link.toLowerCase();
if (l.includes('pan.baidu.com') || l.includes('baidu.com/s/')) return 'baidu';
if (l.includes('115.com') || l.includes('115cdn.com') || l.includes('anxia.com') || l.includes('v.anxia.com')) return '115';
if (l.includes('magnet:') || l.includes('ed2k://')) return 'magnet';
return 'unknown';
}
// 日志相关
let logRefreshInterval = null;
async function fetchLogs() {
try {
let resp = await fetch('/api/logs');
let data = await resp.json();
let container = document.getElementById('debugLogsList');
if (!container) return;
let logs = data.logs || [];
if (logs.length === 0) {
container.innerHTML = '<div style="text-align:center;color:#aaa;">暂无日志</div>';
return;
}
let wasAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 10;
container.innerHTML = logs.map(line => {
let cls = 'log-info';
let lowerLine = line.toLowerCase();
if (lowerLine.includes('error') || lowerLine.includes('exception') || lowerLine.includes('失败')) cls = 'log-error';
else if (lowerLine.includes('warning') || lowerLine.includes('警告')) cls = 'log-warning';
else if (lowerLine.includes('✅') || lowerLine.includes('成功')) cls = 'log-success';
return `<div class="log-entry ${cls}">${escapeHtml(line)}</div>`;
}).join('');
if (wasAtBottom) setTimeout(() => container.scrollTop = container.scrollHeight, 10);
} catch(e) { console.error(e); }
}
function showDebugModal() {
let modal = document.getElementById('debugModal');
modal.style.display = 'block';
fetchLogs();
if (logRefreshInterval) clearInterval(logRefreshInterval);
logRefreshInterval = setInterval(fetchLogs, 2000);
setTimeout(() => { let c = document.getElementById('debugLogsList'); if(c) c.scrollTop = c.scrollHeight; }, 100);
}
function closeDebugModal() {
document.getElementById('debugModal').style.display = 'none';
if (logRefreshInterval) clearInterval(logRefreshInterval);
}
async function clearAllLogs() {
await fetch('/api/logs/clear', { method: 'POST' });
fetchLogs();
}
// 通用函数
let pendingAction = null;
function showPasswordModal(action) { pendingAction=action; document.getElementById('passwordModal').style.display='block'; document.getElementById('unlockPassword').focus(); }
function closePasswordModal() { document.getElementById('passwordModal').style.display='none'; pendingAction=null; }
async function submitPassword() {
let pwd=document.getElementById('unlockPassword').value;
if(!pwd){ alert('请输入密码'); return; }
try {
let r=await fetch('/api/verify-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
let d=await r.json();
if(d.success) {
document.getElementById('passwordModal').style.display='none';
if(pendingAction) {
if(pendingAction.type === 'unlock') await doUnlockResource(pendingAction.slug, pendingAction.points, pwd);
else if(pendingAction.type === 'refresh') await doRefreshCache(pendingAction.mediaType, pendingAction.tmdbId, pendingAction.btn);
else if(pendingAction.type === 'quickUnlock') await doQuickUnlock(pendingAction.slug, pwd);
}
pendingAction = null;
} else { alert(d.message); }
} catch(e){ alert(`验证失败: ${e.message}`); }
}
function getPosterUrl(path) { if(!path) return ''; let tmdbUrl=`https://image.tmdb.org/t/p/w342${path}`; return `/api/image/proxy?url=${encodeURIComponent(tmdbUrl)}`; }
function attachImageListeners(img,path) { img.addEventListener('error',()=>{ img.style.display='none'; if(img.parentElement) img.parentElement.style.background='#2a2a2a'; }); }
let currentType='movie', currentPage=1, totalPages=1;
let currentMovies=[], selectedMovie=null, showPaid=true, currentQuery='';
let movieFilters={ region:null, year:null, genres:[], sort_by:'latest' };
let tvFilters={ region:null, year:null, genres:[], sort_by:'latest' };
let currentPreset = 'popular';
let movieGenres = [];
let tvGenres = [];
const regionOptions = ['大陆','香港','台湾','日本','韩国','欧美'];
const currentYear = new Date().getFullYear();
const yearList = (() => {
let years = [];
for (let y = currentYear; y >= 2010; y--) years.push(y.toString());
years.push('2010年前');
return years;
})();
const sortOptions = [{value:'latest', label:'最新'},{value:'imdb', label:'IMDb 评分'},{value:'douban', label:'豆瓣评分'}];
const ALLOWED_PAN_TYPES=['115','baidu','magnet'];
function extractLinkFromResource(r){return r.link||r.download_url||r.url||r.pan_url||'';}
// 加载 TMDB 类型
async function loadMovieGenres() {
try { let resp = await fetch('/api/tmdb/genres/movie'); let data = await resp.json(); movieGenres = data; } catch(e) { console.error(e); movieGenres = []; }
}
async function loadTVGenres() {
try { let resp = await fetch('/api/tmdb/genres/tv'); let data = await resp.json(); tvGenres = data; } catch(e) { console.error(e); tvGenres = []; }
}
// 渲染静态筛选按钮
function renderMovieRegionButtons() {
const container = document.getElementById('movie-region');
if (!container) return;
container.innerHTML = regionOptions.map(r => `<button class="filter-btn-item ${movieFilters.region===r?'active':''}" onclick="selectMovieRegion('${r}')">${r}</button>`).join('');
}
function renderTVRegionButtons() {
const container = document.getElementById('tv-region');
if (!container) return;
container.innerHTML = regionOptions.map(r => `<button class="filter-btn-item ${tvFilters.region===r?'active':''}" onclick="selectTVRegion('${r}')">${r}</button>`).join('');
}
function renderMovieYearButtons() {
const container = document.getElementById('movie-year');
if (!container) return;
container.innerHTML = yearList.map(y => `<button class="filter-btn-item ${movieFilters.year===y?'active':''}" onclick="selectMovieYear('${y}')">${y}</button>`).join('');
}
function renderTVYearButtons() {
const container = document.getElementById('tv-year');
if (!container) return;
container.innerHTML = yearList.map(y => `<button class="filter-btn-item ${tvFilters.year===y?'active':''}" onclick="selectTVYear('${y}')">${y}</button>`).join('');
}
function renderMovieGenreButtons() {
const container = document.getElementById('movie-genre');
if (!container) return;
container.innerHTML = movieGenres.map(g => `<button class="filter-btn-item ${movieFilters.genres.includes(g.id)?'active':''}" onclick="toggleMovieGenre(${g.id})">${g.name}</button>`).join('');
}
function renderTVGenreButtons() {
const container = document.getElementById('tv-genre');
if (!container) return;
container.innerHTML = tvGenres.map(g => `<button class="filter-btn-item ${tvFilters.genres.includes(g.id)?'active':''}" onclick="toggleTVGenre(${g.id})">${g.name}</button>`).join('');
}
function renderMovieSortButtons() {
const container = document.getElementById('movie-sort');
if (!container) return;
container.innerHTML = sortOptions.map(s => `<button class="filter-btn-item ${movieFilters.sort_by===s.value?'active':''}" onclick="selectMovieSort('${s.value}')">${s.label}</button>`).join('');
}
function renderTVSortButtons() {
const container = document.getElementById('tv-sort');
if (!container) return;
container.innerHTML = sortOptions.map(s => `<button class="filter-btn-item ${tvFilters.sort_by===s.value?'active':''}" onclick="selectTVSort('${s.value}')">${s.label}</button>`).join('');
}
function updatePresetButtons() {
const moviePresetDiv = document.getElementById('movie-presets');
const tvPresetDiv = document.getElementById('tv-presets');
if (currentType === 'movie') {
const btns = moviePresetDiv.querySelectorAll('.preset-btn');
btns.forEach(btn => {
if (btn.dataset.preset === currentPreset) btn.classList.add('active');
else btn.classList.remove('active');
});
} else {
const btns = tvPresetDiv.querySelectorAll('.preset-btn');
btns.forEach(btn => {
if (btn.dataset.preset === currentPreset) btn.classList.add('active');
else btn.classList.remove('active');
});
}
}
function resetFilters() {
if (currentType === 'movie') {
movieFilters = { region:null, year:null, genres:[], sort_by:'latest' };
renderMovieRegionButtons();
renderMovieYearButtons();
renderMovieGenreButtons();
renderMovieSortButtons();
} else {
tvFilters = { region:null, year:null, genres:[], sort_by:'latest' };
renderTVRegionButtons();
renderTVYearButtons();
renderTVGenreButtons();
renderTVSortButtons();
}
}
function selectPreset(preset) {
if (currentPreset === preset) {
currentPreset = null;
} else {
currentPreset = preset;
}
updatePresetButtons();
currentPage = 1;
selectedMovie = null;
loadMovies();
}
function selectMovieRegion(code) {
movieFilters.region = (movieFilters.region===code ? null : code);
renderMovieRegionButtons();
loadMovies();
}
function selectMovieYear(year) {
movieFilters.year = (movieFilters.year===year ? null : year);
renderMovieYearButtons();
loadMovies();
}
function toggleMovieGenre(id) {
if(movieFilters.genres.includes(id)) movieFilters.genres = movieFilters.genres.filter(g=>g!==id);
else movieFilters.genres.push(id);
renderMovieGenreButtons();
loadMovies();
}
function selectMovieSort(sort) {
movieFilters.sort_by = sort;
renderMovieSortButtons();
loadMovies();
}
function selectTVRegion(code) {
tvFilters.region = (tvFilters.region===code ? null : code);
renderTVRegionButtons();
loadMovies();
}
function selectTVYear(year) {
tvFilters.year = (tvFilters.year===year ? null : year);
renderTVYearButtons();
loadMovies();
}
function toggleTVGenre(id) {
if(tvFilters.genres.includes(id)) tvFilters.genres = tvFilters.genres.filter(g=>g!==id);
else tvFilters.genres.push(id);
renderTVGenreButtons();
loadMovies();
}
function selectTVSort(sort) {
tvFilters.sort_by = sort;
renderTVSortButtons();
loadMovies();
}
async function switchType(type){
currentType=type; currentPage=1; currentQuery=''; selectedMovie=null;
currentPreset = 'popular';
resetFilters();
document.getElementById('search-input').value='';
document.getElementById('btn-movie').classList.toggle('active',type==='movie');
document.getElementById('btn-tv').classList.toggle('active',type==='tv');
document.getElementById('movie-presets').style.display = type==='movie' ? 'flex' : 'none';
document.getElementById('tv-presets').style.display = type==='tv' ? 'flex' : 'none';
if(type==='movie') {
document.getElementById('movie-cats').style.display='flex';
document.getElementById('tv-cats').style.display='none';
renderMovieRegionButtons(); renderMovieYearButtons(); renderMovieGenreButtons(); renderMovieSortButtons();
} else {
document.getElementById('movie-cats').style.display='none';
document.getElementById('tv-cats').style.display='block';
renderTVRegionButtons(); renderTVYearButtons(); renderTVGenreButtons(); renderTVSortButtons();
}
updatePresetButtons();
loadMovies();
document.getElementById('selected-media').innerHTML='<div class="empty">点击上方影视获取资源</div>';
document.getElementById('resource-list').innerHTML='<div class="empty">请先选择影视</div>';
document.querySelectorAll('.movie-card').forEach(c=>c.classList.remove('selected'));
}
async function loadMovies(){
document.getElementById('movies-grid').innerHTML='<div class="loading">加载中...</div>';
try {
let params, resp;
if(currentQuery) {
params = {query: currentQuery, page: currentPage};
resp = await fetch('/api/search',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});
let data = await resp.json();
currentMovies = data.results || [];
totalPages = data.total_pages || 1;
document.getElementById('panel-title').innerHTML = `🔍 搜索结果: ${currentQuery}`;
} else {
let filters = (currentType === 'movie') ? { ...movieFilters } : { ...tvFilters };
params = {type: currentType, page: currentPage, filters: filters};
if (currentPreset) {
params.preset = currentPreset;
}
resp = await fetch('/api/discover',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(params)});
let data = await resp.json();
currentMovies = data.results || [];
totalPages = data.total_pages || 1;
let title = '';
if (currentPreset) {
const presetNames = {
popular: '热门', now_playing: '正在热映', top_rated: '评分最高', upcoming: '即将上映',
on_the_air: '正在播出', airing_today: '今日播出'
};
title = presetNames[currentPreset] || (currentType==='movie'?'电影':'电视剧');
} else {
if (currentType === 'movie') {
if(movieFilters.region) title += movieFilters.region + ' ';
if(movieFilters.year) title += (movieFilters.year==='2010年前'?'2010年前':movieFilters.year+'年') + ' ';
if(movieFilters.genres.length) title += movieGenres.find(g=>g.id===movieFilters.genres[0])?.name || '';
if(!title) title = '电影';
} else {
if(tvFilters.region) title += tvFilters.region + ' ';
if(tvFilters.year) title += (tvFilters.year==='2010年前'?'2010年前':tvFilters.year+'年') + ' ';
if(tvFilters.genres.length) title += tvGenres.find(g=>g.id===tvFilters.genres[0])?.name || '';
if(!title) title = '电视剧';
}
}
document.getElementById('panel-title').innerHTML = title;
}
renderMovies();
renderPagination();
document.getElementById('panel-count').innerHTML = `共 ${currentMovies.length} 部`;
} catch(e){
document.getElementById('movies-grid').innerHTML='<div class="empty">❌ 加载失败</div>';
console.error(e);
}
}
async function searchByKeyword(){
let kw=document.getElementById('search-input').value.trim();
if(!kw) return;
currentQuery=kw; currentPage=1; selectedMovie=null;
currentPreset = null;
resetFilters();
updatePresetButtons();
await loadMovies();
document.getElementById('selected-media').innerHTML='<div class="empty">点击上方影视获取资源</div>';
document.getElementById('resource-list').innerHTML='<div class="empty">请先选择影视</div>';
document.querySelectorAll('.movie-card').forEach(c=>c.classList.remove('selected'));
}
function renderMovies(){
if(!currentMovies.length){ document.getElementById('movies-grid').innerHTML='<div class="empty">暂无数据</div>'; return; }
document.getElementById('movies-grid').innerHTML=currentMovies.map(m=>{
let posterUrl=m.poster_path?getPosterUrl(m.poster_path):'';
let title=m.title||m.name||'未知';
let year=(m.release_date||m.first_air_date||'').slice(0,4);
let isSelected=selectedMovie&&selectedMovie.id===m.id;
let mediaType=m.media_type||(m.title?'movie':'tv');
let cached=m.cached===true;
let refreshBtnHtml=cached?`<button class="refresh-cache-btn" onclick="event.stopPropagation(); refreshCache('${mediaType}',${m.id},this)">🔄</button>`:'';
return `<div class="movie-card ${isSelected?'selected':''}" data-id="${m.id}" data-type="${mediaType}">
${refreshBtnHtml}
${posterUrl?`<img class="movie-poster" src="${posterUrl}" data-poster-path="${m.poster_path}" loading="lazy">`:'<div class="movie-poster" style="background:#2a2a2a;"></div>'}
<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>
</div>`;
}).join('');
document.querySelectorAll('.movie-poster').forEach(img=>{let path=img.getAttribute('data-poster-path');if(path)attachImageListeners(img,path);});
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);}));
}
async function refreshCache(mediaType,tmdbId,btn){
showPasswordModal({type:'refresh', mediaType, tmdbId, btn});
}
async function doRefreshCache(mediaType,tmdbId,btn){
if(!confirm('确定刷新缓存?')) return;
let msgDiv=document.getElementById('backupMsg');
msgDiv.innerText='🔄 刷新中...';
try{
let resp=await fetch(`/api/cache/refresh/${mediaType}/${tmdbId}`,{method:'POST'});
let data=await resp.json();
if(data.success){
msgDiv.innerText='✅ 刷新成功!';
setTimeout(()=>{if(msgDiv.innerText==='✅ 刷新成功!')msgDiv.innerText='';},3000);
if(selectedMovie&&selectedMovie.id===tmdbId) await selectMovie(tmdbId,mediaType);
await loadMovies();
await updateAllStats();
} else { msgDiv.innerText=`❌ 失败: ${data.error||'未知'}`; }
}catch(e){ msgDiv.innerText=`❌ 错误: ${e.message}`; }
}
async function selectMovie(id,forcedType){
let movie=currentMovies.find(m=>m.id===id);
if(!movie) return;
selectedMovie=movie;
let mediaType=forcedType||movie.media_type||(movie.title?'movie':'tv');
selectedMovie.media_type = mediaType;
document.querySelectorAll('.movie-card').forEach(c=>c.classList.toggle('selected',parseInt(c.dataset.id)===id));
let poster=movie.poster_path?getPosterUrl(movie.poster_path):'';
let title=movie.title||movie.name||'未知';
let year=(movie.release_date||movie.first_air_date||'').slice(0,4);
let overview=movie.overview||'暂无简介';
document.getElementById('selected-media').innerHTML=`
<div class="media-info">
${poster?`<img class="media-poster" src="${poster}" onerror="this.style.display='none'">`:'<div class="media-poster" style="background:#2a2a2a;"></div>'}
<div class="media-details">
<div class="media-title">${escapeHtml(title)}</div>
<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>
<div class="media-overview">${escapeHtml(overview)}</div>
</div>
</div>`;
document.getElementById('resource-list').innerHTML='<div class="loading">获取资源中...</div>';
try{
let resp=await fetch(`/api/cache/resources/${mediaType}/${movie.id}`);
let data=await resp.json();
if(!data.success){document.getElementById('resource-list').innerHTML='<div class="empty">暂无资源</div>';return;}
let resources=data.resources||[];
if(resources.length===0){document.getElementById('resource-list').innerHTML='<div class="empty">暂无资源</div>';return;}
let freeItems=[],paidItems=[];
for(let r of resources){
let points=r.unlock_points||0;
let link=extractLinkFromResource(r);
let panType=detectPanTypeFromLink(link);
if(panType==='unknown') panType=(r.pan_type||'unknown').toLowerCase();
if(!ALLOWED_PAN_TYPES.includes(panType)) continue;
if(points===0||r.is_free_for_user===true) freeItems.push({...r,panType,link});
else paidItems.push({...r,panType,link});
}
let allItems=[...freeItems,...(showPaid?paidItems:[])];
if(allItems.length===0){document.getElementById('resource-list').innerHTML='<div class="empty">暂无115、百度、磁力资源</div>';return;}
let html=`<div style="margin-bottom:10px;"><strong>🎁 资源列表</strong> ${data.from_cache?'<span class="cache-badge">📦 缓存</span>':''}</div>`;
for(let r of allItems){
let points=r.unlock_points||0;
let isFree=points===0||r.is_free_for_user===true;
let link=r.link;
if(!link&&r.slug) link=localStorage.getItem(`unlock_${r.slug}`);
let panBadge=getPanBadgeHtml(r.panType);
if(link){
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>`;
}else{
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>`;
}
}
document.getElementById('resource-list').innerHTML=html;
let rightPanel=document.querySelector('.right-panel');
if(rightPanel) rightPanel.scrollIntoView({behavior:'smooth',block:'start'});
}catch(e){document.getElementById('resource-list').innerHTML=`<div class="empty">❌ 获取失败: ${e.message}</div>`; console.error(e);}
}
// ========== 修改后的解锁逻辑 ==========
// 左侧资源列表的解锁处理
function handleUnlockClick(slug, points) {
// 直接尝试解锁(先查缓存),不再预先判断 points
doUnlockResource(slug, points, null);
}
async function doUnlockResource(slug, points, password = null) {
try {
let body = {slug: slug, allow_points: true};
if (password) {
body.password = password;
}
let resp = await fetch('/api/cache/unlock', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
let data = await resp.json();
if (data.need_password) {
showPasswordModal({type:'unlock', slug, points});
return;
}
if (data.from_cache === true) alert('📦 来自缓存,不消耗积分');
if (data.code === 'OPENAPI_COOLDOWN') {
let wait = data.retry_after_seconds || 60;
alert(`请等待 ${wait} 秒`);
setTimeout(() => { if(selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type); }, wait * 1000);
return;
}
let link = data.link || data.data?.full_url || data.data?.url || data.url;
if (link) {
localStorage.setItem(`unlock_${slug}`, link);
alert('✅ 解锁成功!');
} else {
alert('❌ 解锁失败');
}
if (selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type);
} catch(e) {
alert(`错误: ${e.message}`);
if (selectedMovie) selectMovie(selectedMovie.id, selectedMovie.media_type);
}
}
// 快捷解锁
function extractSlugFromUrl(input) {
let trimmed = input.trim();
if (/^[a-f0-9]{32}$/i.test(trimmed)) return trimmed;
let match = trimmed.match(/\/resource\/(?:[^\/]+\/)?([a-f0-9]{32})/i);
if (match) return match[1];
match = trimmed.match(/([a-f0-9]{32})/i);
return match ? match[1] : null;
}
async function quickUnlock() {
let raw = document.getElementById('quickSlugInput').value;
if (!raw.trim()) {
alert('请输入资源链接或 slug');
return;
}
let slug = extractSlugFromUrl(raw);
if (!slug) {
alert('未能提取到有效的 slug (32位十六进制)');
return;
}
let resultDiv = document.getElementById('quickUnlockResult');
resultDiv.style.display = 'block';
resultDiv.innerHTML = '<div style="color:#aaa;">🔓 解锁中...</div>';
// 直接调用解锁(不带密码),后端会先查缓存,再根据是否需要密码返回 need_password
doQuickUnlock(slug, null);
}
async function doQuickUnlock(slug, password) {
let resultDiv = document.getElementById('quickUnlockResult');
resultDiv.innerHTML = '<div style="color:#aaa;">🔓 解锁中...</div>';
try {
let body = {slug: slug, allow_points: true};
if (password) {
body.password = password;
}
let resp = await fetch('/api/cache/unlock', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
let data = await resp.json();
if (data.need_password) {
showPasswordModal({type:'quickUnlock', slug: slug});
return;
}
if (data.code === 'OPENAPI_COOLDOWN') {
let wait = data.retry_after_seconds || 60;
resultDiv.innerHTML = `<div style="color:#f39c12;">⏰ API 冷却中,请等待 ${wait} 秒后重试。</div>`;
alert(`冷却中,请等待 ${wait} 秒`);
return;
}
let link = data.link || data.data?.full_url || data.data?.url || data.url;
if (link) {
localStorage.setItem(`unlock_${slug}`, link);
let cacheInfo = '';
if (data.from_cache === true) {
alert('📦 来自缓存,不消耗积分');
cacheInfo = '<span style="color:#2ecc71;">📦 来自缓存,不消耗积分</span>';
}
let resourceTitle = data.data?.title || data.data?.name || data.data?.resource?.title || data.data?.resource?.name;
if (!resourceTitle) {
resourceTitle = `资源 ${slug.substring(0,8)}...`;
}
let panType = data.data?.pan_type || data.data?.resource?.pan_type || 'unknown';
if (panType === 'unknown' && link) {
panType = detectPanTypeFromLink(link);
}
let panBadge = getPanBadgeHtml(panType);
resultDiv.innerHTML = `
<div class="resource-detail">
<strong>📽️ ${escapeHtml(resourceTitle)}</strong> ${panBadge}
</div>
<div><strong>✅ 解锁成功</strong> ${cacheInfo}</div>
<div style="margin-top:8px; word-break:break-all;">
<a href="${link}" target="_blank">${link}</a>
<button class="copy-result-btn" onclick="copyToClipboard('${link.replace(/'/g,"\\'")}', this)">📋 复制</button>
</div>
`;
alert('✅ 解锁成功!');
} else {
resultDiv.innerHTML = `<div style="color:#e74c3c;">❌ 解锁失败:${data.message || '无返回链接'}</div>`;
alert('解锁失败');
}
} catch(e) {
let resultDiv = document.getElementById('quickUnlockResult');
resultDiv.innerHTML = `<div style="color:#e74c3c;">❌ 请求异常: ${e.message}</div>`;
alert(`错误: ${e.message}`);
}
}
function toggleShowPaid(){ showPaid=document.getElementById('show-paid').checked; if(selectedMovie) selectMovie(selectedMovie.id,selectedMovie.media_type); }
function renderPagination(){
if(totalPages<=1){document.getElementById('pagination').innerHTML='';return;}
let html='';
if(currentPage>1) html+=`<button class="page-btn" onclick="goToPage(${currentPage-1})">◀</button>`;
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>`;
if(currentPage<totalPages) html+=`<button class="page-btn" onclick="goToPage(${currentPage+1})">▶</button>`;
document.getElementById('pagination').innerHTML=html;
}
function goToPage(page){currentPage=page;loadMovies();window.scrollTo({top:0});}
function escapeHtml(str){if(!str)return '';return str.replace(/[&<>]/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;'})[m]);}
async function copyToClipboard(text,btn){
try { await navigator.clipboard.writeText(text); let o=btn.innerText; btn.innerText='✅ 已复制'; setTimeout(()=>btn.innerText=o,1500); }
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); }
}
async function manualBackup(){
let btn=event.target; let msg=document.getElementById('backupMsg');
btn.disabled=true; msg.innerText='备份中...';
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;}
}
async function updateAllStats(){
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){}
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){}
}
async function updateCountdown(){
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){}
}
// 初始化
(async function init() {
await Promise.all([loadMovieGenres(), loadTVGenres()]);
document.getElementById('movie-presets').querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => selectPreset(btn.dataset.preset));
});
document.getElementById('tv-presets').querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => selectPreset(btn.dataset.preset));
});
await switchType('movie');
setInterval(updateAllStats, 5000);
setInterval(updateCountdown, 1000);
updateAllStats();
updateCountdown();
})();
</script>
</body>
</html>
"""
if __name__ == '__main__':
app.run(host='0.0.0.0', port=7860)
# SQLite WAL 优化
try:
conn = sqlite3.connect(DB_PATH)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.close()
print("✅ SQLite WAL 已启用")
except Exception as e:
print("WAL 初始化失败:", e)