├── frontend ├── src │ ├── assets │ │ └── main.css │ ├── main.js │ ├── api │ │ └── index.js │ ├── App.vue │ ├── components │ │ ├── DisplayOrderManager.vue │ │ ├── VirtualLibraries.vue │ │ ├── SystemSettings.vue │ │ └── LibraryEditDialog.vue │ └── stores │ │ └── main.js ├── vite.config.js ├── index.html ├── package.json └── favicon.svg ├── src ├── rss_processor │ ├── __init__.py │ ├── douban.py │ └── bangumi.py ├── assets │ ├── fonts │ │ ├── wendao.ttf │ │ ├── phosphate.ttf │ │ ├── lilitaone.woff2 │ │ ├── multi_1_en.otf │ │ ├── multi_1_zh.ttf │ │ ├── EmblemaOne.woff2 │ │ └── josefinsans.woff2 │ └── images_placeholder │ │ ├── generating.jpg │ │ ├── placeholder.jpg │ │ └── rsshubpost.jpg ├── proxy_handlers │ ├── __init__.py │ ├── handler_merger.py │ ├── handler_virtual_items.py │ ├── handler_images.py │ ├── handler_default.py │ ├── handler_system.py │ ├── handler_seasons.py │ ├── _find_helper.py │ ├── _filter_translator.py │ ├── handler_rss.py │ ├── handler_views.py │ ├── handler_autogen.py │ ├── handler_episodes.py │ ├── handler_latest.py │ └── handler_items.py ├── requirements.txt ├── proxy_cache.py ├── main.py ├── config_manager.py ├── minimal_proxy.py ├── models.py ├── db_manager.py ├── proxy_server.py └── cover_generator │ └── style_single_2.py ├── .gitignore ├── docker-compose.yml ├── supervisord.conf ├── Dockerfile └── README.md /frontend/src/assets/main.css: -------------------------------------------------------------------------------- 1 | /* 这个文件可以留空,或者保留默认内容 */ -------------------------------------------------------------------------------- /src/rss_processor/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the rss_processor directory a Python package. 2 | -------------------------------------------------------------------------------- /src/assets/fonts/wendao.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/wendao.ttf -------------------------------------------------------------------------------- /src/assets/fonts/phosphate.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/phosphate.ttf -------------------------------------------------------------------------------- /src/assets/fonts/lilitaone.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/lilitaone.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/multi_1_en.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/multi_1_en.otf -------------------------------------------------------------------------------- /src/assets/fonts/multi_1_zh.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/multi_1_zh.ttf -------------------------------------------------------------------------------- /src/assets/fonts/EmblemaOne.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/EmblemaOne.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/josefinsans.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/fonts/josefinsans.woff2 -------------------------------------------------------------------------------- /src/assets/images_placeholder/generating.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/images_placeholder/generating.jpg -------------------------------------------------------------------------------- /src/assets/images_placeholder/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/images_placeholder/placeholder.jpg -------------------------------------------------------------------------------- /src/assets/images_placeholder/rsshubpost.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipi20xx/emby-virtual-proxy/HEAD/src/assets/images_placeholder/rsshubpost.jpg -------------------------------------------------------------------------------- /src/proxy_handlers/__init__.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/__init__.py 2 | from . import handler_merger 3 | from . import handler_seasons 4 | from . import handler_episodes -------------------------------------------------------------------------------- /src/requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | uvicorn[standard] 3 | aiohttp 4 | pydantic 5 | cachetools 6 | websockets 7 | docker 8 | Pillow==10.4.0 9 | numpy==1.26.4 10 | supervisor 11 | python-multipart 12 | Brotli 13 | requests 14 | beautifulsoup4 15 | lxml 16 | python-dotenv 17 | apscheduler 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VS Code 工作区文件 2 | github.code-workspace 3 | 4 | # 副本文件 5 | docker-compose - 副本.yml 6 | Dockerfile - 副本 7 | 8 | # 包含敏感信息或本地环境的配置文件 9 | docker-compose-pro.yml 10 | config/ 11 | 12 | # 日志文件 13 | logs.txt 14 | *.log 15 | 16 | # Python 缓存 17 | __pycache__/ 18 | *.pyc 19 | 20 | AI_DOCUMENTATION.md -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url' 2 | 3 | import { defineConfig } from 'vite' 4 | import vue from '@vitejs/plugin-vue' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | } 16 | }) -------------------------------------------------------------------------------- /src/proxy_cache.py: -------------------------------------------------------------------------------- 1 | # src/proxy_cache.py 2 | 3 | from cachetools import TTLCache, Cache 4 | 5 | # 创建一个全局的、线程安全的缓存实例 6 | # - maxsize=500: 最多缓存 500 个不同的 API 响应 7 | # - ttl=300: 每个缓存项的存活时间为 300 秒 (5 分钟) 8 | api_cache = TTLCache(maxsize=500, ttl=300) 9 | 10 | # 【【【 新增 】】】 11 | # 虚拟库项目列表缓存 (用于封面生成) 12 | # - maxsize=100: 最多缓存100个虚拟库的项目列表 13 | # 这个缓存不需要时间过期,因为它只在用户浏览时更新 14 | vlib_items_cache = Cache(maxsize=100) -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 |标题
评分
年份 / 国家 / ...
62 | if len(p_tags) > 1: 63 | # 年份信息通常在最后一个p标签 64 | info_line = p_tags[-1].text 65 | year_match = re.search(r'^\s*(\d{4})', info_line) 66 | if year_match: 67 | year = int(year_match.group(1)) 68 | 69 | title = title_text.strip() 70 | 71 | items_data.append({ 72 | "id": None, # id为None以触发兜底匹配 73 | "title": title, 74 | "year": year 75 | }) 76 | if year: 77 | logger.info(f"成功从描述中解析出年份: {year},标题: {title}") 78 | else: 79 | logger.warning(f"未能从描述中解析出年份,标题: {title}") 80 | 81 | logger.info(f"从 RSS 源中共解析出 {len(items_data)} 个条目。") 82 | return items_data 83 | 84 | def _get_tmdb_info(self, source_info): 85 | """通过豆瓣ID获取TMDB信息,返回一个包含 (tmdb_id, media_type) 元组的列表""" 86 | douban_id = source_info['id'] 87 | # 检查是否已存在 豆瓣ID -> TMDB ID 的映射 88 | existing_mapping = self.douban_db.fetchone( 89 | "SELECT tmdb_id, media_type FROM douban_tmdb_mapping WHERE douban_id = ?", 90 | (douban_id,) 91 | ) 92 | if existing_mapping: 93 | tmdb_id = existing_mapping['tmdb_id'] 94 | media_type = existing_mapping['media_type'] 95 | logger.info(f"豆瓣ID {douban_id}: 在缓存中找到已存在的TMDB映射 -> {tmdb_id} ({media_type})") 96 | return [(tmdb_id, media_type)] 97 | 98 | # 如果没有映射,则走完整流程 99 | logger.info(f"豆瓣ID {douban_id}: 未找到TMDB映射,开始完整处理流程。") 100 | imdb_id = self._get_imdb_id_from_douban_page(douban_id) 101 | if not imdb_id: 102 | logger.warning(f"豆瓣ID {douban_id}: 因未能找到 IMDb ID 而跳过。") 103 | return [] 104 | 105 | tmdb_id, media_type = self._get_tmdb_info_from_imdb(imdb_id) 106 | if not tmdb_id or not media_type: 107 | return [] 108 | 109 | # 存入映射关系 110 | self.douban_db.execute( 111 | "INSERT OR REPLACE INTO douban_tmdb_mapping (douban_id, tmdb_id, media_type, match_method) VALUES (?, ?, ?, ?)", 112 | (douban_id, tmdb_id, media_type, 'douban_id'), 113 | commit=True 114 | ) 115 | return [(tmdb_id, media_type)] 116 | 117 | def _get_imdb_id_from_douban_page(self, douban_id): 118 | """通过访问豆瓣页面抓取 IMDb ID 和标题""" 119 | cached = self.douban_db.fetchone("SELECT api_response, name FROM douban_api_cache WHERE douban_id = ?", (f"douban_imdb_{douban_id}",)) 120 | if cached and cached['api_response']: 121 | logger.info(f"豆瓣ID {douban_id}: 在缓存中找到 IMDb ID -> {cached['api_response']} (名称: {cached['name'] or 'N/A'})") 122 | return cached['api_response'] 123 | 124 | since_last_call = time.time() - self.last_api_call_time 125 | if since_last_call < DOUBAN_API_RATE_LIMIT: 126 | sleep_time = DOUBAN_API_RATE_LIMIT - since_last_call 127 | logger.info(f"豆瓣页面访问速率限制:休眠 {sleep_time:.2f} 秒。") 128 | time.sleep(sleep_time) 129 | self.last_api_call_time = time.time() 130 | 131 | url = f"https://movie.douban.com/subject/{douban_id}/" 132 | headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'} 133 | 134 | logger.info(f"豆瓣ID {douban_id}: 正在抓取页面 {url} 以寻找 IMDb ID。") 135 | try: 136 | response = requests.get(url, headers=headers) 137 | response.raise_for_status() 138 | except requests.RequestException as e: 139 | logger.error(f"抓取豆瓣页面失败 (ID: {douban_id}): {e}") 140 | return None 141 | 142 | soup = BeautifulSoup(response.text, 'html.parser') 143 | 144 | title_tag = soup.find('span', property='v:itemreviewed') 145 | name = title_tag.text.strip() if title_tag else None 146 | 147 | imdb_id_match = re.search(r'IMDb: (tt\d+)', response.text) 148 | imdb_id = imdb_id_match.group(1) if imdb_id_match else None 149 | 150 | if imdb_id: 151 | logger.info(f"豆瓣ID {douban_id}: 从页面中找到 IMDb ID {imdb_id} (名称: {name})。") 152 | else: 153 | logger.warning(f"豆瓣ID {douban_id}: 在页面中未能找到 IMDb ID。") 154 | 155 | self.douban_db.execute( 156 | "INSERT OR REPLACE INTO douban_api_cache (douban_id, api_response, name) VALUES (?, ?, ?)", 157 | (f"douban_imdb_{douban_id}", imdb_id or '', name), 158 | commit=True 159 | ) 160 | return imdb_id 161 | 162 | def _get_tmdb_info_from_imdb(self, imdb_id): 163 | """通过 IMDb ID 查询 TMDB""" 164 | logger.info(f"IMDb ID {imdb_id}: 正在查询 TMDB API...") 165 | tmdb_api_key = self.config.tmdb_api_key 166 | if not tmdb_api_key: 167 | raise ValueError("TMDB API Key not configured.") 168 | 169 | url = f"https://api.themoviedb.org/3/find/{imdb_id}?api_key={tmdb_api_key}&external_source=imdb_id" 170 | 171 | proxies = {"http": self.config.tmdb_proxy, "https": self.config.tmdb_proxy} if self.config.tmdb_proxy else None 172 | 173 | response = requests.get(url, proxies=proxies) 174 | response.raise_for_status() 175 | data = response.json() 176 | 177 | if data.get('movie_results'): 178 | tmdb_id = data['movie_results'][0]['id'] 179 | media_type = 'movie' 180 | elif data.get('tv_results'): 181 | tmdb_id = data['tv_results'][0]['id'] 182 | media_type = 'tv' 183 | else: 184 | logger.warning(f"IMDb ID {imdb_id}: 在 TMDB 上未找到结果。") 185 | return None, None 186 | 187 | logger.info(f"IMDb ID {imdb_id}: 在 TMDB 上找到 -> TMDB ID: {tmdb_id}, 类型: {media_type}") 188 | return str(tmdb_id), media_type 189 | -------------------------------------------------------------------------------- /src/proxy_server.py: -------------------------------------------------------------------------------- 1 | # src/proxy_server.py (最终修复版) 2 | 3 | import asyncio 4 | import logging 5 | from contextlib import asynccontextmanager 6 | import aiohttp 7 | # 【【【 修改这一行 】】】 8 | from fastapi import FastAPI, Request, Response, WebSocket, WebSocketDisconnect, HTTPException 9 | from fastapi.responses import StreamingResponse, JSONResponse 10 | from fastapi.staticfiles import StaticFiles 11 | from pathlib import Path 12 | from typing import Tuple, Dict 13 | 14 | # 【【【 同时修改这一行,从 proxy_cache 导入两个缓存实例 】】】 15 | from proxy_cache import api_cache, vlib_items_cache 16 | import config_manager 17 | from proxy_handlers import ( 18 | handler_system, 19 | handler_views, 20 | handler_items, 21 | handler_seasons, 22 | handler_episodes, 23 | handler_default, 24 | handler_latest, 25 | handler_images, 26 | handler_virtual_items 27 | ) 28 | 29 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s') 30 | logger = logging.getLogger(__name__) 31 | 32 | def get_cache_key(request: Request, full_path: str) -> str: 33 | if request.method != "GET": return None 34 | params = dict(request.query_params); params.pop("X-Emby-Token", None); params.pop("api_key", None) 35 | sorted_params = tuple(sorted(params.items())); user_id_from_path = "public" 36 | if "/Users/" in full_path: 37 | try: parts = full_path.split("/"); user_id_from_path = parts[parts.index("Users") + 1] 38 | except (ValueError, IndexError): pass 39 | user_id = params.get("UserId", user_id_from_path) 40 | return f"user:{user_id}:path:{full_path}:params:{sorted_params}" 41 | 42 | @asynccontextmanager 43 | async def lifespan(app: FastAPI): 44 | app.state.aiohttp_session = aiohttp.ClientSession(cookie_jar=aiohttp.DummyCookieJar()); logger.info("Global AIOHTTP ClientSession created.") 45 | yield 46 | await app.state.aiohttp_session.close(); logger.info("Global AIOHTTP ClientSession closed.") 47 | 48 | proxy_app = FastAPI(title="Emby Virtual Proxy - Core", lifespan=lifespan) 49 | 50 | covers_dir = Path("/app/config/images") 51 | if covers_dir.is_dir(): 52 | proxy_app.mount("/covers", StaticFiles(directory=str(covers_dir)), name="generated_covers") 53 | logger.info(f"Successfully mounted generated covers directory at /covers") 54 | else: 55 | logger.warning(f"Generated covers directory not found at {covers_dir}, skipping mount.") 56 | 57 | @proxy_app.get("/api/internal/get-cached-items/{library_id}") 58 | async def get_cached_items_for_admin(library_id: str): 59 | """ 60 | 一个内部API,专门用于给admin服务提供已缓存的虚拟库项目列表。 61 | """ 62 | cached_items = vlib_items_cache.get(library_id) 63 | if not cached_items: 64 | logger.warning(f"Admin请求缓存,但未找到库 {library_id} 的缓存。") 65 | raise HTTPException( 66 | status_code=404, 67 | detail="未找到该虚拟库的项目缓存。请先在Emby客户端中实际浏览一次该虚拟库,以生成缓存数据。" 68 | ) 69 | 70 | logger.info(f"Admin成功获取到库 {library_id} 的 {len(cached_items)} 条缓存项目。") 71 | return JSONResponse(content={"Items": cached_items}) 72 | # --- 【【【 核心修复:重写 WebSocket 代理 】】】 --- 73 | @proxy_app.websocket("/{full_path:path}") 74 | async def websocket_proxy(client_ws: WebSocket, full_path: str): 75 | await client_ws.accept() 76 | config = config_manager.load_config() 77 | target_url = config.emby_url.replace("http", "ws", 1).rstrip('/') + "/" + full_path 78 | 79 | session = proxy_app.state.aiohttp_session 80 | try: 81 | headers = {k: v for k, v in client_ws.headers.items() if k.lower() not in ['host', 'connection', 'upgrade', 'sec-websocket-key', 'sec-websocket-version']} 82 | 83 | async with session.ws_connect(target_url, params=client_ws.query_params, headers=headers) as server_ws: 84 | 85 | async def forward_client_to_server(): 86 | try: 87 | while True: 88 | # FastAPI WebSocket 使用 .receive_text() 或 .receive_bytes() 89 | data = await client_ws.receive_text() 90 | await server_ws.send_str(data) 91 | except WebSocketDisconnect: 92 | logger.debug("Client WebSocket disconnected.") 93 | except Exception as e: 94 | logger.debug(f"Error forwarding C->S: {e}") 95 | 96 | async def forward_server_to_client(): 97 | try: 98 | # aiohttp ClientWebSocketResponse 是一个异步迭代器 99 | async for msg in server_ws: 100 | if msg.type == aiohttp.WSMsgType.TEXT: 101 | await client_ws.send_text(msg.data) 102 | elif msg.type == aiohttp.WSMsgType.BINARY: 103 | await client_ws.send_bytes(msg.data) 104 | except Exception as e: 105 | logger.debug(f"Error forwarding S->C: {e}") 106 | 107 | # 并发运行两个转发任务 108 | await asyncio.gather(forward_client_to_server(), forward_server_to_client()) 109 | 110 | except Exception as e: 111 | logger.warning(f"WebSocket proxy error for path '{full_path}': {e}") 112 | finally: 113 | try: 114 | await client_ws.close() 115 | except Exception: 116 | pass 117 | # --- 【【【 修复结束 】】】 --- 118 | 119 | 120 | @proxy_app.api_route("/{full_path:path}", methods=["GET", "POST", "DELETE", "PUT"]) 121 | async def reverse_proxy(request: Request, full_path: str): 122 | config = config_manager.load_config() 123 | real_emby_url = config.emby_url.rstrip('/') 124 | 125 | cache_key = None 126 | if config.enable_cache: 127 | cache_key = get_cache_key(request, full_path) 128 | if cache_key and cache_key in api_cache: 129 | cached_response_data = api_cache.get(cache_key) 130 | if cached_response_data: 131 | content, status, headers = cached_response_data 132 | logger.info(f"✅ Cache HIT for key: {cache_key}") 133 | return Response(content=content, status_code=status, headers=headers) 134 | if cache_key: 135 | logger.info(f"❌ Cache MISS for key: {cache_key}") 136 | 137 | proxy_address = f"{request.url.scheme}://{request.url.netloc}" 138 | session = request.app.state.aiohttp_session 139 | response = None 140 | 141 | if not response: response = await handler_images.handle_virtual_library_image(request, full_path) 142 | if not response: response = await handler_virtual_items.handle_get_virtual_item_info(request, full_path, config) 143 | if not response: response = await handler_latest.handle_home_latest_items(request, full_path, request.method, real_emby_url, session, config) 144 | if not response: response = await handler_system.handle_system_and_playback_info(request, full_path, request.method, real_emby_url, proxy_address, session) 145 | if not response: response = await handler_episodes.handle_episodes_merge(request, full_path, session, real_emby_url) 146 | if not response: response = await handler_seasons.handle_seasons_merge(request, full_path, session, real_emby_url) 147 | if not response: response = await handler_items.handle_virtual_library_items(request, full_path, request.method, real_emby_url, session, config) 148 | if not response: response = await handler_views.handle_view_injection(request, full_path, request.method, real_emby_url, session, config) 149 | if not response: response = await handler_default.forward_request(request, full_path, request.method, real_emby_url, session) 150 | 151 | if config.enable_cache and cache_key and response and response.status_code == 200 and not isinstance(response, StreamingResponse): 152 | content_type = response.headers.get("Content-Type", "") 153 | if "application/json" in content_type: 154 | # For aiohttp responses, we need to read the body before caching 155 | if hasattr(response, 'body'): 156 | response_body = response.body 157 | else: # For regular FastAPI responses 158 | response_body = await response.body() 159 | 160 | response_to_cache: Tuple[bytes, int, Dict] = (response_body, response.status_code, dict(response.headers)) 161 | api_cache[cache_key] = response_to_cache 162 | logger.info(f"📝 Cache SET for key: {cache_key}") 163 | 164 | # Since body is already read, return a new Response object 165 | return Response(content=response_body, status_code=response.status_code, headers=response.headers) 166 | 167 | return response 168 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_latest.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_latest.py (最终修正版) 2 | 3 | import logging 4 | import json 5 | from fastapi import Request, Response 6 | from aiohttp import ClientSession 7 | from models import AppConfig 8 | from typing import List, Dict 9 | import asyncio 10 | from pathlib import Path 11 | 12 | from . import handler_merger 13 | # 【新增】导入后台生成处理器 14 | from . import handler_autogen 15 | from ._filter_translator import translate_rules 16 | from .handler_items import _apply_post_filter 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | # 【新增】定义封面存储路径 21 | COVERS_DIR = Path("/app/config/images") 22 | 23 | async def handle_home_latest_items( 24 | request: Request, 25 | full_path: str, 26 | method: str, 27 | real_emby_url: str, 28 | session: ClientSession, 29 | config: AppConfig 30 | ) -> Response | None: 31 | if "/Items/Latest" not in full_path or method != "GET": 32 | return None 33 | 34 | params = request.query_params 35 | parent_id = params.get("ParentId") 36 | if not parent_id: return None 37 | 38 | found_vlib = next((vlib for vlib in config.virtual_libraries if vlib.id == parent_id), None) 39 | if not found_vlib: 40 | return None 41 | 42 | # 终极修复:将 UserId 的提取逻辑提前,确保所有分支都能访问到它 43 | user_id = params.get("UserId") 44 | if not user_id: 45 | path_parts_for_user = full_path.split('/') 46 | if 'Users' in path_parts_for_user: 47 | try: 48 | user_id_index = path_parts_for_user.index('Users') + 1 49 | if user_id_index < len(path_parts_for_user): user_id = path_parts_for_user[user_id_index] 50 | except (ValueError, IndexError): pass 51 | if not user_id: return None 52 | 53 | # 如果是 RSS 库,则使用专门的逻辑处理 54 | if found_vlib.resource_type == 'rsshub': 55 | logger.info(f"HOME_LATEST_HANDLER: Intercepting request for latest items in RSS vlib '{found_vlib.name}'.") 56 | 57 | from .handler_rss import RssHandler 58 | rss_handler = RssHandler() 59 | limit = int(request.query_params.get("Limit", 20)) 60 | 61 | # 最终修复:完全同步 handler_items.py 中的健壮逻辑 62 | latest_items_from_db = rss_handler.rss_library_db.fetchall( 63 | "SELECT tmdb_id, media_type, emby_item_id FROM rss_library_items WHERE library_id = ? ORDER BY rowid DESC LIMIT ?", 64 | (found_vlib.id, limit) 65 | ) 66 | if not latest_items_from_db: 67 | return Response(content=json.dumps([]).encode('utf-8'), status_code=200, headers={"Content-Type": "application/json"}) 68 | 69 | existing_emby_ids = [str(item['emby_item_id']) for item in latest_items_from_db if item['emby_item_id']] 70 | missing_items_info = [{'tmdb_id': item['tmdb_id'], 'media_type': item['media_type']} for item in latest_items_from_db if not item['emby_item_id']] 71 | 72 | existing_items_data = [] 73 | server_id = None 74 | if existing_emby_ids: 75 | existing_items_data = await rss_handler._get_emby_items_by_ids_async( 76 | existing_emby_ids, 77 | request_params=request.query_params, 78 | user_id=user_id, 79 | session=session, 80 | real_emby_url=real_emby_url, 81 | request_headers=request.headers 82 | ) 83 | if existing_items_data: 84 | server_id = existing_items_data[0].get("ServerId") 85 | 86 | if not server_id: 87 | server_id = rss_handler.config.emby_server_id or "emby" 88 | 89 | missing_items_placeholders = [] 90 | for item_info in missing_items_info: 91 | item = rss_handler._get_item_from_tmdb(item_info['tmdb_id'], item_info['media_type'], server_id) 92 | if item: 93 | missing_items_placeholders.append(item) 94 | 95 | final_items = existing_items_data + missing_items_placeholders 96 | 97 | content = json.dumps(final_items).encode('utf-8') 98 | return Response(content=content, status_code=200, headers={"Content-Type": "application/json"}) 99 | 100 | logger.info(f"HOME_LATEST_HANDLER: Intercepting request for latest items in vlib '{found_vlib.name}'.") 101 | 102 | # --- 【【【 核心修正:在这里也添加封面自动生成触发器 】】】 --- 103 | image_file = COVERS_DIR / f"{found_vlib.id}.jpg" 104 | if not image_file.is_file(): 105 | logger.info(f"首页最新项目:发现虚拟库 '{found_vlib.name}' ({found_vlib.id}) 缺少封面,触发自动生成。") 106 | if found_vlib.id not in handler_autogen.GENERATION_IN_PROGRESS: 107 | # 从当前请求中提取必要信息 108 | user_id = params.get("UserId") 109 | api_key = params.get("X-Emby-Token") or config.emby_api_key 110 | 111 | if user_id and api_key: 112 | asyncio.create_task(handler_autogen.generate_poster_in_background(found_vlib.id, user_id, api_key)) 113 | else: 114 | logger.warning(f"无法为库 {found_vlib.id} 触发后台任务,因为缺少 UserId 或 ApiKey。") 115 | # --- 【【【 修正结束 】】】 --- 116 | 117 | new_params = {} 118 | safe_params_to_inherit = ["Fields", "IncludeItemTypes", "EnableImageTypes", "ImageTypeLimit", "X-Emby-Token", "EnableUserData", "Limit", "ParentId"] 119 | for key in safe_params_to_inherit: 120 | if key in params: new_params[key] = params[key] 121 | 122 | new_params["SortBy"] = "DateCreated" 123 | new_params["SortOrder"] = "Descending" 124 | new_params["Recursive"] = "true" 125 | new_params["IncludeItemTypes"] = "Movie,Series,Video" 126 | 127 | post_filter_rules = [] 128 | if found_vlib.advanced_filter_id: 129 | adv_filter = next((f for f in config.advanced_filters if f.id == found_vlib.advanced_filter_id), None) 130 | if adv_filter: 131 | emby_native_params, post_filter_rules = translate_rules(adv_filter.rules) 132 | new_params.update(emby_native_params) 133 | logger.info(f"HOME_LATEST_HANDLER: 应用了 {len(emby_native_params)} 条原生筛选规则。") 134 | 135 | is_tmdb_merge_enabled = found_vlib.merge_by_tmdb_id or config.force_merge_by_tmdb_id 136 | if post_filter_rules or is_tmdb_merge_enabled: 137 | fetch_limit = 200 138 | client_limit = int(params.get("Limit", 20)) 139 | try: fetch_limit = min(max(client_limit * 10, 50), 200) 140 | except (ValueError, TypeError): pass 141 | new_params["Limit"] = fetch_limit 142 | logger.info(f"HOME_LATEST_HANDLER: 后筛选或合并需要,已将获取限制提高到 {fetch_limit}。") 143 | 144 | required_fields = set(["ProviderIds"]) 145 | if post_filter_rules: 146 | for rule in post_filter_rules: required_fields.add(rule.field.split('.')[0]) 147 | if required_fields: 148 | current_fields = set(new_params.get("Fields", "").split(',')) 149 | current_fields.discard('') 150 | current_fields.update(required_fields) 151 | new_params["Fields"] = ",".join(sorted(list(current_fields))) 152 | 153 | resource_map = {"collection": "CollectionIds", "tag": "TagIds", "person": "PersonIds", "genre": "GenreIds", "studio": "StudioIds"} 154 | if found_vlib.resource_type in resource_map: 155 | new_params[resource_map[found_vlib.resource_type]] = found_vlib.resource_id 156 | elif found_vlib.resource_type == 'all': 157 | # For 'all' type, we don't add any specific resource filter, 158 | # which means it will fetch from all libraries. 159 | # We also remove ParentId to avoid conflicts. 160 | if "ParentId" in new_params: 161 | del new_params["ParentId"] 162 | 163 | # 采用白名单策略转发所有必要的请求头,确保认证信息不丢失 164 | headers_to_forward = { 165 | k: v for k, v in request.headers.items() 166 | if k.lower() in [ 167 | 'accept', 'accept-language', 'user-agent', 168 | 'x-emby-authorization', 'x-emby-client', 'x-emby-device-name', 169 | 'x-emby-device-id', 'x-emby-client-version', 'x-emby-language', 170 | 'x-emby-token' 171 | ] 172 | } 173 | 174 | target_url = f"{real_emby_url}/emby/Users/{user_id}/Items" 175 | logger.debug(f"HOME_LATEST_HANDLER: Forwarding to URL={target_url}, Params={new_params}") 176 | 177 | async with session.get(target_url, params=new_params, headers=headers_to_forward) as resp: 178 | if resp.status != 200 or "application/json" not in resp.headers.get("Content-Type", ""): 179 | content = await resp.read(); return Response(content=content, status_code=resp.status, headers={"Content-Type": resp.headers.get("Content-Type")}) 180 | 181 | data = await resp.json() 182 | items_list = data.get("Items", []) 183 | 184 | if post_filter_rules: 185 | items_list = _apply_post_filter(items_list, post_filter_rules) 186 | 187 | if is_tmdb_merge_enabled: 188 | items_list = await handler_merger.merge_items_by_tmdb(items_list) 189 | 190 | client_limit_str = params.get("Limit") 191 | if client_limit_str: 192 | try: 193 | final_limit = int(client_limit_str) 194 | items_list = items_list[:final_limit] 195 | except (ValueError, TypeError): pass 196 | 197 | # 关键修复:/Items/Latest 端点需要直接返回一个 JSON 数组,而不是一个包含 "Items" 键的对象。 198 | # 这与 Go 版本的实现保持一致。 199 | content = json.dumps(items_list).encode('utf-8') 200 | return Response(content=content, status_code=200, headers={"Content-Type": "application/json"}) 201 | 202 | return None 203 | -------------------------------------------------------------------------------- /frontend/src/stores/main.js: -------------------------------------------------------------------------------- 1 | // frontend/src/stores/main.js (最终修复版) 2 | 3 | import { defineStore } from 'pinia'; 4 | import { ElMessage } from 'element-plus'; 5 | import api from '../api'; 6 | 7 | export const useMainStore = defineStore('main', { 8 | state: () => ({ 9 | config: { 10 | emby_url: '', 11 | emby_api_key: '', 12 | hide: [], 13 | display_order: [], 14 | advanced_filters: [], 15 | library: [] // 确保 config 对象中有 library 数组 16 | }, 17 | originalConfigForComparison: null, 18 | // 【核心修复】: 删除独立的 virtualLibraries 状态,它应该始终是 config 的一部分 19 | // virtualLibraries: [], 20 | classifications: {}, 21 | saving: false, 22 | dataLoading: false, 23 | dataStatus: null, 24 | dialogVisible: false, 25 | isEditing: false, 26 | currentLibrary: {}, 27 | allLibrariesForSorting: [], 28 | layoutManagerVisible: false, 29 | coverGenerating: false, 30 | personNameCache: {}, 31 | }), 32 | 33 | getters: { 34 | // 【核心修复】: 直接从 config.library 读取数据 35 | virtualLibraries: (state) => state.config.library || [], 36 | 37 | sortedLibsInDisplayOrder: (state) => { 38 | if (!state.config.display_order || !state.allLibrariesForSorting.length) { 39 | return []; 40 | } 41 | const libMap = new Map(state.allLibrariesForSorting.map(lib => [lib.id, lib])); 42 | return state.config.display_order 43 | .map(id => libMap.get(id)) 44 | .filter(Boolean); 45 | }, 46 | unsortedLibs: (state) => { 47 | if (!state.allLibrariesForSorting.length) return []; 48 | const sortedIds = new Set(state.config.display_order || []); 49 | return state.allLibrariesForSorting.filter(lib => !sortedIds.has(lib.id)); 50 | }, 51 | availableResources: (state) => { 52 | const type = state.currentLibrary?.resource_type; 53 | if (!type || !state.classifications) return []; 54 | const pluralTypeMap = { 55 | collection: 'collections', 56 | tag: 'tags', 57 | genre: 'genres', 58 | studio: 'studios', 59 | person: 'persons' 60 | }; 61 | return state.classifications[pluralTypeMap[type]] || []; 62 | }, 63 | }, 64 | 65 | actions: { 66 | async fetchAllInitialData() { 67 | this.dataLoading = true; 68 | this.dataStatus = null; 69 | try { 70 | const [configRes, classificationsRes, allLibsRes] = await Promise.all([ 71 | api.getConfig(), 72 | api.getClassifications(), 73 | api.getAllLibraries(), 74 | ]); 75 | 76 | // 直接将获取到的配置赋值给 state.config 77 | this.config = configRes.data; 78 | if (!this.config.advanced_filters) this.config.advanced_filters = []; 79 | if (!this.config.library) this.config.library = []; // 确保 library 数组存在 80 | 81 | this.originalConfigForComparison = JSON.parse(JSON.stringify(configRes.data)); 82 | this.classifications = classificationsRes.data; 83 | this.allLibrariesForSorting = allLibsRes.data; 84 | 85 | if (!this.config.display_order || this.config.display_order.length === 0) { 86 | if (this.allLibrariesForSorting.length > 0) { 87 | this.config.display_order = this.allLibrariesForSorting.map(l => l.id); 88 | } 89 | } 90 | this.resolveVisiblePersonNames(); 91 | this.dataStatus = { type: 'success', text: 'Emby数据已加载' }; 92 | } catch (error) { 93 | this._handleApiError(error, '加载初始数据失败'); 94 | this.dataStatus = { type: 'error', text: 'Emby数据加载失败' }; 95 | } finally { 96 | this.dataLoading = false; 97 | } 98 | }, 99 | 100 | async _reloadConfigAndAllLibs() { 101 | try { 102 | const [configRes, allLibsRes] = await Promise.all([ 103 | api.getConfig(), 104 | api.getAllLibraries() 105 | ]); 106 | this.config = configRes.data; 107 | if (!this.config.advanced_filters) this.config.advanced_filters = []; 108 | if (!this.config.library) this.config.library = []; 109 | 110 | this.originalConfigForComparison = JSON.parse(JSON.stringify(configRes.data)); 111 | this.allLibrariesForSorting = allLibsRes.data; 112 | this.resolveVisiblePersonNames(); 113 | } catch (error) { 114 | this._handleApiError(error, "刷新配置列表失败"); 115 | } 116 | }, 117 | 118 | // --- 所有其他 actions 保持和上一版一样 --- 119 | // 为了确保万无一失,我将它们全部粘贴在这里 120 | 121 | async saveAdvancedFilters(filters) { 122 | this.saving = true; 123 | try { 124 | await api.saveAdvancedFilters(filters); 125 | this.config.advanced_filters = filters; 126 | } catch (error) { 127 | this._handleApiError(error, "保存高级筛选器失败"); 128 | throw error; 129 | } finally { 130 | this.saving = false; 131 | } 132 | }, 133 | 134 | async generateLibraryCover(libraryId, titleZh, titleEn, styleName, tempImagePaths) { 135 | this.coverGenerating = true; 136 | try { 137 | const response = await api.generateCover(libraryId, titleZh, titleEn, styleName, tempImagePaths); 138 | if (response.data && response.data.success) { 139 | ElMessage.success("封面已在后台生成!请点击保存。"); 140 | const newImageTag = response.data.image_tag; 141 | if (this.currentLibrary && this.currentLibrary.id === libraryId) { 142 | this.currentLibrary.image_tag = newImageTag; 143 | } 144 | return true; 145 | } 146 | return false; 147 | } catch (error) { 148 | this._handleApiError(error, "封面生成失败"); 149 | return false; 150 | } finally { 151 | this.coverGenerating = false; 152 | } 153 | }, 154 | 155 | async saveLibrary() { 156 | const libraryToSave = this.currentLibrary; 157 | 158 | // 采用更清晰的分步验证逻辑来彻底修复问题 159 | if (!libraryToSave.name) { 160 | ElMessage.warning('请填写所有必填字段'); 161 | return; 162 | } 163 | 164 | if (libraryToSave.resource_type === 'rsshub') { 165 | if (!libraryToSave.rsshub_url || !libraryToSave.rss_type) { 166 | ElMessage.warning('请填写所有必填字段'); 167 | return; 168 | } 169 | } else if (libraryToSave.resource_type !== 'all') { 170 | if (!libraryToSave.resource_id) { 171 | ElMessage.warning('请填写所有必填字段'); 172 | return; 173 | } 174 | } 175 | 176 | this.saving = true; 177 | const action = this.isEditing ? api.updateLibrary(libraryToSave.id, libraryToSave) : api.addLibrary(libraryToSave); 178 | const successMsg = this.isEditing ? '虚拟库已更新' : '虚拟库已添加'; 179 | try { 180 | await action; 181 | ElMessage.success(successMsg); 182 | this.dialogVisible = false; 183 | await this._reloadConfigAndAllLibs(); 184 | } catch (error) { 185 | this._handleApiError(error, '保存虚拟库失败'); 186 | } finally { 187 | this.saving = false; 188 | } 189 | }, 190 | 191 | resolveVisiblePersonNames() { 192 | if (!this.config.library) return; 193 | const personLibs = this.config.library.filter(lib => lib.resource_type === 'person'); 194 | for (const lib of personLibs) { 195 | if (lib.resource_id) { this.resolvePersonName(lib.resource_id); } 196 | } 197 | }, 198 | 199 | async fetchAllEmbyData() { 200 | this.dataLoading = true; 201 | this.dataStatus = { type: 'info', text: '正在刷新...' }; 202 | try { 203 | const [classificationsRes, allLibsRes] = await Promise.all([ 204 | api.getClassifications(), 205 | api.getAllLibraries(), 206 | ]); 207 | this.classifications = classificationsRes.data; 208 | this.allLibrariesForSorting = allLibsRes.data; 209 | this.dataStatus = { type: 'success', text: 'Emby数据已刷新' }; 210 | ElMessage.success("分类和媒体库数据已从Emby刷新!"); 211 | } catch (error) { 212 | this._handleApiError(error, '刷新Emby数据失败'); 213 | this.dataStatus = { type: 'error', text: '刷新失败' }; 214 | } finally { 215 | this.dataLoading = false; 216 | } 217 | }, 218 | 219 | async saveDisplayOrder(orderedIds) { 220 | this.saving = true; 221 | try { 222 | // 直接修改 state.config.display_order 223 | this.config.display_order = orderedIds; 224 | // 然后将整个 config 对象保存 225 | await api.saveDisplayOrder(this.config.display_order); 226 | ElMessage.success("主页布局已保存!"); 227 | await this._reloadConfigAndAllLibs(); 228 | } catch (error) { 229 | this._handleApiError(error, "保存布局失败"); 230 | } finally { 231 | this.saving = false; 232 | } 233 | }, 234 | openLayoutManager() { 235 | this.layoutManagerVisible = true; 236 | }, 237 | async deleteLibrary(id) { 238 | try { 239 | await api.deleteLibrary(id); 240 | ElMessage.success('虚拟库已删除'); 241 | await this._reloadConfigAndAllLibs(); 242 | } catch (error) { 243 | this._handleApiError(error, '删除虚拟库失败'); 244 | } 245 | }, 246 | 247 | async refreshRssLibrary(id) { 248 | try { 249 | await api.refreshRssLibrary(id); 250 | ElMessage.success('RSS 库刷新请求已发送,将在后台处理。'); 251 | } catch (error) { 252 | this._handleApiError(error, '刷新 RSS 库失败'); 253 | } 254 | }, 255 | async restartProxyServer() { 256 | this.saving = true; 257 | try { 258 | await api.restartProxy(); 259 | ElMessage.success("代理服务重启命令已发送!它将在几秒后恢复服务。"); 260 | } catch (error) { 261 | this._handleApiError(error, "重启代理服务失败"); 262 | } finally { 263 | this.saving = false; 264 | } 265 | }, 266 | 267 | async clearAllCovers() { 268 | this.saving = true; 269 | try { 270 | await api.clearCovers(); 271 | ElMessage.success("所有本地封面已清空!"); 272 | // 刷新配置以更新UI(清除image_tag) 273 | await this._reloadConfigAndAllLibs(); 274 | } catch (error) { 275 | this._handleApiError(error, "清空封面失败"); 276 | } finally { 277 | this.saving = false; 278 | } 279 | }, 280 | 281 | async saveConfig() { 282 | this.saving = true; 283 | try { 284 | await api.updateConfig(this.config); 285 | ElMessage.success('系统设置已保存'); 286 | this.originalConfigForComparison = JSON.parse(JSON.stringify(this.config)); 287 | } catch (error) { 288 | this._handleApiError(error, '保存设置失败'); 289 | } finally { 290 | this.saving = false; 291 | } 292 | }, 293 | openAddDialog() { 294 | this.isEditing = false; 295 | this.currentLibrary = { 296 | name: '', 297 | resource_type: 'collection', 298 | resource_id: '', 299 | merge_by_tmdb_id: false, 300 | image_tag: null, 301 | fallback_tmdb_id: null, 302 | fallback_tmdb_type: null 303 | }; 304 | this.dialogVisible = true; 305 | }, 306 | openEditDialog(library) { 307 | this.isEditing = true; 308 | this.currentLibrary = JSON.parse(JSON.stringify(library)); 309 | if (this.currentLibrary.merge_by_tmdb_id === undefined) { 310 | this.currentLibrary.merge_by_tmdb_id = false; 311 | } 312 | if (library.resource_type === 'person' && library.resource_id) { 313 | this.resolvePersonName(library.resource_id); 314 | } 315 | this.dialogVisible = true; 316 | }, 317 | _handleApiError(error, messagePrefix) { 318 | const detail = error.response?.data?.detail; 319 | ElMessage.error(`${messagePrefix}: ${detail || '请检查网络或联系管理员'}`); 320 | }, 321 | async resolvePersonName(personId) { 322 | if (!personId || this.personNameCache[personId]) { return; } 323 | this.personNameCache[personId] = '...'; 324 | try { 325 | const response = await api.resolveItem(personId); 326 | this.personNameCache[personId] = response.data.name; 327 | } catch (error) { 328 | console.error(`解析人员ID ${personId} 失败:`, error); 329 | this.personNameCache[personId] = '未知'; 330 | } 331 | }, 332 | }, 333 | }); 334 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Emby Virtual Proxy 2 | 3 | 本项目是一个功能强大的 **Emby 代理服务器**。它作为中间层部署在您的 Emby 客户端(如浏览器、手机APP)和真实的 Emby 服务器之间。通过智能地拦截和修改客户端与服务器之间的通信数据(API请求),本项目实现了许多 Emby 原生不支持的高级功能,极大地增强了您的媒体库管理和浏览体验。 4 | 5 | --- 6 | 7 |