| 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}) |
|
|
| |
| @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 |
|
|
| |
| @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 |
| |
| |
| |
| points = None |
| |
| 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_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=>({'&':'&','<':'<','>':'>'})[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) |
|
|
| |
| 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) |