├── 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 | Emby Proxy Config 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | app: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | container_name: emby-proxy 8 | ports: 9 | - "8011:8001" 10 | - "8999:8999" 11 | volumes: 12 | - ./config:/app/config 13 | - /var/run/docker.sock:/var/run/docker.sock 14 | environment: 15 | - PROXY_CONTAINER_NAME=emby-proxy 16 | - PROXY_CORE_URL=http://localhost:8999 17 | restart: unless-stopped 18 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import './assets/main.css' 2 | 3 | import { createApp } from 'vue' 4 | import { createPinia } from 'pinia' 5 | import App from './App.vue' 6 | 7 | import ElementPlus from 'element-plus' 8 | import 'element-plus/dist/index.css' 9 | // 【【【 在这里引入深色主题的核心CSS变量文件 】】】 10 | import 'element-plus/theme-chalk/dark/css-vars.css' 11 | 12 | 13 | const app = createApp(App) 14 | const pinia = createPinia() 15 | 16 | app.use(pinia) 17 | app.use(ElementPlus) 18 | app.mount('#app') -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@element-plus/icons-vue": "^2.3.1", 12 | "axios": "^1.6.2", 13 | "element-plus": "^2.4.4", 14 | "pinia": "^2.1.7", 15 | "sortablejs": "^1.15.2", 16 | "uuid": "^9.0.1", 17 | "vue": "^3.3.11", 18 | "vuedraggable": "^4.1.0" 19 | }, 20 | "devDependencies": { 21 | "@vitejs/plugin-vue": "^4.5.2", 22 | "vite": "^5.0.10" 23 | } 24 | } -------------------------------------------------------------------------------- /supervisord.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | logfile=/dev/null 4 | logfile_maxbytes=0 5 | pidfile=/var/run/supervisord.pid 6 | 7 | [program:proxy] 8 | command=python src/main.py proxy 9 | directory=/app 10 | autostart=true 11 | autorestart=true 12 | stdout_logfile=/dev/stdout 13 | stdout_logfile_maxbytes=0 14 | stderr_logfile=/dev/stderr 15 | stderr_logfile_maxbytes=0 16 | 17 | [program:admin] 18 | command=python src/main.py admin 19 | directory=/app 20 | autostart=true 21 | autorestart=true 22 | stdout_logfile=/dev/stdout 23 | stdout_logfile_maxbytes=0 24 | stderr_logfile=/dev/stderr 25 | stderr_logfile_maxbytes=0 26 | -------------------------------------------------------------------------------- /src/main.py: -------------------------------------------------------------------------------- 1 | # src/main.py 2 | import uvicorn 3 | import sys 4 | 5 | def start_admin(): 6 | """启动管理服务器""" 7 | print("--- Starting Admin Server ---") 8 | print("Access the Web UI at http://127.0.0.1:8011 (or your host IP)") 9 | print("API documentation at http://127.0.0.1:8011/docs") 10 | uvicorn.run("admin_server:admin_app", host="0.0.0.0", port=8001, reload=True) 11 | 12 | def start_proxy(): 13 | """启动代理服务器""" 14 | print("--- Starting Proxy Server ---") 15 | print("Proxy is listening on http://0.0.0.0:8999") 16 | uvicorn.run("proxy_server:proxy_app", host="0.0.0.0", port=8999, reload=True) 17 | 18 | 19 | if __name__ == "__main__": 20 | if len(sys.argv) > 1: 21 | if sys.argv[1] == "admin": 22 | start_admin() 23 | elif sys.argv[1] == "proxy": 24 | start_proxy() 25 | else: 26 | print(f"Unknown command: {sys.argv[1]}") 27 | print("Available commands: admin, proxy") 28 | else: 29 | print("Please specify a service to start.") 30 | print("Available commands: admin, proxy") -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # --- STAGE 1: Build Frontend (这一阶段不变) --- 2 | FROM node:18-alpine AS frontend-builder 3 | WORKDIR /app/frontend 4 | COPY frontend/package.json . 5 | RUN npm install 6 | COPY frontend/ . 7 | RUN npm run build 8 | RUN if [ ! -d "dist" ] || [ -z "$(ls -A dist)" ]; then echo "Frontend build failed: dist directory is empty or does not exist." && exit 1; fi 9 | 10 | 11 | # --- STAGE 2: Build Final Image (这一阶段结构调整) --- 12 | FROM python:3.11-slim 13 | 14 | # 将整个应用放在 /app 目录下 15 | WORKDIR /app 16 | 17 | # 关键改动:将 Python 源码复制到 /app/src 子目录 18 | COPY src/ /app/src/ 19 | 20 | # 关键改动:将 PYTHONPATH 设置为源码目录 21 | ENV PYTHONPATH=/app/src 22 | 23 | # 将依赖文件复制到工作目录根部并安装 24 | COPY src/requirements.txt . 25 | RUN apt-get update && apt-get install -y supervisor && \ 26 | pip install --no-cache-dir -r requirements.txt 27 | 28 | # 关键改动:将编译好的前端文件复制到 /app/static,与 src 目录同级 29 | COPY --from=frontend-builder /app/frontend/dist /app/static 30 | 31 | # 暴露端口 32 | EXPOSE 8001 33 | EXPOSE 8999 34 | 35 | # 新增:复制 supervisord 配置文件 36 | COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf 37 | 38 | # 修改:使用 supervisord 启动服务 39 | CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"] 40 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_merger.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_merger.py (无缓存的最终版) 2 | 3 | import logging 4 | from typing import List, Dict 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | async def merge_items_by_tmdb(items: List[Dict]) -> List[Dict]: 9 | """ 10 | 根据 TMDB ID 合并项目列表。 11 | 它会保留遇到的第一个具有特定 TMDB ID 的项目作为代表。 12 | 13 | Args: 14 | items: 从 Emby API 返回的原始项目列表。 15 | 16 | Returns: 17 | 合并后的新项目列表。 18 | """ 19 | if not items: 20 | return [] 21 | 22 | logger.info(f"开始执行TMDB ID合并,原始项目数量: {len(items)}") 23 | 24 | tmdb_map: Dict[str, Dict] = {} 25 | final_items: List[Dict] = [] 26 | 27 | for item in items: 28 | if not isinstance(item, dict): 29 | final_items.append(item) 30 | continue 31 | 32 | provider_ids = item.get("ProviderIds", {}) 33 | item_type = item.get("Type") 34 | 35 | if item_type not in ("Movie", "Series"): 36 | final_items.append(item) 37 | continue 38 | 39 | tmdb_id = provider_ids.get("Tmdb") 40 | 41 | if tmdb_id: 42 | if tmdb_id not in tmdb_map: 43 | tmdb_map[tmdb_id] = item 44 | final_items.append(item) 45 | else: 46 | logger.debug(f"合并项目 '{item.get('Name')}' (ID: {item.get('Id')}),因为它与已有项目共享 TMDB ID: {tmdb_id}") 47 | else: 48 | final_items.append(item) 49 | 50 | merged_count = len(items) - len(final_items) 51 | if merged_count > 0: 52 | logger.info(f"TMDB ID 合并完成。{merged_count} 个项目被合并。最终项目数量: {len(final_items)}") 53 | 54 | return final_items -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | // frontend/src/api/index.js (Corrected and Cleaned) 2 | 3 | import axios from 'axios'; 4 | 5 | const apiClient = axios.create({ 6 | baseURL: '/api', 7 | }); 8 | 9 | export default { 10 | // System 11 | getConfig: () => apiClient.get('/config'), 12 | updateConfig: (config) => apiClient.post('/config', config), 13 | restartProxy: () => apiClient.post('/proxy/restart'), 14 | 15 | // Libraries 16 | addLibrary: (library) => apiClient.post('/libraries', library), 17 | updateLibrary: (id, library) => apiClient.put(`/libraries/${id}`, library), 18 | deleteLibrary: (id) => apiClient.delete(`/libraries/${id}`), 19 | refreshRssLibrary: (id) => apiClient.post(`/libraries/${id}/refresh`), 20 | 21 | // Display Management 22 | getAllLibraries: () => apiClient.get('/all-libraries'), 23 | saveDisplayOrder: (orderedIds) => apiClient.post('/display-order', orderedIds), 24 | 25 | // Emby Helpers 26 | getClassifications: () => apiClient.get('/emby/classifications'), 27 | searchPersons: (query, page = 1) => apiClient.get('/emby/persons/search', { params: { query, page } }), 28 | resolveItem: (itemId) => apiClient.get(`/emby/resolve-item/${itemId}`), 29 | 30 | // Advanced Filters 31 | getAdvancedFilters: () => apiClient.get('/advanced-filters'), 32 | saveAdvancedFilters: (filters) => apiClient.post('/advanced-filters', filters), 33 | 34 | // 新增: Cover Generator 35 | generateCover: (libraryId, titleZh, titleEn, styleName, tempImagePaths) => apiClient.post('/generate-cover', { 36 | library_id: libraryId, 37 | title_zh: titleZh, 38 | title_en: titleEn, 39 | style_name: styleName, 40 | temp_image_paths: tempImagePaths 41 | }), 42 | clearCovers: () => apiClient.post('/covers/clear'), 43 | }; 44 | -------------------------------------------------------------------------------- /src/config_manager.py: -------------------------------------------------------------------------------- 1 | # src/config_manager.py (最终导入修正版) 2 | 3 | import json 4 | from pathlib import Path 5 | from models import AppConfig # <--- 修正这里 6 | 7 | # 定义配置文件的路径 8 | CONFIG_DIR = Path(__file__).parent.parent / "config" 9 | CONFIG_FILE_PATH = CONFIG_DIR / "config.json" 10 | 11 | def load_config() -> AppConfig: 12 | """ 13 | 加载配置文件。如果目录或文件不存在,则使用默认值自动创建。 14 | """ 15 | try: 16 | CONFIG_DIR.mkdir(exist_ok=True) 17 | 18 | if not CONFIG_FILE_PATH.is_file(): 19 | print("Config file not found. Creating a new one with default values.") 20 | default_config = AppConfig() 21 | save_config(default_config) 22 | return default_config 23 | 24 | with open(CONFIG_FILE_PATH, 'r', encoding='utf-8') as f: 25 | data = json.load(f) 26 | # --- 核心修复:确保新字段存在以兼容旧配置文件 --- 27 | if 'advanced_filters' not in data: 28 | data['advanced_filters'] = [] 29 | if 'show_missing_episodes' not in data: 30 | data['show_missing_episodes'] = False 31 | if 'tmdb_api_key' not in data: 32 | data['tmdb_api_key'] = "" 33 | if 'tmdb_proxy' not in data: 34 | data['tmdb_proxy'] = "" 35 | return AppConfig.model_validate(data) 36 | 37 | except (json.JSONDecodeError, Exception) as e: 38 | print(f"Error loading or parsing config file: {e}. Returning a temporary default config.") 39 | return AppConfig() 40 | 41 | def save_config(config: AppConfig): 42 | """ 43 | 将配置对象安全地保存到文件。 44 | """ 45 | try: 46 | CONFIG_DIR.mkdir(exist_ok=True) 47 | 48 | with open(CONFIG_FILE_PATH, 'w', encoding='utf-8') as f: 49 | f.write(config.model_dump_json(by_alias=True, indent=4)) 50 | print(f"Configuration successfully saved to {CONFIG_FILE_PATH}") 51 | except Exception as e: 52 | print(f"Error saving config file: {e}") 53 | -------------------------------------------------------------------------------- /frontend/favicon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/minimal_proxy.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') 5 | 6 | # --- 在这里硬编码您的 Emby 服务器地址 --- 7 | EMBY_HOST = "192.168.50.12" 8 | EMBY_PORT = 8096 9 | # --- 代理监听的端口 --- 10 | PROXY_PORT = 8999 11 | 12 | async def pipe(reader, writer, direction): 13 | try: 14 | while not reader.at_eof(): 15 | data = await reader.read(4096) 16 | if not data: 17 | break 18 | writer.write(data) 19 | await writer.drain() 20 | # logging.info(f"Forwarded {len(data)} bytes in direction {direction}") 21 | except (ConnectionResetError, BrokenPipeError, asyncio.CancelledError): 22 | pass # 连接关闭是正常现象 23 | finally: 24 | if not writer.is_closing(): 25 | writer.close() 26 | await writer.wait_closed() 27 | 28 | async def handle_client(client_reader, client_writer): 29 | client_addr = client_writer.get_extra_info('peername') 30 | logging.info(f"Accepted connection from {client_addr}") 31 | 32 | try: 33 | server_reader, server_writer = await asyncio.open_connection(EMBY_HOST, EMBY_PORT) 34 | logging.info(f"Connected to upstream Emby server at {EMBY_HOST}:{EMBY_PORT}") 35 | except Exception as e: 36 | logging.error(f"Failed to connect to upstream server: {e}") 37 | client_writer.close() 38 | await client_writer.wait_closed() 39 | return 40 | 41 | # 并发处理双向数据流 42 | await asyncio.gather( 43 | pipe(client_reader, server_writer, "C->S"), 44 | pipe(server_reader, client_writer, "S->C") 45 | ) 46 | 47 | logging.info(f"Connection from {client_addr} closed.") 48 | 49 | async def main(): 50 | server = await asyncio.start_server(handle_client, '0.0.0.0', PROXY_PORT) 51 | addr = server.sockets[0].getsockname() 52 | logging.info(f'Serving on {addr}') 53 | 54 | async with server: 55 | await server.serve_forever() 56 | 57 | if __name__ == '__main__': 58 | try: 59 | asyncio.run(main()) 60 | except KeyboardInterrupt: 61 | logging.info("Shutting down.") -------------------------------------------------------------------------------- /src/proxy_handlers/handler_virtual_items.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_virtual_items.py (新文件) 2 | import logging 3 | import json 4 | from fastapi import Request, Response 5 | from models import AppConfig 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | async def handle_get_virtual_item_info(request: Request, full_path: str, config: AppConfig) -> Response | None: 10 | """ 11 | 拦截对虚拟库本身详情的请求 (e.g., /Users/{uid}/Items/{vlib_id}) 12 | 并返回一个伪造的、包含正确 ImageTag 的响应。 13 | """ 14 | # 检查路径格式是否为 /Users/{...}/Items/{...} 15 | path_parts = full_path.split('/') 16 | if len(path_parts) != 4 or path_parts[0] != "Users" or path_parts[2] != "Items": 17 | return None 18 | 19 | vlib_id_from_path = path_parts[3] 20 | 21 | # 在配置中查找这个ID对应的虚拟库 22 | found_vlib = next((vlib for vlib in config.virtual_libraries if vlib.id == vlib_id_from_path), None) 23 | 24 | # 如果没找到,或者这个虚拟库没有设置 image_tag,则不处理 25 | if not found_vlib or not found_vlib.image_tag: 26 | return None 27 | 28 | logger.info(f"✅ VLIB_ITEM_INFO: Intercepting request for virtual library '{found_vlib.name}' info.") 29 | 30 | # 获取 ServerId 以便伪造响应 (从任意一个真实库中获取) 31 | # 注意: 这需要 config.display_order 至少包含一个真实库ID,如果全是虚拟库可能会出问题 32 | # 更好的方法是从Emby动态获取,但暂时简化处理 33 | server_id = "unknown_server_id" # 提供一个默认值 34 | 35 | # 伪造一个看起来像 CollectionFolder 的 JSON 响应 36 | fake_item_data = { 37 | "Name": found_vlib.name, 38 | "Id": found_vlib.id, 39 | "ServerId": server_id, # 这里需要一个真实的ServerId 40 | "Type": "CollectionFolder", 41 | "CollectionType": "folder", # 标记为通用文件夹类型 42 | "IsFolder": True, 43 | "ImageTags": { 44 | "Primary": found_vlib.image_tag 45 | }, 46 | "HasPrimaryImage": True, 47 | # 添加一些客户端可能需要的其他默认字段 48 | "UserData": { 49 | "IsFavorite": False, 50 | "Likes": None, 51 | "LastPlayedDate": None, 52 | "Played": False, 53 | "PlaybackPositionTicks": 0, 54 | "PlayCount": 0, 55 | "PlayedPercentage": None 56 | } 57 | } 58 | 59 | # 返回伪造的 JSON 响应 60 | return Response(content=json.dumps(fake_item_data), status_code=200, media_type="application/json") -------------------------------------------------------------------------------- /src/proxy_handlers/handler_images.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_images.py (最终修复版) 2 | 3 | import logging 4 | import re 5 | from pathlib import Path 6 | from fastapi import Request, Response 7 | from fastapi.responses import FileResponse 8 | import asyncio 9 | 10 | import config_manager 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # 【修复 1】修正正则表达式,使其同时匹配 UUID, "tmdb-" (横杠), 和 "tmdb_" (下划线) 15 | IMAGE_PATH_REGEX = re.compile(r"/Items/([a-f0-9\-]{36}|tmdb[-_]\d+)/Images/(\w+)") 16 | 17 | COVERS_DIR = Path("/app/config/images") 18 | PLACEHOLDER_GENERATING_PATH = Path("/app/src/assets/images_placeholder/generating.jpg") # 虚拟库 (UUID) 19 | PLACEHOLDER_RSSHUB_PATH = Path("/app/src/assets/images_placeholder/rsshubpost.jpg") # RSSHub (tmdb-) 20 | # 【重要】请确保这个路径下存在一个为缺失剧集准备的占位图文件 21 | PLACEHOLDER_EPISODE_PATH = Path("/app/src/assets/images_placeholder/placeholder.jpg") # 缺失剧集 (tmdb_) 22 | 23 | 24 | async def handle_virtual_library_image(request: Request, full_path: str) -> Response | None: 25 | match = IMAGE_PATH_REGEX.search(f"/{full_path}") 26 | if not match: 27 | return None 28 | 29 | item_id = match.group(1) 30 | image_type = match.group(2) 31 | 32 | if image_type != "Primary": 33 | return None 34 | 35 | # 检查实际的封面文件是否存在,如果存在则直接返回 36 | image_file = COVERS_DIR / f"{item_id}.jpg" 37 | if image_file.is_file(): 38 | return FileResponse(str(image_file), media_type="image/jpeg") 39 | 40 | # --- 【修复 2】重构占位图返回逻辑 --- 41 | # 如果封面文件不存在,则根据 item_id 的格式返回对应的占位图 42 | # 这种方法比检查 URL 参数更可靠 43 | 44 | placeholder_to_serve = None 45 | 46 | if item_id.startswith("tmdb_"): # 缺失剧集的 ID 以 "tmdb_" 开头 47 | logger.debug(f"IMAGE_HANDLER: Serving MISSING EPISODE placeholder for item '{item_id}'.") 48 | placeholder_to_serve = PLACEHOLDER_EPISODE_PATH 49 | 50 | elif item_id.startswith("tmdb-"): # RSSHub 项目的 ID 以 "tmdb-" 开头 51 | logger.debug(f"IMAGE_HANDLER: Serving RSSHUB placeholder for item '{item_id}'.") 52 | placeholder_to_serve = PLACEHOLDER_RSSHUB_PATH 53 | 54 | else: # 其他情况(标准的 UUID)被认为是常规虚拟库 55 | logger.debug(f"IMAGE_HANDLER: Serving 'GENERATING' placeholder for virtual library '{item_id}'.") 56 | placeholder_to_serve = PLACEHOLDER_GENERATING_PATH 57 | 58 | if placeholder_to_serve and placeholder_to_serve.is_file(): 59 | return FileResponse(str(placeholder_to_serve), media_type="image/jpeg") 60 | 61 | # 如果连占位图文件本身都不存在,则返回 404 62 | logger.error(f"IMAGE_HANDLER: Placeholder file not found for item '{item_id}' at path: {placeholder_to_serve}") 63 | return Response(status_code=404) -------------------------------------------------------------------------------- /src/proxy_handlers/handler_default.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_default.py (完整流式优化版) 2 | import logging 3 | from fastapi import Request 4 | from fastapi.responses import StreamingResponse, Response 5 | from aiohttp import ClientSession, ClientError 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | async def forward_request( 10 | request: Request, 11 | full_path: str, 12 | method: str, 13 | real_emby_url: str, 14 | session: ClientSession, 15 | ) -> Response: 16 | """ 17 | 默认的请求转发器,使用流式传输将请求高效地转发到真实的 Emby 服务器。 18 | 这对于视频播放、文件下载等大文件传输至关重要。 19 | """ 20 | target_url = f"{real_emby_url}/{full_path}" 21 | 22 | # 准备要转发的头部。 23 | # 必须移除 'host' 头,否则 aiohttp 会报错,并且目标服务器可能会因此行为异常。 24 | # aiohttp 会根据 target_url 自动生成正确的 Host 头。 25 | headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'} 26 | 27 | try: 28 | # 使用 aiohttp 发起流式请求。 29 | # 'data' 参数直接使用 request.stream(),它是一个异步生成器,会逐块读取客户端上传的数据。 30 | # 这样,即使客户端上传一个很大的文件,代理服务器的内存也不会暴涨。 31 | resp = await session.request( 32 | method=method, 33 | url=target_url, 34 | params=request.query_params, 35 | headers=headers, 36 | data=request.stream(), 37 | allow_redirects=False # 让客户端自己处理重定向,这是反向代理的标准行为 38 | ) 39 | 40 | # 定义一个异步生成器,用于逐块读取来自 Emby 服务器的响应体并将其 yield 出去。 41 | async def stream_generator(): 42 | try: 43 | # resp.content 是一个 aiohttp.StreamReader 对象。 44 | # .iter_chunked(8192) 会以 8KB 的块大小读取数据。 45 | # 这是一个合理的缓冲区大小,可以在网络效率和内存占用之间取得平衡。 46 | async for chunk in resp.content.iter_chunked(8192): 47 | yield chunk 48 | except ClientError as e: 49 | logger.error(f"Error while streaming response from Emby for {full_path}: {e}") 50 | finally: 51 | # 无论成功还是失败,都确保上游响应被关闭,以释放连接回连接池。 52 | resp.release() 53 | 54 | # 过滤掉 hop-by-hop headers,这些头部是描述两个直接连接节点之间的信息,不应该被代理转发。 55 | # 'content-length' 也应该被移除,因为在流式传输(Transfer-Encoding: chunked)中,长度是动态的。 56 | response_headers = { 57 | k: v for k, v in resp.headers.items() 58 | if k.lower() not in ('transfer-encoding', 'connection', 'content-encoding', 'content-length') 59 | } 60 | 61 | # 使用 FastAPI 的 StreamingResponse 将数据流式返回给客户端。 62 | # 客户端可以立即开始接收数据,而无需等待整个文件在代理服务器上下载完成。 63 | return StreamingResponse( 64 | content=stream_generator(), 65 | status_code=resp.status, 66 | headers=response_headers, 67 | media_type=resp.headers.get('Content-Type') 68 | ) 69 | 70 | except ClientError as e: 71 | logger.error(f"Proxy connection error to {target_url}: {e}") 72 | # 如果连接到上游服务器时就发生错误,返回一个 502 Bad Gateway 错误。 73 | return Response(content=f"Error connecting to upstream Emby server: {e}", status_code=502) -------------------------------------------------------------------------------- /src/proxy_handlers/handler_system.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_system.py 2 | import json 3 | import logging 4 | from fastapi import Request, Response 5 | from aiohttp import ClientSession 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | async def handle_system_and_playback_info( 10 | request: Request, 11 | full_path: str, 12 | method: str, 13 | real_emby_url: str, 14 | proxy_address: str, 15 | session: ClientSession 16 | ) -> Response | None: 17 | """ 18 | 拦截 /System/Info 和 /PlaybackInfo 请求,重写其中的 URL。 19 | 如果不匹配,则返回 None。 20 | """ 21 | target_url = f"{real_emby_url}/{full_path}" 22 | headers = dict(request.headers) 23 | params = request.query_params 24 | data = await request.body() 25 | 26 | # 关键功能 1: 欺骗客户端,使其始终通过代理通信 27 | if ("/System/Info" in full_path or "/system/info/public" in full_path) and method == "GET": 28 | logger.info(f"Intercepting /System/Info to rewrite URLs for path: {full_path}") 29 | async with session.get(target_url, params=params, headers=headers) as resp: 30 | if resp.status == 200: 31 | content_text = await resp.text() 32 | modified_content_text = content_text.replace(real_emby_url, proxy_address) 33 | final_content = modified_content_text.encode('utf-8') 34 | response_headers = {k: v for k, v in resp.headers.items() if k.lower() not in ('transfer-encoding', 'connection', 'content-encoding', 'content-length')} 35 | return Response(content=final_content, status_code=resp.status, headers=response_headers) 36 | # 如果请求失败,让默认处理器来处理 37 | return None 38 | 39 | # 关键功能 2: 重写播放信息中的地址 (已禁用) 40 | # if "/PlaybackInfo" in full_path: 41 | # logger.info(f"Intercepting /PlaybackInfo to rewrite stream URLs for path: {full_path}") 42 | # async with session.request(method, target_url, params=params, headers=headers, data=data) as resp: 43 | # if resp.status == 200 and "application/json" in resp.headers.get("Content-Type", ""): 44 | # content_json = await resp.json() 45 | # if "MediaSources" in content_json and isinstance(content_json["MediaSources"], list): 46 | # for source in content_json["MediaSources"]: 47 | # for key, value in source.items(): 48 | # if isinstance(value, str) and real_emby_url in value: 49 | # source[key] = value.replace(real_emby_url, proxy_address) 50 | 51 | # final_content = json.dumps(content_json).encode('utf-8') 52 | # response_headers = {k: v for k, v in resp.headers.items() if k.lower() not in ('transfer-encoding', 'connection', 'content-encoding', 'content-length')} 53 | # return Response(content=final_content, status_code=resp.status, media_type="application/json") 54 | # # 如果不是我们关心的类型,让默认处理器来处理 55 | # return None 56 | 57 | return None # 此处理器不处理该请求 58 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_seasons.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_seasons.py (修改后) 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | import re 7 | from fastapi import Request, Response 8 | from aiohttp import ClientSession 9 | from ._find_helper import find_all_series_by_tmdb_id, is_item_in_a_merge_enabled_vlib # <-- 导入新函数 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | SEASONS_PATH_REGEX = re.compile(r"/Shows/([a-f0-9\-]+)/Seasons") 14 | 15 | async def handle_seasons_merge(request: Request, full_path: str, session: ClientSession, real_emby_url: str) -> Response | None: 16 | match = SEASONS_PATH_REGEX.search(f"/{full_path}") 17 | if not match: return None 18 | 19 | representative_id = match.group(1) 20 | logger.info(f"SEASONS_HANDLER: 拦截到对剧集 {representative_id} 的“季”请求。") 21 | 22 | params = request.query_params 23 | user_id = params.get("UserId") 24 | if not user_id: return Response(content="UserId not found", status_code=400) 25 | 26 | headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'} 27 | auth_token_param = {'X-Emby-Token': params.get('X-Emby-Token')} if 'X-Emby-Token' in params else {} 28 | 29 | # --- 【【【 新增的资格检查 】】】 --- 30 | # 在执行任何昂贵的操作之前,先检查此剧集是否有资格进行合并 31 | should_merge = await is_item_in_a_merge_enabled_vlib( 32 | session, real_emby_url, user_id, representative_id, headers, auth_token_param 33 | ) 34 | if not should_merge: 35 | # 如果检查不通过,立即返回 None,让默认处理器来处理这个请求 36 | return None 37 | # --- 资格检查结束 --- 38 | 39 | try: 40 | item_url = f"{real_emby_url}/emby/Users/{user_id}/Items/{representative_id}" 41 | item_params = {'Fields': 'ProviderIds', **auth_token_param} 42 | async with session.get(item_url, params=item_params, headers=headers) as resp: 43 | tmdb_id = (await resp.json()).get("ProviderIds", {}).get("Tmdb") 44 | except Exception as e: 45 | logger.error(f"SEASONS_HANDLER: 获取TMDB ID失败: {e}"); return None 46 | 47 | if not tmdb_id: return None 48 | logger.info(f"SEASONS_HANDLER: 找到TMDB ID: {tmdb_id}。") 49 | 50 | original_series_ids = await find_all_series_by_tmdb_id(session, real_emby_url, user_id, tmdb_id, headers, auth_token_param) 51 | if len(original_series_ids) < 2: return None 52 | logger.info(f"SEASONS_HANDLER: ✅ 找到 {len(original_series_ids)} 个关联剧集: {original_series_ids}。") 53 | 54 | async def fetch_seasons(series_id: str): 55 | url = f"{real_emby_url}/emby/Shows/{series_id}/Seasons" 56 | try: 57 | async with session.get(url, params=params, headers=headers) as resp: 58 | return (await resp.json()).get("Items", []) if resp.status == 200 else [] 59 | except Exception: return [] 60 | 61 | tasks = [fetch_seasons(sid) for sid in original_series_ids] 62 | all_seasons = [s for sublist in await asyncio.gather(*tasks) for s in sublist] 63 | 64 | merged_seasons = {} 65 | for season in all_seasons: 66 | key = season.get("IndexNumber") 67 | if key is not None and key not in merged_seasons: 68 | merged_seasons[key] = season 69 | 70 | final_items = sorted(merged_seasons.values(), key=lambda x: x.get("IndexNumber", 0)) 71 | logger.info(f"SEASONS_HANDLER: 合并完成。合并前总数: {len(all_seasons)}, 合并后最终数量: {len(final_items)}") 72 | 73 | return Response(content=json.dumps({"Items": final_items, "TotalRecordCount": len(final_items)}), status_code=200, media_type="application/json") 74 | -------------------------------------------------------------------------------- /src/models.py: -------------------------------------------------------------------------------- 1 | # src/models.py (Final Corrected Version) 2 | 3 | from pydantic import BaseModel, Field, ConfigDict 4 | from typing import List, Literal, Optional 5 | import uuid 6 | 7 | class AdvancedFilterRule(BaseModel): 8 | field: str 9 | operator: Literal[ 10 | "equals", "not_equals", 11 | "contains", "not_contains", 12 | "greater_than", "less_than", 13 | "is_empty", "is_not_empty" 14 | ] 15 | value: Optional[str] = None 16 | relative_days: Optional[int] = None # 新增:用于存储相对日期(例如 30 天) 17 | 18 | class AdvancedFilter(BaseModel): 19 | id: str = Field(default_factory=lambda: str(uuid.uuid4())) 20 | name: str 21 | match_all: bool = Field(default=True) 22 | rules: List[AdvancedFilterRule] = Field(default_factory=list) 23 | 24 | class VirtualLibrary(BaseModel): 25 | id: str = Field(default_factory=lambda: str(uuid.uuid4())) 26 | name: str 27 | resource_type: Literal["collection", "tag", "genre", "studio", "person", "all", "rsshub"] 28 | resource_id: Optional[str] = None 29 | # image: Optional[str] = None <-- 我们不再需要这个字段了,可以删除或注释掉 30 | image_tag: Optional[str] = None # <-- 【新增】用于存储图片的唯一标签 31 | rsshub_url: Optional[str] = None # <-- 【新增】RSSHUB链接 32 | rss_type: Optional[Literal["douban", "bangumi"]] = None # <-- 【新增】RSS类型 33 | fallback_tmdb_id: Optional[str] = None # <-- 【新增】RSS库的兜底TMDB ID 34 | fallback_tmdb_type: Optional[Literal["Movie", "TV"]] = None # <-- 【新增】RSS库的兜底TMDB类型 35 | enable_retention: bool = Field(default=False) # <-- 【新增】是否开启数据保留功能 36 | retention_days: int = Field(default=7) # <-- 【新增】RSS项目保留天数,默认7天 37 | advanced_filter_id: Optional[str] = None 38 | merge_by_tmdb_id: bool = Field(default=False) 39 | order: int = 0 40 | source_library: Optional[str] = None 41 | conditions: Optional[list] = None 42 | cover_custom_zh_font_path: Optional[str] = Field(default=None) # <-- 【新增】海报自定义中文字体 43 | cover_custom_en_font_path: Optional[str] = Field(default=None) # <-- 【新增】海报自定义英文字体 44 | cover_custom_image_path: Optional[str] = Field(default=None) # <-- 【新增】海报自定义图片目录 45 | 46 | class AppConfig(BaseModel): 47 | emby_url: str = Field(default="http://127.0.0.1:8096") 48 | emby_api_key: Optional[str] = Field(default="") 49 | emby_server_id: Optional[str] = Field(default=None) # 新增:用于TMDB缓存占位符的备用服务器ID 50 | log_level: Literal["debug", "info", "warn", "error"] = Field(default="info") 51 | display_order: List[str] = Field(default_factory=list) 52 | hide: List[str] = Field(default_factory=list) 53 | 54 | # 使用别名 'library' 来兼容旧的 config.json 55 | virtual_libraries: List[VirtualLibrary] = Field( 56 | default_factory=list, 57 | alias="library", 58 | validation_alias="library" # <-- 【新增】确保加载时也优先用 'library' 59 | ) 60 | 61 | # 明确定义 advanced_filters,不使用任何复杂的配置 62 | advanced_filters: List[AdvancedFilter] = Field(default_factory=list) 63 | 64 | # 新增:缓存开关 65 | enable_cache: bool = Field(default=True) 66 | 67 | # 新增:自动生成封面的默认样式 68 | default_cover_style: str = Field(default='style_multi_1') 69 | 70 | # 新增:显示缺失剧集的开关 71 | show_missing_episodes: bool = Field(default=False) 72 | 73 | # 新增:TMDB API Key 74 | tmdb_api_key: Optional[str] = Field(default="") 75 | 76 | # 新增:TMDB HTTP 代理 77 | tmdb_proxy: Optional[str] = Field(default="") 78 | 79 | # 新增:RSS 定时刷新间隔(小时),0为禁用 80 | rss_refresh_interval: Optional[int] = Field(default=0) 81 | 82 | # 新增:全局强制 TMDB ID 合并 83 | force_merge_by_tmdb_id: bool = Field(default=False) 84 | 85 | # 新增:自定义字体路径 86 | custom_zh_font_path: Optional[str] = Field(default="") 87 | custom_en_font_path: Optional[str] = Field(default="") 88 | custom_image_path: Optional[str] = Field(default="") # <-- 【新增】全局自定义图片目录 89 | 90 | class Config: 91 | # 允许从别名填充模型 92 | populate_by_name = True 93 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 83 | 84 | 91 | 92 | 147 | -------------------------------------------------------------------------------- /frontend/src/components/DisplayOrderManager.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 115 | 116 | -------------------------------------------------------------------------------- /frontend/src/components/VirtualLibraries.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 103 | 104 | 116 | -------------------------------------------------------------------------------- /src/proxy_handlers/_find_helper.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/_find_helper.py (终极加固版) 2 | 3 | import json 4 | import logging 5 | from typing import List, Dict 6 | from aiohttp import ClientSession 7 | import config_manager 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | async def is_item_in_a_merge_enabled_vlib( 12 | session: ClientSession, real_emby_url: str, user_id: str, item_id: str, headers: Dict, auth_token_param: Dict 13 | ) -> bool: 14 | """ 15 | 检查给定的 item_id 是否属于任何一个启用了 TMDB 合并功能的虚拟库。 16 | 这是决定是否合并其子项目(季/集)的关键。 17 | 此版本为终极加固版,强制进行字符串比较,以避免任何类型不匹配问题,并依赖DEBUG日志进行诊断。 18 | """ 19 | config = config_manager.load_config() 20 | 21 | # 检查全局强制合并开关 22 | if config.force_merge_by_tmdb_id: 23 | logger.info(f"MERGE_CHECK: ✅ 全局开关已启用。允许对项目 {item_id} 进行合并。") 24 | return True 25 | 26 | merge_vlibs = [vlib for vlib in config.virtual_libraries if vlib.merge_by_tmdb_id] 27 | 28 | if not merge_vlibs: 29 | logger.debug(f"MERGE_CHECK: 没有任何虚拟库启用合并功能。跳过对项目 {item_id} 的合并检查。") 30 | return False 31 | 32 | item_details_url = f"{real_emby_url}/emby/Users/{user_id}/Items/{item_id}" 33 | item_params = { 34 | 'Fields': 'CollectionIds,TagItems,GenreItems,Studios,People,ProviderIds', 35 | **auth_token_param 36 | } 37 | 38 | item = None 39 | try: 40 | async with session.get(item_details_url, params=item_params, headers=headers) as resp: 41 | if resp.status != 200: 42 | logger.warning(f"MERGE_CHECK: 无法获取项目 {item_id} 的详情。状态码: {resp.status}, 响应: {await resp.text()}") 43 | return False 44 | 45 | item = await resp.json() 46 | # 【【【 这是本次最关键的日志,请务必在 DEBUG 模式下查看 】】】 47 | logger.debug(f"MERGE_CHECK: 已获取项目 {item_id} ('{item.get('Name')}') 的详情用于匹配。收到的数据: \n{json.dumps(item, indent=2, ensure_ascii=False)}") 48 | 49 | except Exception as e: 50 | logger.error(f"MERGE_CHECK: 获取项目 {item_id} 详情时发生严重错误: {e}") 51 | return False 52 | 53 | if not item: 54 | return False 55 | 56 | for vlib in merge_vlibs: 57 | resource_type = vlib.resource_type 58 | # --- 核心修正:将虚拟库的目标ID也转换为字符串,用于后续比较 --- 59 | resource_id_str = str(vlib.resource_id) 60 | match_found = False 61 | 62 | try: 63 | if resource_type == "collection": 64 | # 将项目的所有收藏夹ID转换为字符串列表 65 | item_collection_ids = [str(col_id) for col_id in item.get("CollectionIds", [])] 66 | if resource_id_str in item_collection_ids: 67 | match_found = True 68 | 69 | elif resource_type == "tag": 70 | # 将项目的所有标签ID转换为字符串列表 71 | item_tag_ids = [str(tag.get("Id")) for tag in item.get("TagItems", []) if tag.get("Id")] 72 | if resource_id_str in item_tag_ids: 73 | match_found = True 74 | 75 | elif resource_type == "genre": 76 | # 将项目的所有类型ID转换为字符串列表 77 | item_genre_ids = [str(genre.get("Id")) for genre in item.get("GenreItems", []) if genre.get("Id")] 78 | if resource_id_str in item_genre_ids: 79 | match_found = True 80 | 81 | elif resource_type == "studio": 82 | # 将项目的所有工作室ID转换为字符串列表 83 | item_studio_ids = [str(studio.get("Id")) for studio in item.get("Studios", []) if studio.get("Id")] 84 | if resource_id_str in item_studio_ids: 85 | match_found = True 86 | 87 | elif resource_type == "person": 88 | # 将项目的所有人员ID转换为字符串列表 89 | item_person_ids = [str(person.get("Id")) for person in item.get("People", []) if person.get("Id")] 90 | if resource_id_str in item_person_ids: 91 | match_found = True 92 | 93 | except Exception as e: 94 | logger.error(f"MERGE_CHECK: 在检查虚拟库 '{vlib.name}' 时发生内部错误: {e}") 95 | continue 96 | 97 | if match_found: 98 | logger.info(f"MERGE_CHECK: ✅ 成功! 项目 {item_id} ('{item.get('Name')}') 确认位于已启用合并的虚拟库 '{vlib.name}' (类型: {resource_type}) 中。允许合并。") 99 | return True 100 | 101 | logger.info(f"MERGE_CHECK: ❌ 拒绝。项目 {item_id} ('{item.get('Name')}') 未在 {len(merge_vlibs)} 个已启用合并的虚拟库中找到。") 102 | return False 103 | 104 | 105 | async def find_all_series_by_tmdb_id( 106 | session: ClientSession, real_emby_url: str, user_id: str, tmdb_id: str, headers: Dict, auth_token_param: Dict 107 | ) -> List[str]: 108 | search_url = f"{real_emby_url}/emby/Items" 109 | search_params = { 110 | 'Recursive': 'true', 111 | 'IncludeItemTypes': 'Series', 112 | 'Fields': 'ProviderIds', 113 | 'HasTmdbId': 'true', 114 | 'UserId': user_id, 115 | **auth_token_param 116 | } 117 | logger.debug(f"正在执行全局剧集遍历搜索 (TMDB ID: {tmdb_id})") 118 | try: 119 | async with session.get(search_url, params=search_params, headers=headers, timeout=120) as resp: 120 | if resp.status == 200: 121 | data = await resp.json() 122 | all_series = data.get("Items", []) 123 | found_ids = [ 124 | item.get("Id") for item in all_series 125 | if str(item.get("ProviderIds", {}).get("Tmdb")) == str(tmdb_id) 126 | ] 127 | return list(set(found_ids)) 128 | else: 129 | logger.error(f"全局遍历搜索失败,状态码: {resp.status},响应: {await resp.text()}") 130 | return [] 131 | except Exception as e: 132 | logger.error(f"全局遍历搜索时发生异常: {e}") 133 | return [] 134 | -------------------------------------------------------------------------------- /src/proxy_handlers/_filter_translator.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/_filter_translator.py (新文件) 2 | 3 | import logging 4 | from typing import List, Dict, Any, Tuple 5 | from models import AdvancedFilterRule 6 | from datetime import datetime, timedelta 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | # 映射我们的操作符到 Emby API 的过滤器名称后缀 11 | # 例如: 'equals' -> 'Is', 'contains' -> 'Contains' (虽然不常用), 'greater_than' -> 'GreaterThan' 12 | # 注意:Emby 对参数名的大小写敏感。 13 | OPERATOR_MAP = { 14 | "equals": "Is", 15 | "not_equals": "IsNot", 16 | "contains": "Contains", 17 | "greater_than": "GreaterThan", 18 | "less_than": "LessThan", 19 | } 20 | 21 | # 映射我们的字段到 Emby API 的查询参数 22 | # Key 是我们在前端定义的 field, Value 是对应的 Emby API 参数 (或元组) 23 | # 对于 'greater_than'/'less_than',我们使用 (Min-Param, Max-Param) 的元组 24 | FIELD_MAP = { 25 | "CommunityRating": ("MinCommunityRating", "MaxCommunityRating"), 26 | "CriticRating": ("MinCriticRating", "MaxCriticRating"), 27 | "OfficialRating": "OfficialRatings", 28 | "ProductionYear": ("MinPremiereDate", "MaxPremiereDate"), # 将年份转换为日期 29 | "PremiereDate": ("MinPremiereDate", "MaxPremiereDate"), # 新增:首播日期 30 | "Genres": "Genres", 31 | "Tags": "Tags", 32 | "Studios": "Studios", 33 | "VideoRange": "VideoTypes", # 例如 'HDR', 'SDR' 34 | "Container": "Containers", 35 | "NameStartsWith": "NameStartsWith", 36 | "SeriesStatus": "SeriesStatus", 37 | # 布尔类型的特殊字段 38 | "IsMovie": "IsMovie", 39 | "IsSeries": "IsSeries", 40 | "IsPlayed": "IsPlayed", 41 | "IsUnplayed": "IsUnplayed", 42 | "HasSubtitles": "HasSubtitles", 43 | "HasOfficialRating": "HasOfficialRating", 44 | # 存在性检查 45 | "ProviderIds.Tmdb": "HasTmdbId", 46 | "ProviderIds.Imdb": "HasImdbId", 47 | } 48 | 49 | def translate_rules(rules: List[AdvancedFilterRule]) -> Tuple[Dict[str, Any], List[AdvancedFilterRule]]: 50 | """ 51 | 将高级筛选规则列表翻译成 Emby 原生 API 参数和需要后筛选的规则。 52 | 53 | Args: 54 | rules: 从前端传来的高级筛选规则列表。 55 | 56 | Returns: 57 | 一个元组,包含: 58 | - emby_native_params: 一个可以直接用于 aiohttp 请求的字典。 59 | - post_filter_rules: 一个无法被翻译,需要代理端进行后筛选的规则列表。 60 | """ 61 | emby_native_params = {} 62 | post_filter_rules = [] 63 | 64 | for rule in rules: 65 | field = rule.field 66 | operator = rule.operator 67 | value = rule.value 68 | translated = False 69 | 70 | # 新增:处理相对日期 71 | if rule.relative_days and field == "PremiereDate": 72 | # 计算目标日期 73 | target_date = datetime.utcnow() - timedelta(days=rule.relative_days) 74 | # 将其格式化为字符串,并赋值给 value 变量,以便后续逻辑复用 75 | value = target_date.strftime('%Y-%m-%d') 76 | # 确保操作符是 greater_than 77 | operator = "greater_than" 78 | 79 | if operator == "is_not_empty": 80 | if field in FIELD_MAP and isinstance(FIELD_MAP[field], str) and FIELD_MAP[field].startswith("Has"): 81 | emby_native_params[FIELD_MAP[field]] = "true" 82 | translated = True 83 | 84 | elif operator == "is_empty": 85 | if field in FIELD_MAP and isinstance(FIELD_MAP[field], str) and FIELD_MAP[field].startswith("Has"): 86 | emby_native_params[FIELD_MAP[field]] = "false" 87 | translated = True 88 | 89 | elif field in FIELD_MAP: 90 | param_template = FIELD_MAP[field] 91 | 92 | # 处理范围查询 (Min/Max) 93 | if isinstance(param_template, tuple): 94 | min_param, max_param = param_template 95 | if operator == "greater_than": 96 | param_name = min_param 97 | elif operator == "less_than": 98 | param_name = max_param 99 | elif operator == "equals": 100 | # 对于年份的精确匹配,需要设置Min和Max为同一年 101 | if field == "ProductionYear": 102 | emby_native_params[min_param] = f"{value}-01-01T00:00:00.000Z" 103 | emby_native_params[max_param] = f"{value}-12-31T23:59:59.999Z" 104 | translated = True 105 | # 新增:对首播日期的精确匹配 106 | elif field == "PremiereDate": 107 | emby_native_params[min_param] = f"{value}T00:00:00.000Z" 108 | emby_native_params[max_param] = f"{value}T23:59:59.999Z" 109 | translated = True 110 | else: # 其他字段的 equals 111 | param_name = f"{param_template[0].replace('Min','')}" # 假设是 CommunityRating 112 | else: 113 | param_name = None 114 | 115 | if not translated and param_name: 116 | # 对年份特殊处理,转换为完整的日期时间格式 117 | if field == "ProductionYear": 118 | # aiohttp 会自动编码,这里不需要手动处理 119 | emby_native_params[param_name] = f"{value}-01-01T00:00:00.000Z" if operator == "greater_than" else f"{value}-12-31T23:59:59.999Z" 120 | # 新增:对首播日期的处理 121 | elif field == "PremiereDate": 122 | # 假设 value 是 'YYYY-MM-DD' 格式 123 | emby_native_params[param_name] = f"{value}T00:00:00.000Z" if operator == "greater_than" else f"{value}T23:59:59.999Z" 124 | else: 125 | emby_native_params[param_name] = value 126 | translated = True 127 | 128 | # 处理直接映射的字段 129 | elif isinstance(param_template, str): 130 | emby_native_params[param_template] = value 131 | translated = True 132 | 133 | if not translated: 134 | logger.info(f"高级筛选规则无法翻译,将进行后筛选: {rule.field} {rule.operator} {rule.value}") 135 | post_filter_rules.append(rule) 136 | else: 137 | logger.info(f"高级筛选规则已成功翻译为原生参数: {rule.field} -> {emby_native_params}") 138 | 139 | return emby_native_params, post_filter_rules 140 | -------------------------------------------------------------------------------- /src/db_manager.py: -------------------------------------------------------------------------------- 1 | import sqlite3 2 | import threading 3 | import datetime 4 | from pathlib import Path 5 | 6 | # 数据库文件都存放在 config 目录下 7 | DB_DIR = Path(__file__).parent.parent / "config" 8 | DB_DIR.mkdir(exist_ok=True) 9 | 10 | # 为每个功能定义独立的数据库文件 11 | RSS_CACHE_DB = DB_DIR / "rss_cache.db" 12 | DOUBAN_CACHE_DB = DB_DIR / "douban_cache.db" 13 | BANGUMI_CACHE_DB = DB_DIR / "bangumi_cache.db" 14 | TMDB_CACHE_DB = DB_DIR / "tmdb_cache.db" 15 | 16 | class DBManager: 17 | _instances = {} 18 | _locks = {} 19 | 20 | def __new__(cls, db_path): 21 | if db_path not in cls._instances: 22 | cls._instances[db_path] = super(DBManager, cls).__new__(cls) 23 | cls._locks[db_path] = threading.Lock() 24 | return cls._instances[db_path] 25 | 26 | def __init__(self, db_path): 27 | self.db_path = db_path 28 | self.conn = None 29 | 30 | def get_conn(self): 31 | # 每个线程使用自己的连接 32 | local = threading.local() 33 | if not hasattr(local, 'conn') or local.conn.is_closed(): 34 | local.conn = sqlite3.connect(self.db_path, check_same_thread=False) 35 | local.conn.row_factory = sqlite3.Row 36 | return local.conn 37 | 38 | def execute(self, query, params=(), commit=False): 39 | with self._locks[self.db_path]: 40 | conn = self.get_conn() 41 | cursor = conn.cursor() 42 | cursor.execute(query, params) 43 | if commit: 44 | conn.commit() 45 | return cursor 46 | 47 | def fetchall(self, query, params=()): 48 | cursor = self.execute(query, params) 49 | return cursor.fetchall() 50 | 51 | def fetchone(self, query, params=()): 52 | cursor = self.execute(query, params) 53 | return cursor.fetchone() 54 | 55 | def close(self): 56 | conn = self.get_conn() 57 | if conn: 58 | conn.close() 59 | 60 | def init_databases(): 61 | # 初始化 RSS 缓存数据库 62 | rss_db = DBManager(RSS_CACHE_DB) 63 | rss_db.execute(""" 64 | CREATE TABLE IF NOT EXISTS rss_cache ( 65 | url TEXT PRIMARY KEY, 66 | content TEXT, 67 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 68 | ) 69 | """, commit=True) 70 | 71 | # 初始化豆瓣缓存和映射数据库 72 | douban_db = DBManager(DOUBAN_CACHE_DB) 73 | douban_db.execute(""" 74 | CREATE TABLE IF NOT EXISTS douban_api_cache ( 75 | douban_id TEXT PRIMARY KEY, 76 | api_response TEXT, 77 | name TEXT, -- 新增:存储条目名称 78 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 79 | ) 80 | """, commit=True) 81 | douban_db.execute(""" 82 | CREATE TABLE IF NOT EXISTS douban_tmdb_mapping ( 83 | douban_id TEXT PRIMARY KEY, 84 | tmdb_id TEXT, 85 | media_type TEXT, 86 | match_method TEXT, -- 新增:存储匹配方法 87 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 88 | ) 89 | """, commit=True) 90 | 91 | # --- 豆瓣数据库迁移 --- 92 | try: 93 | cursor = douban_db.execute("PRAGMA table_info(douban_api_cache)") 94 | columns = [row['name'] for row in cursor.fetchall()] 95 | if 'name' not in columns: 96 | print("Adding 'name' column to 'douban_api_cache' table.") 97 | douban_db.execute("ALTER TABLE douban_api_cache ADD COLUMN name TEXT", commit=True) 98 | 99 | cursor = douban_db.execute("PRAGMA table_info(douban_tmdb_mapping)") 100 | columns = [row['name'] for row in cursor.fetchall()] 101 | if 'match_method' not in columns: 102 | print("Adding 'match_method' column to 'douban_tmdb_mapping' table.") 103 | douban_db.execute("ALTER TABLE douban_tmdb_mapping ADD COLUMN match_method TEXT", commit=True) 104 | except Exception as e: 105 | print(f"Error updating douban tables schema: {e}") 106 | 107 | # 初始化 Bangumi 缓存和映射数据库 108 | bangumi_db = DBManager(BANGUMI_CACHE_DB) 109 | bangumi_db.execute(""" 110 | CREATE TABLE IF NOT EXISTS bangumi_api_cache ( 111 | bangumi_id TEXT PRIMARY KEY, 112 | api_response TEXT, 113 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 114 | ) 115 | """, commit=True) 116 | bangumi_db.execute(""" 117 | CREATE TABLE IF NOT EXISTS bangumi_tmdb_mapping ( 118 | bangumi_id TEXT PRIMARY KEY, 119 | tmdb_id TEXT, 120 | media_type TEXT, 121 | match_method TEXT, 122 | score REAL, 123 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 124 | ) 125 | """, commit=True) 126 | 127 | # 初始化 TMDB 缓存数据库 128 | tmdb_db = DBManager(TMDB_CACHE_DB) 129 | tmdb_db.execute(""" 130 | CREATE TABLE IF NOT EXISTS tmdb_cache ( 131 | tmdb_id TEXT PRIMARY KEY, 132 | media_type TEXT, 133 | data TEXT, 134 | timestamp DATETIME DEFAULT CURRENT_TIMESTAMP 135 | ) 136 | """, commit=True) 137 | 138 | # 初始化 RSS 虚拟库项目数据库 139 | rss_library_db = DBManager(DB_DIR / "rss_library_items.db") 140 | rss_library_db.execute(""" 141 | CREATE TABLE IF NOT EXISTS rss_library_items ( 142 | library_id TEXT, 143 | tmdb_id TEXT, 144 | media_type TEXT, 145 | emby_item_id TEXT, -- 新增:用于存储在 Emby 中匹配到的 Item ID 146 | added_at DATETIME DEFAULT CURRENT_TIMESTAMP, -- 新增:添加时间,用于过期清理 147 | PRIMARY KEY (library_id, tmdb_id) 148 | ) 149 | """, commit=True) 150 | 151 | # --- 安全地为旧表添加新列 --- 152 | try: 153 | cursor = rss_library_db.execute("PRAGMA table_info(rss_library_items)") 154 | columns = [row['name'] for row in cursor.fetchall()] 155 | 156 | if 'emby_item_id' not in columns: 157 | print("Adding 'emby_item_id' column to 'rss_library_items' table.") 158 | rss_library_db.execute("ALTER TABLE rss_library_items ADD COLUMN emby_item_id TEXT", commit=True) 159 | 160 | if 'added_at' not in columns: 161 | print("Adding 'added_at' column to 'rss_library_items' table.") 162 | # 为旧数据设置当前时间,避免被误删。使用固定时间字符串以兼容 SQLite 限制。 163 | now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 164 | rss_library_db.execute(f"ALTER TABLE rss_library_items ADD COLUMN added_at DATETIME DEFAULT '{now_str}'", commit=True) 165 | 166 | except Exception as e: 167 | print(f"Error updating rss_library_items table schema: {e}") 168 | 169 | # 在模块加载时执行初始化 170 | init_databases() 171 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_rss.py: -------------------------------------------------------------------------------- 1 | from db_manager import DBManager 2 | from pathlib import Path 3 | import requests 4 | import json 5 | import config_manager 6 | 7 | DB_DIR = Path(__file__).parent.parent.parent / "config" 8 | RSS_LIBRARY_DB = DB_DIR / "rss_library_items.db" 9 | TMDB_CACHE_DB = DB_DIR / "tmdb_cache.db" 10 | 11 | class RssHandler: 12 | def __init__(self): 13 | self.rss_library_db = DBManager(RSS_LIBRARY_DB) 14 | self.tmdb_cache_db = DBManager(TMDB_CACHE_DB) 15 | self.config = config_manager.load_config() 16 | 17 | async def handle(self, request_path: str, vlib_id: str, request_params, user_id: str, session, real_emby_url: str, request_headers): 18 | all_items_from_db = self.rss_library_db.fetchall( 19 | "SELECT tmdb_id, media_type, emby_item_id FROM rss_library_items WHERE library_id = ?", 20 | (vlib_id,) 21 | ) 22 | if not all_items_from_db: 23 | return {"Items": [], "TotalRecordCount": 0} 24 | 25 | existing_emby_ids = [str(item['emby_item_id']) for item in all_items_from_db if item['emby_item_id']] 26 | missing_items_info = [{'tmdb_id': item['tmdb_id'], 'media_type': item['media_type']} for item in all_items_from_db if not item['emby_item_id']] 27 | 28 | # 1. 获取已存在项目的数据,并从中提取真实的 ServerId 29 | existing_items_data = [] 30 | server_id = None 31 | if existing_emby_ids: 32 | # 关键:传入 request_params 以继承 Fields 33 | existing_items_data = await self._get_emby_items_by_ids_async(existing_emby_ids, request_params, user_id, session, real_emby_url, request_headers) 34 | if existing_items_data: 35 | server_id = existing_items_data[0].get("ServerId") 36 | 37 | # 2. 如果没有已存在的项目,则从配置中获取备用 ServerId 38 | if not server_id: 39 | server_id = self.config.emby_server_id or "emby" 40 | 41 | # 3. 使用真实的 ServerId 创建不存在项目的占位符 42 | missing_items_placeholders = [] 43 | for item_info in missing_items_info: 44 | # 注意:_get_item_from_tmdb 是同步的,但内部的数据库和网络请求是阻塞的 45 | # 在异步函数中直接调用它在 aiohttp 环境下是安全的 46 | item = self._get_item_from_tmdb(item_info['tmdb_id'], item_info['media_type'], server_id) 47 | if item: 48 | missing_items_placeholders.append(item) 49 | 50 | final_items = existing_items_data + missing_items_placeholders 51 | return {"Items": final_items, "TotalRecordCount": len(final_items)} 52 | 53 | async def _get_emby_items_by_ids_async(self, item_ids: list, request_params, user_id: str, session, real_emby_url: str, request_headers): 54 | if not item_ids: return [] 55 | 56 | ids_str = ",".join(item_ids) 57 | url = f"{real_emby_url}/emby/Users/{user_id}/Items" 58 | 59 | headers = {k: v for k, v in request_headers.items() if k.lower() in ['accept', 'accept-language', 'user-agent', 'x-emby-authorization', 'x-emby-client', 'x-emby-device-name', 'x-emby-device-id', 'x-emby-client-version', 'x-emby-language', 'x-emby-token']} 60 | 61 | params = {"Ids": ids_str} 62 | if request_params and "Fields" in request_params: 63 | params["Fields"] = request_params.get("Fields") 64 | if request_params and "X-Emby-Token" in request_params: 65 | headers["X-Emby-Token"] = request_params.get("X-Emby-Token") 66 | 67 | try: 68 | async with session.get(url, params=params, headers=headers) as resp: 69 | if resp.status == 200: 70 | return (await resp.json()).get("Items", []) 71 | return [] 72 | except Exception as e: 73 | print(f"通过 ID 查询 Emby 项目失败 (async): {e}") 74 | return [] 75 | 76 | def _get_item_from_tmdb(self, tmdb_id, media_type, server_id): # 新增 server_id 参数 77 | cached = self.tmdb_cache_db.fetchone("SELECT data FROM tmdb_cache WHERE tmdb_id = ? AND media_type = ?", (tmdb_id, media_type)) 78 | if cached: 79 | # 关键:即使是从缓存加载,也要用最新的真实 ServerId 覆盖 80 | cached_data = json.loads(cached['data']) 81 | cached_data["ServerId"] = server_id 82 | return cached_data 83 | 84 | if not self.config.tmdb_api_key: return None 85 | 86 | item_type_path = 'movie' if media_type == 'movie' else 'tv' 87 | url = f"https://api.themoviedb.org/3/{item_type_path}/{tmdb_id}?api_key={self.config.tmdb_api_key}&language=zh-CN" 88 | proxies = {"http": self.config.tmdb_proxy, "https": self.config.tmdb_proxy} if self.config.tmdb_proxy else None 89 | 90 | try: 91 | response = requests.get(url, proxies=proxies, timeout=10) 92 | response.raise_for_status() 93 | data = response.json() 94 | 95 | emby_item = self._format_tmdb_to_emby(data, media_type, tmdb_id, server_id) # 传递 server_id 96 | 97 | self.tmdb_cache_db.execute( 98 | "INSERT OR REPLACE INTO tmdb_cache (tmdb_id, media_type, data) VALUES (?, ?, ?)", 99 | (tmdb_id, media_type, json.dumps(emby_item, ensure_ascii=False)), 100 | commit=True 101 | ) 102 | return emby_item 103 | except Exception as e: 104 | print(f"Error fetching from TMDB for {tmdb_id}: {e}") 105 | return None 106 | 107 | def _format_tmdb_to_emby(self, tmdb_data, media_type, tmdb_id, server_id): # 新增 server_id 参数 108 | is_movie = media_type == 'movie' 109 | item_type = 'Movie' if is_movie else 'Series' 110 | 111 | return { 112 | "Name": tmdb_data.get('title') if is_movie else tmdb_data.get('name'), 113 | "ProductionYear": int((tmdb_data.get('release_date') or '0').split('-')[0]) if is_movie else int((tmdb_data.get('first_air_date') or '0').split('-')[0]), 114 | "Id": f"tmdb-{tmdb_id}", 115 | "Type": item_type, 116 | "IsFolder": False, 117 | "MediaType": "Video" if is_movie else "Series", 118 | "ServerId": server_id, # 使用真实的 ServerId 119 | "ImageTags": {"Primary": "placeholder"}, 120 | "HasPrimaryImage": True, 121 | "PrimaryImageAspectRatio": 0.6666666666666666, 122 | "ProviderIds": {"Tmdb": str(tmdb_id)}, 123 | "UserData": {"Played": False, "PlayCount": 0, "IsFavorite": False, "PlaybackPositionTicks": 0}, 124 | "Overview": tmdb_data.get("overview"), 125 | "PremiereDate": tmdb_data.get("release_date") if is_movie else tmdb_data.get("first_air_date"), 126 | } 127 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_views.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_views.py 2 | 3 | import json 4 | import logging 5 | from fastapi import Request, Response 6 | from aiohttp import ClientSession 7 | from models import AppConfig 8 | import asyncio 9 | from pathlib import Path 10 | 11 | # 【新增】导入后台生成处理器和任务锁 12 | from . import handler_autogen 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | # 【新增】定义封面存储路径 17 | COVERS_DIR = Path("/app/config/images") 18 | 19 | async def handle_view_injection( 20 | request: Request, 21 | full_path: str, 22 | method: str, 23 | real_emby_url: str, 24 | session: ClientSession, 25 | config: AppConfig 26 | ) -> Response | None: 27 | if "Users" not in full_path or "/Views" not in full_path or method != "GET": 28 | return None 29 | 30 | if not config.display_order: 31 | # 旧版逻辑可以保持原样,或者也进行相应修改,但我们主要关注新版 32 | return await legacy_handle_view_injection(request, full_path, method, real_emby_url, session, config) 33 | 34 | logger.info(f"Full layout control enabled. Intercepting views for path: {full_path}") 35 | 36 | target_url = f"{real_emby_url}/{full_path}" 37 | params = request.query_params 38 | 39 | # 采用更稳健的白名单策略转发必要的请求头,确保认证信息不丢失 40 | headers_to_forward = { 41 | k: v for k, v in request.headers.items() 42 | if k.lower() in [ 43 | 'accept', 'accept-language', 'user-agent', 44 | 'x-emby-authorization', 'x-emby-client', 'x-emby-device-name', 45 | 'x-emby-device-id', 'x-emby-client-version', 'x-emby-language', 46 | 'x-emby-token' 47 | ] 48 | } 49 | 50 | async with session.get(target_url, params=params, headers=headers_to_forward) as resp: 51 | if resp.status != 200 or "application/json" not in resp.headers.get("Content-Type", ""): 52 | return None 53 | 54 | original_data = await resp.json() 55 | 56 | all_available_libs = {item["Id"]: item for item in original_data.get("Items", [])} 57 | 58 | server_id = next((item.get("ServerId") for item in all_available_libs.values() if item.get("ServerId")), "unknown") 59 | 60 | for vlib in config.virtual_libraries: 61 | vlib_data = { 62 | "Name": vlib.name, "ServerId": server_id, "Id": vlib.id, 63 | "Type": "CollectionFolder", 64 | "CollectionType": "tvshows", 65 | "IsFolder": True, 66 | "ImageTags": {} 67 | } 68 | 69 | # 【【【 核心修改:在这里决定是否触发自动生成 】】】 70 | image_file = COVERS_DIR / f"{vlib.id}.jpg" 71 | 72 | if image_file.is_file(): 73 | # 图片已存在, 注入真实的 ImageTag 74 | if vlib.image_tag: 75 | vlib_data["ImageTags"]["Primary"] = vlib.image_tag 76 | vlib_data["HasPrimaryImage"] = True 77 | else: 78 | # 图片不存在, 触发后台生成并注入假Tag 79 | logger.info(f"主页视图:发现虚拟库 '{vlib.name}' ({vlib.id}) 缺少封面。") 80 | 81 | # 检查任务是否已在运行,防止重复触发 82 | if vlib.id not in handler_autogen.GENERATION_IN_PROGRESS: 83 | # 【【【 核心修正2:传递用户ID和API Key 】】】 84 | # 从当前请求中提取必要信息 85 | user_id = params.get("UserId") 86 | if not user_id: # 如果参数里没有,就从路径里找 87 | path_parts = full_path.split('/') 88 | if 'Users' in path_parts: 89 | user_id = path_parts[path_parts.index('Users') + 1] 90 | api_key = params.get("X-Emby-Token") or config.emby_api_key 91 | 92 | if user_id and api_key: 93 | asyncio.create_task(handler_autogen.generate_poster_in_background(vlib.id, user_id, api_key)) 94 | else: 95 | logger.warning(f"无法为库 {vlib.id} 触发后台任务,因为缺少 UserId 或 ApiKey。") 96 | 97 | vlib_data["ImageTags"]["Primary"] = "generating_placeholder" 98 | vlib_data["HasPrimaryImage"] = True 99 | 100 | all_available_libs[vlib.id] = vlib_data 101 | 102 | sorted_items = [all_available_libs[lib_id] for lib_id in config.display_order if lib_id in all_available_libs] 103 | 104 | original_data["Items"] = sorted_items 105 | original_data["TotalRecordCount"] = len(sorted_items) 106 | 107 | final_content = json.dumps(original_data).encode('utf-8') 108 | return Response(content=final_content, status_code=200, media_type="application/json") 109 | 110 | 111 | async def legacy_handle_view_injection(request: Request, full_path: str, method: str, real_emby_url: str, session: ClientSession, config: AppConfig): 112 | target_url = f"{real_emby_url}/{full_path}" 113 | params = request.query_params 114 | 115 | # 同样在此处采用白名单策略 116 | headers_to_forward = { 117 | k: v for k, v in request.headers.items() 118 | if k.lower() in [ 119 | 'accept', 'accept-language', 'user-agent', 120 | 'x-emby-authorization', 'x-emby-client', 'x-emby-device-name', 121 | 'x-emby-device-id', 'x-emby-client-version', 'x-emby-language', 122 | 'x-emby-token' 123 | ] 124 | } 125 | 126 | async with session.get(target_url, params=params, headers=headers_to_forward) as resp: 127 | if resp.status == 200 and "application/json" in resp.headers.get("Content-Type", ""): 128 | content_json = await resp.json() 129 | if content_json.get("Items"): 130 | if config.hide: 131 | content_json["Items"] = [item for item in content_json["Items"] if item.get("CollectionType") not in config.hide] 132 | 133 | # 【【【 已删除 is_hidden 判断 】】】 134 | sorted_virtual_libraries = sorted(config.virtual_libraries, key=lambda vlib: getattr(vlib, 'order', 0)) 135 | server_id = content_json["Items"][0].get("ServerId") if content_json.get("Items") else "unknown" 136 | 137 | for vlib in sorted_virtual_libraries: 138 | if not any(item.get("Id") == vlib.id for item in content_json["Items"]): 139 | content_json["Items"].append({ 140 | "Name": vlib.name, "ServerId": server_id, "Id": vlib.id, 141 | "Type": "CollectionFolder", "CollectionType": "tvshows", 142 | "IsFolder": True, "ImageTags": {} 143 | }) 144 | final_content = json.dumps(content_json).encode('utf-8') 145 | return Response(content=final_content, status_code=200, media_type="application/json") 146 | return None 147 | -------------------------------------------------------------------------------- /frontend/src/components/SystemSettings.vue: -------------------------------------------------------------------------------- 1 | 184 | 185 | 204 | 205 | 221 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_autogen.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_autogen.py (最终身份修正版) 2 | 3 | import asyncio 4 | import logging 5 | import shutil 6 | import random 7 | import base64 8 | import os 9 | import hashlib 10 | import time 11 | from pathlib import Path 12 | from io import BytesIO 13 | from PIL import Image 14 | import aiohttp 15 | import importlib 16 | 17 | import config_manager 18 | # from cover_generator import style_multi_1 # 改为动态导入 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | GENERATION_IN_PROGRESS = set() 23 | 24 | # 【【【 核心修正1:函数签名改变,接收用户ID和Token 】】】 25 | async def generate_poster_in_background(library_id: str, user_id: str, api_key: str): 26 | """ 27 | 在后台异步生成海报。此版本使用触发时传入的身份信息来确保权限正确。 28 | """ 29 | if library_id in GENERATION_IN_PROGRESS: 30 | return 31 | 32 | GENERATION_IN_PROGRESS.add(library_id) 33 | logger.info(f"✅ 已启动库 {library_id} (用户: {user_id}) 的封面自动生成后台任务。") 34 | 35 | config = None 36 | temp_dir = None 37 | 38 | try: 39 | config = config_manager.load_config() 40 | vlib = next((v for v in config.virtual_libraries if v.id == library_id), None) 41 | if not vlib: 42 | logger.error(f"后台任务:配置中未找到 vlib {library_id}。") 43 | return 44 | 45 | # --- 不再需要自己获取用户ID,直接使用传入的 --- 46 | 47 | # --- 2. 通过内部请求调用 proxy-core 自身来获取项目 --- 48 | internal_proxy_url = f"http://localhost:8999/emby/Users/{user_id}/Items" 49 | 50 | # 使用传入的、保证正确的 api_key 51 | params = { 52 | "ParentId": library_id, 53 | "Limit": 100, 54 | "Fields": "ImageTags,ProviderIds", # 添加ProviderIds以备不时之需 55 | "Recursive": "true", 56 | "IncludeItemTypes": "Movie,Series,Video", 57 | "X-Emby-Token": api_key, 58 | "X-Emby-Client": "Proxy (AutoGen)", 59 | "X-Emby-Device-Name": "ProxyAutoGen", 60 | "X-Emby-Device-Id": "proxy-autogen-device-id", 61 | "X-Emby-Client-Version": "4.8.11.0", 62 | } 63 | 64 | internal_headers = { 65 | 'Accept': 'application/json', 66 | 'X-Emby-Authorization': f'Emby UserId="{user_id}", Client="Proxy (AutoGen)", Device="ProxyAutoGen", DeviceId="proxy-autogen-device-id", Version="4.8.11.0", Token="{api_key}"' 67 | } 68 | 69 | items = [] 70 | logger.info(f"后台任务:正在向内部代理 {internal_proxy_url} 请求项目...") 71 | try: 72 | async with aiohttp.ClientSession() as session: 73 | async with session.get(internal_proxy_url, params=params, headers=internal_headers, timeout=60) as response: 74 | if response.status == 200: 75 | items_dict = await response.json() 76 | if isinstance(items_dict, dict): items = items_dict.get("Items", []) 77 | else: 78 | logger.error(f"后台任务:内部代理请求失败,状态码: {response.status}, 响应: {await response.text()}") 79 | except Exception as e: 80 | logger.error(f"后台任务连接内部代理时出错: {e}") 81 | 82 | if not items: 83 | logger.warning(f"后台任务:根据虚拟库 '{vlib.name}' 的规则,未从代理获取到任何项目。") 84 | return 85 | 86 | items_with_images = [item for item in items if item.get("ImageTags", {}).get("Primary")] 87 | if not items_with_images: 88 | logger.warning(f"后台任务:获取到的 {len(items)} 个项目中,没有带主图的项目,无法为库 '{vlib.name}' 生成封面。") 89 | return 90 | 91 | selected_items = random.sample(items_with_images, min(9, len(items_with_images))) 92 | 93 | # --- 3. 下载图片 --- 94 | output_dir = Path("/app/config/images/") 95 | output_dir.mkdir(exist_ok=True) 96 | temp_dir = output_dir / f"temp_autogen_{library_id}" 97 | temp_dir.mkdir(exist_ok=True) 98 | 99 | async def download_image(session, item, index): 100 | image_url = f"{config.emby_url.rstrip('/')}/emby/Items/{item['Id']}/Images/Primary" 101 | download_headers = {'X-Emby-Token': api_key} # 使用正确的api_key 102 | try: 103 | async with session.get(image_url, headers=download_headers, timeout=20) as response: 104 | if response.status == 200: 105 | with open(temp_dir / f"{index}.jpg", "wb") as f: f.write(await response.read()) 106 | return True 107 | except Exception: return False 108 | return False 109 | 110 | async with aiohttp.ClientSession() as session: 111 | tasks = [download_image(session, item, i + 1) for i, item in enumerate(selected_items)] 112 | results = await asyncio.gather(*tasks) 113 | 114 | if not any(results): 115 | logger.error(f"后台任务:为库 {library_id} 下载封面素材失败。") 116 | return 117 | 118 | # --- 动态调用所选的默认样式 --- 119 | style_name = config.default_cover_style 120 | logger.info(f"后台任务:使用默认样式 '{style_name}' 为 '{vlib.name}' 生成封面...") 121 | 122 | try: 123 | style_module = importlib.import_module(f"cover_generator.{style_name}") 124 | create_function = getattr(style_module, f"create_{style_name}") 125 | except (ImportError, AttributeError) as e: 126 | logger.error(f"后台任务:无法加载样式 '{style_name}': {e}") 127 | return 128 | 129 | # 检查自定义字体路径,如果未设置则使用默认值 130 | zh_font_path = config.custom_zh_font_path or "/app/src/assets/fonts/multi_1_zh.ttf" 131 | en_font_path = config.custom_en_font_path or "/app/src/assets/fonts/multi_1_en.otf" 132 | 133 | kwargs = { 134 | "title": (vlib.name, ""), 135 | "font_path": (zh_font_path, en_font_path) 136 | } 137 | 138 | if style_name == 'style_multi_1': 139 | kwargs['library_dir'] = str(temp_dir) 140 | elif style_name in ['style_single_1', 'style_single_2']: 141 | main_image_path = temp_dir / "1.jpg" 142 | if not main_image_path.is_file(): 143 | logger.error(f"后台任务:无法找到用于单图模式的主素材图片 (1.jpg)。") 144 | return 145 | kwargs['image_path'] = str(main_image_path) 146 | else: 147 | logger.error(f"后台任务:未知的默认样式名称: {style_name}") 148 | return 149 | 150 | res_b64 = create_function(**kwargs) 151 | if not res_b64: 152 | logger.error(f"后台任务:为库 {library_id} 调用封面生成函数失败。") 153 | return 154 | 155 | image_data = base64.b64decode(res_b64) 156 | img = Image.open(BytesIO(image_data)).convert("RGB") 157 | final_path = output_dir / f"{library_id}.jpg" 158 | img.save(final_path, "JPEG", quality=90) 159 | 160 | new_image_tag = hashlib.md5(str(time.time()).encode()).hexdigest() 161 | current_config = config_manager.load_config() 162 | vlib_found_and_updated = False 163 | for vlib_in_config in current_config.virtual_libraries: 164 | if vlib_in_config.id == library_id: 165 | vlib_in_config.image_tag = new_image_tag 166 | vlib_found_and_updated = True 167 | break 168 | 169 | if vlib_found_and_updated: 170 | config_manager.save_config(current_config) 171 | logger.info(f"🎉 封面自动生成成功!已保存至 {final_path} 并更新了 config.json 的 ImageTag 为 {new_image_tag}") 172 | else: 173 | logger.error(f"自动生成封面后,无法在 config.json 中找到虚拟库 {library_id} 以更新 ImageTag。") 174 | 175 | except Exception as e: 176 | logger.error(f"封面自动生成后台任务发生未知错误: {e}", exc_info=True) 177 | finally: 178 | if temp_dir and temp_dir.exists(): 179 | shutil.rmtree(temp_dir) 180 | GENERATION_IN_PROGRESS.remove(library_id) 181 | logger.info(f"后台任务结束,已释放库 {library_id} 的生成锁。") 182 | -------------------------------------------------------------------------------- /src/proxy_handlers/handler_episodes.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_episodes.py (修改后) 2 | 3 | import asyncio 4 | import json 5 | import logging 6 | import re 7 | from fastapi import Request, Response 8 | from aiohttp import ClientSession 9 | from ._find_helper import find_all_series_by_tmdb_id, is_item_in_a_merge_enabled_vlib # <-- 导入新函数 10 | from config_manager import load_config 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | TMDB_API_BASE_URL = "https://api.themoviedb.org/3" 15 | 16 | EPISODES_PATH_REGEX = re.compile(r"/Shows/([a-f0-9\-]+)/Episodes") 17 | 18 | async def handle_episodes_merge(request: Request, full_path: str, session: ClientSession, real_emby_url: str) -> Response | None: 19 | match = EPISODES_PATH_REGEX.search(f"/{full_path}") 20 | season_id = request.query_params.get("SeasonId") 21 | if not match or not season_id: return None 22 | 23 | series_id_from_path = match.group(1) 24 | logger.info(f"EPISODES_HANDLER: 拦截到对剧集 {series_id_from_path} 下,季 {season_id} 的“集”请求。") 25 | 26 | params = request.query_params 27 | user_id = params.get("UserId") 28 | if not user_id: return Response(content="UserId not found", status_code=400) 29 | 30 | headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'} 31 | auth_token_param = {'X-Emby-Token': params.get('X-Emby-Token')} if 'X-Emby-Token' in params else {} 32 | 33 | # --- 【【【 新增的资格检查 】】】 --- 34 | should_merge = await is_item_in_a_merge_enabled_vlib( 35 | session, real_emby_url, user_id, series_id_from_path, headers, auth_token_param 36 | ) 37 | if not should_merge: 38 | return None 39 | # --- 资格检查结束 --- 40 | 41 | try: 42 | item_url = f"{real_emby_url}/emby/Users/{user_id}/Items/{series_id_from_path}" 43 | item_params = {'Fields': 'ProviderIds', **auth_token_param} 44 | async with session.get(item_url, params=item_params, headers=headers) as resp: 45 | tmdb_id = (await resp.json()).get("ProviderIds", {}).get("Tmdb") 46 | 47 | season_url = f"{real_emby_url}/emby/Users/{user_id}/Items/{season_id}" 48 | season_params = {'Fields': 'IndexNumber', **auth_token_param} 49 | async with session.get(season_url, params=season_params, headers=headers) as resp: 50 | target_season_number = (await resp.json()).get("IndexNumber") 51 | 52 | except Exception as e: 53 | logger.error(f"EPISODES_HANDLER: 获取TMDB ID或季号失败: {e}"); return None 54 | 55 | if not tmdb_id or target_season_number is None: return None 56 | logger.info(f"EPISODES_HANDLER: 找到TMDB ID: {tmdb_id},目标季号: {target_season_number}。") 57 | 58 | config = load_config() 59 | show_missing = config.show_missing_episodes 60 | tmdb_api_key = config.tmdb_api_key 61 | tmdb_proxy = config.tmdb_proxy 62 | 63 | original_series_ids = await find_all_series_by_tmdb_id(session, real_emby_url, user_id, tmdb_id, headers, auth_token_param) 64 | 65 | # 如果不显示缺失剧集,并且只有一个库,那么就没必要继续执行了 66 | if not show_missing and len(original_series_ids) < 2: 67 | return None 68 | 69 | logger.info(f"EPISODES_HANDLER: ✅ 找到 {len(original_series_ids)} 个关联剧集: {original_series_ids}。") 70 | 71 | async def fetch_episodes(series_id: str): 72 | seasons_url = f"{real_emby_url}/emby/Shows/{series_id}/Seasons" 73 | try: 74 | async with session.get(seasons_url, params=auth_token_param, headers=headers) as resp: 75 | seasons = (await resp.json()).get("Items", []) 76 | 77 | # 找到这个剧集里,与我们目标季号相同的那个季的ID 78 | matching_season = next((s for s in seasons if s.get("IndexNumber") == target_season_number), None) 79 | if not matching_season: return [] 80 | 81 | episodes_url = f"{real_emby_url}/emby/Shows/{series_id}/Episodes" 82 | episode_params = dict(params) 83 | episode_params["SeasonId"] = matching_season.get("Id") # 使用正确的季ID 84 | # 移除分页参数,以获取所有集 85 | episode_params.pop("Limit", None) 86 | episode_params.pop("StartIndex", None) 87 | 88 | async with session.get(episodes_url, params=episode_params, headers=headers) as resp: 89 | return (await resp.json()).get("Items", []) if resp.status == 200 else [] 90 | except Exception: return [] 91 | 92 | tasks = [fetch_episodes(sid) for sid in original_series_ids] 93 | all_episodes = [ep for sublist in await asyncio.gather(*tasks) for ep in sublist] 94 | 95 | merged_episodes = {} 96 | server_id = None # 用于存储 ServerId 97 | for episode in all_episodes: 98 | if not server_id: 99 | server_id = episode.get("ServerId") # 从一个真实的剧集中获取 ServerId 100 | key = episode.get("IndexNumber") 101 | if key is not None and key not in merged_episodes: 102 | merged_episodes[key] = episode 103 | 104 | if show_missing: 105 | logger.info(f"EPISODES_HANDLER: '显示缺失剧集' 已开启,开始从 TMDB 获取信息。") 106 | tmdb_episodes = await fetch_tmdb_episodes(session, tmdb_api_key, tmdb_id, target_season_number, tmdb_proxy) 107 | 108 | if tmdb_episodes: 109 | # 获取当前剧集信息以用于填充缺失剧集 110 | series_info_url = f"{real_emby_url}/emby/Users/{user_id}/Items/{series_id_from_path}" 111 | series_info_params = {**auth_token_param} 112 | async with session.get(series_info_url, params=series_info_params, headers=headers) as resp: 113 | series_info = await resp.json() 114 | 115 | for tmdb_episode in tmdb_episodes: 116 | episode_number = tmdb_episode.get("episode_number") 117 | if episode_number is not None and episode_number not in merged_episodes: 118 | missing_episode = { 119 | "Name": tmdb_episode.get("name"), 120 | "IndexNumber": episode_number, 121 | "SeasonNumber": target_season_number, 122 | "Id": f"tmdb_{tmdb_episode.get('id')}", 123 | "Type": "Episode", 124 | "IsFolder": False, 125 | "UserData": {"Played": False}, 126 | "SeriesId": series_id_from_path, 127 | "SeriesName": series_info.get("Name"), 128 | "SeriesPrimaryImageTag": series_info.get("ImageTags", {}).get("Primary"), 129 | "ImageTags": { 130 | "Primary": "placeholder" 131 | }, 132 | "PrimaryImageAspectRatio": 1.7777777777777777, 133 | # --- 【【【 新增的关键字段 】】】 --- 134 | "ServerId": server_id, 135 | "Overview": tmdb_episode.get("overview"), 136 | "PremiereDate": tmdb_episode.get("air_date"), 137 | } 138 | merged_episodes[episode_number] = missing_episode 139 | 140 | final_items = sorted(merged_episodes.values(), key=lambda x: x.get("IndexNumber", 0)) 141 | logger.info(f"EPISODES_HANDLER: 合并完成。合并前总数: {len(all_episodes)}, 合并后最终数量: {len(final_items)}") 142 | 143 | return Response(content=json.dumps({"Items": final_items, "TotalRecordCount": len(final_items)}), status_code=200, media_type="application/json") 144 | 145 | async def fetch_tmdb_episodes(session: ClientSession, api_key: str, tmdb_id: str, season_number: int, proxy: str | None = None): 146 | """从TMDB获取指定季的所有集信息""" 147 | if not api_key: 148 | logger.warning("TMDB_API_KEY not configured. Skipping TMDB fetch.") 149 | return [] 150 | 151 | url = f"{TMDB_API_BASE_URL}/tv/{tmdb_id}/season/{season_number}?api_key={api_key}&language=zh-CN" 152 | try: 153 | async with session.get(url, proxy=proxy) as response: 154 | if response.status == 200: 155 | data = await response.json() 156 | return data.get("episodes", []) 157 | else: 158 | logger.error(f"Error fetching TMDB season details: {response.status}") 159 | return [] 160 | except Exception as e: 161 | logger.error(f"Exception fetching TMDB season details: {e}") 162 | return [] 163 | -------------------------------------------------------------------------------- /src/rss_processor/douban.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import requests 4 | import logging 5 | from bs4 import BeautifulSoup 6 | from db_manager import DBManager, DOUBAN_CACHE_DB 7 | from .base_processor import BaseRssProcessor 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | # 豆瓣 API 的速率限制 12 | DOUBAN_API_RATE_LIMIT = 2 # 秒 13 | 14 | class DoubanProcessor(BaseRssProcessor): 15 | def __init__(self, vlib): 16 | super().__init__(vlib) 17 | self.douban_db = DBManager(DOUBAN_CACHE_DB) 18 | self.last_api_call_time = 0 19 | 20 | def _parse_source_ids(self, xml_content): 21 | """从 RSS XML 中解析出豆瓣 ID、标题和年份。如果ID不存在,则尝试从描述中解析。""" 22 | soup = BeautifulSoup(xml_content, 'xml') 23 | items_data = [] 24 | for item in soup.find_all('item'): 25 | link_tag = item.find('link') 26 | link = link_tag.text if link_tag else '' 27 | title_text = item.find('title').text 28 | 29 | douban_id_match = re.search(r'douban.com/subject/(\d+)', link) 30 | if not douban_id_match: 31 | douban_id_match = re.search(r'douban.com/doubanapp/dispatch/movie/(\d+)', link) 32 | 33 | if douban_id_match: 34 | douban_id = douban_id_match.group(1) 35 | 36 | # 尝试从标题中解析年份 37 | year_match = re.search(r'\((\d{4})\)', title_text) 38 | year = int(year_match.group(1)) if year_match else None 39 | 40 | # 清理标题 41 | title = re.sub(r'\(\d{4}\)', '', title_text).strip() 42 | 43 | items_data.append({ 44 | "id": douban_id, 45 | "title": title, 46 | "year": year 47 | }) 48 | else: 49 | # 如果没有豆瓣ID,尝试从 description 中解析 50 | logger.info(f"条目 '{title_text}' 缺少豆瓣ID,尝试从描述中解析。") 51 | description_tag = item.find('description') 52 | if not description_tag: 53 | logger.warning(f"条目 '{title_text}' 既无豆瓣ID也无描述,已跳过。") 54 | continue 55 | 56 | description_html = description_tag.text 57 | desc_soup = BeautifulSoup(description_html, 'html.parser') 58 | p_tags = desc_soup.find_all('p') 59 | 60 | year = None 61 | # 豆瓣RSSHub格式通常是:

标题

评分

年份 / 国家 / ...

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 |
8 | 点击展开/折叠更新日志 9 | 10 | --- 11 | 12 | ### 🚀 [1.5.0] - 2025-12-10 13 | - **新功能**: 14 | - **RSS 数据保留机制**: 15 | - 新增“开启数据保留”功能。针对 RSS 类型的虚拟库,现在可以选择保留即使已从源中消失的条目。 16 | - 实现了可配置的“保留天数”。用户可以指定条目在本地库中的最长存活时间(默认为 7 天,0 为永久保留),系统会自动清理过期的旧条目。 17 | - 此功能完美解决了因 RSS 源更新过快导致未入库条目被意外“顶出”和移除的问题。 18 | - **重构与优化**: 19 | - **Bangumi 匹配逻辑全面升级**: 20 | - 移除了对外部匹配 API 的依赖,转为完全本地化的智能匹配。 21 | - **多策略搜索**: 引入了 5 种优先级的搜索策略(包括精确名、去后缀名等),显著提高了搜索覆盖率。 22 | - **智能打分系统**: 新增了基于“文本相似度 + 动画类型 + 媒体格式 + 年份”的综合打分算法,确保匹配结果的精准度。 23 | - **数据库缓存**: 新增 `bangumi_cache.db`,对 API 响应和匹配结果进行持久化缓存,大幅减少网络请求并提升响应速度。 24 | 25 | --- 26 | 27 | ### 🚀 [1.4.9] - 2025-12-10 28 | - **重构与优化**: 29 | - **Bangumi 匹配逻辑全面升级**: 30 | - 移除了对外部匹配 API 的依赖,转为完全本地化的智能匹配。 31 | - **多策略搜索**: 引入了 5 种优先级的搜索策略(包括精确名、去后缀名等),显著提高了搜索覆盖率。 32 | - **智能打分系统**: 新增了基于“文本相似度 + 动画类型 + 媒体格式 + 年份”的综合打分算法,确保匹配结果的精准度。 33 | - **数据库缓存**: 新增 `bangumi_cache.db`,对 API 响应和匹配结果进行持久化缓存,大幅减少网络请求并提升响应速度。 34 | 35 | --- 36 | 37 | ### 🚀 [1.4.8] - 2025-09-07 38 | - **新功能**: 39 | - **增强 RSS 虚拟库**: 40 | - 新增“追加 TMDB ID”功能。现在,在编辑 RSS 虚拟库时,您可以额外指定一个 TMDB ID 和媒体类型。 41 | - 该指定的影视项目将会被无条件地追加到从 RSS 源解析出的条目列表中,一同在虚拟库中显示。 42 | - 此功能主要用于确保即使 RSS 源匹配失败或内容为空时,该虚拟库中也至少能显示一个您指定的项目。 43 | - **修复**: 44 | - **修复 RSS 处理器初始化错误**: 解决了因 `BaseRssProcessor` 基类构造函数变更后,未同步更新 `DoubanProcessor` 和 `BangumiProcessor` 子类而导致的 `TypeError`,确保 RSS 库刷新功能可正常使用。 45 | 46 | --- 47 | 48 | ### 🚀 [1.4.7] - 2025-09-03 49 | - **新功能**: 50 | - **增强高级筛选器 (动态日期)**: 51 | - 新增了对 `首播日期 (PremiereDate)` 的原生筛选支持。 52 | - 实现了真正的动态日期筛选功能。用户现在可以保存“最近30天内”这样的相对时间规则,筛选器会在每次执行时动态计算日期范围,而不仅仅是在设置时转换一次。 53 | - **优化**: 54 | - **优化**: 55 | - **提升用户体验**: 优化了“首播日期”筛选器的 UI,允许用户在绝对日期和相对时间之间自由切换,并支持手动输入任意天数(如“最近15天内”),提供了极高的灵活性。 56 | - **同步更新文档**: 在高级筛选器管理界面的“性能指南”中,同步更新了“高效筛选规则对照表”,加入了对新功能的支持说明。 57 | 58 | --- 59 | 60 | ### 🚀 [1.4.6] - 2025-09-01 61 | - **新功能**: 62 | - **增强高级筛选器**: 63 | - 大幅扩展了高级筛选器的能力,新增了对多个 Emby API 原生筛选参数的支持,包括 `影评人评分`、`名称以...开头`、`剧集状态`、`是否有字幕`、`是否有官方评级` 等。 64 | - 这使得用户可以创建更精细、更强大的筛选规则,并且由于更多规则可以直接被 Emby 服务器原生处理,筛选效率也将得到提升。 65 | - **优化**: 66 | - **同步更新性能指南**: 在高级筛选器管理界面的“性能指南”中,同步更新了“高效筛选规则对照表”,加入了所有新增的高效筛选规则及其用法说明,确保了文档与功能的一致性。 67 | 68 | --- 69 | 70 | ### 🚀 [1.4.5] - 2025-09-01 71 | - **优化**: 72 | - **实现资源选择无限滚动**: 对虚拟库编辑页面的“选择资源”下拉框(包括工作室、人员、类型等)进行了全面优化。现在,列表将随着您的滚动动态加载,极大地提升了拥有大量项目(如数千个工作室或演员)的用户的加载性能和使用流畅性。 73 | - **修复**: 74 | - **修复加载闪烁问题**: 解决了在滚动加载“人员”列表时,下拉框会短暂消失或闪烁的问题,确保了平滑的滚动加载体验。 75 | - **修复类型切换逻辑**: 修复了在对话框内切换资源类型(例如从“工作室”切换到“人员”)时,列表不会自动刷新的问题。 76 | 77 | --- 78 | 79 | ### 🚀 [1.4.4] - 2025-08-25 80 | - **修复**: 81 | - **增强高级筛选器准确性**: 修复了当高级筛选器中包含 `IsMovie: 'true'` 或 `IsSeries: 'true'` 规则时,Emby API 请求仍可能返回不符合类型内容的问题。现在,代理服务器会强制将 `IncludeItemTypes` 参数分别设置为 `Movie` 或 `Series`,确保筛选结果的准确性。 82 | 83 | --- 84 | 85 | ### 🚀 [1.4.3] - 2025-08-24 86 | - **新功能**: 87 | - **新增临时素材上传功能**: 88 | - 在虚拟库编辑页面的“封面生成”部分,新增了图片上传功能。 89 | - 用户现在可以临时上传最多9张本地图片,作为生成封面的素材。 90 | - 上传的图片将作为最高优先级的素材源。 91 | - **优化**: 92 | - **上传素材随机选择**: 当上传的图片数量超过封面样式所需时,程序会从中随机抽选,确保每次生成的封面都有所不同。 93 | 94 | --- 95 | 96 | ### 🚀 [1.4.2] - 2025-08-24 97 | - **新功能**: 98 | - **封面生成功能增强**: 99 | - **新增封面中文标题**: 在虚拟库编辑页面,现在可以为封面单独设置中文主标题,留空则默认使用虚拟库名称。 100 | - **新增虚拟库级自定义字体**: 在虚拟库编辑页面,可以为单个虚拟库指定不同于全局设置的自定义中英文字体。 101 | - **新增虚拟库级自定义图片目录**: 在虚拟库编辑页面,可以为单个虚拟库指定一个独立的图片文件夹,封面生成器将从该目录中抓取图片素材。 102 | - **新增全局自定义图片目录**: 103 | - 在“系统设置”页面,新增了“全局自定义图片目录”选项。 104 | - 此目录将作为虚拟库未指定自定义图片目录时的“后备”或“默认”图片源。 105 | 106 | --- 107 | 108 | ### 🚀 [1.4.1] - 2025-08-23 109 | - **新功能**: 110 | - **新增封面生成自定义字体选项**: 111 | - 在“系统设置”页面,新增了“自定义中文字体路径”和“自定义英文字体路径”的选项。 112 | - 用户现在可以指定在 Docker 容器内的字体文件绝对路径,用于生成包含自定义字体的封面。 113 | - 如果不填写,系统将自动使用内置的默认字体。 114 | 115 | --- 116 | 117 | ### 🚀 [1.4.0] - 2025-08-23 118 | - **重构与增强**: 119 | - **重构 RSS 处理器**: 120 | - 对 `rss_processor` 模块进行了彻底重构,将 `douban.py` 和 `bangumi.py` 中的通用逻辑(如RSS获取、Emby库匹配、TMDB信息缓存等)提取到一个新的 `base_processor.py` 基类中。 121 | - 此举极大地简化了代码,提高了代码复用性,并为未来支持更多类型的 RSS 源奠定了坚实的基础。 122 | - **新增通用兜底匹配方案**: 123 | - 为 RSS 处理器增加了一个强大的兜底匹配机制。现在,当通过源站 ID(如豆瓣 ID)的精确匹配失败时,系统会自动尝试使用项目的标题和年份在 TMDB 上进行搜索匹配。 124 | - 这一改进将显著提高 RSS 虚拟库中项目的 TMDB ID 匹配成功率。 125 | 126 | --- 127 | 128 | ### 🚀 [1.3.7] - 2025-08-22 129 | - **新功能**: 130 | - **新增“全局强制按 TMDB ID 合并”功能**: 131 | - 在“系统设置”页面增加了一个全局开关。 132 | - 启用后,此开关将覆盖所有虚拟库的独立设置,强制对所有媒体内容执行 TMDB ID 合并。 133 | - 这为希望在整个媒体库中统一合并策略的用户提供了极大的便利。 134 | 135 | --- 136 | 137 | ### 🚀 [1.3.6] - 2025-08-21 138 | - **新功能**: 139 | - **新增“RSS”虚拟库类型 (目前仅支持豆瓣)**: 140 | - 在创建虚拟库时,新增了“RSS”作为资源类型。此功能允许您将一个 **RSSHub 生成的豆瓣订阅源**(如“想看”、“在看”、“看过”列表)映射为一个动态更新的媒体库。 141 | - **混合内容展示**: 虚拟库会自动区分 RSS 源中的项目哪些已在您的 Emby 库中,哪些尚未入库。 142 | - **占位符生成**: 对于尚未入库的项目,代理会利用 TMDB API 获取其元数据(海报、简介、年份等),并动态生成一个“占位符”项目。这使您可以在 Emby 中直观地浏览和管理您的“待看”清单。 143 | - **手动刷新**: 您可以在虚拟库管理页面随时手动刷新 RSS 源,以同步最新内容。 144 | - **重要说明**: 145 | - **数据源**: 当前版本**仅支持**解析通过 [RSSHub](https://docs.rsshub.app/) 生成的**豆瓣**相关订阅链接。 146 | - **依赖**: 此功能需要正确配置“TMDB API 密钥”才能为未入库的项目生成占位符。 147 | 148 | --- 149 | 150 | ### 🚀 [1.3.5] - 2025-08-19 151 | - **新功能**: 152 | - **新增“显示缺失剧集”功能**: 153 | - 在“系统设置”中增加了一个“显示缺失的剧集”开关。 154 | - 启用后,当您浏览电视剧的季页面时,代理服务器会自动通过 TMDB API 查询该季的完整剧集列表。 155 | - 将查询结果与您本地库中已有的剧集进行对比,并将缺失的剧集动态注入到显示列表中。 156 | - 这使您可以直观地看到哪些剧集尚未收藏,方便补全。 157 | - **新增 TMDB API Key 设置**: 158 | - 为了支持上述功能,在“系统设置”中增加了“TMDB API 密钥”的配置项。您需要填入自己申请的有效密钥。 159 | - **支持为缺失剧集自定义占位图**: 160 | - 所有通过此功能动态添加的缺失剧集,都会显示一个统一的占位图。 161 | - 您可以通过替换项目路径 `src/assets/images_placeholder/placeholder.jpg` 下的图片文件,来轻松自定义您喜欢的占位图样式(推荐使用16:9比例的图片)。 162 | - **新增 TMDB HTTP 代理设置**: 163 | - 在“系统设置”中增加了“TMDB HTTP 代理”选项。 164 | - 如果您的服务器无法直接访问 The Movie Database,现在可以配置一个 HTTP 代理来确保网络通畅。 165 | - **修复**: 166 | - **修复缺失剧集无法显示的问题**: 解决了因构造的缺失剧集数据对象缺少 `ServerId`, `Overview`, `PremiereDate` 等关键字段,而导致 Emby/Jellyfin 客户端拒绝渲染这些项目的问题。 167 | 168 | --- 169 | 170 | ### 🚀 [1.3.4] - 2025-08-18 171 | - **新功能**: 172 | - **新增“全库”虚拟库类型**: 在创建虚拟库时,新增了“全库 (All Libraries)”作为资源类型。选择此类型后,虚拟库将包含所有媒体库的内容,可配合高级筛选器实现对整个 Emby 媒体资源的灵活筛选。 173 | - **修复**: 174 | - **修复“全库”类型无法保存的问题**: 调整了前端验证逻辑,允许在资源类型为“全库”时,无需指定具体的资源 ID 即可保存。 175 | - **修正“全库”类型在首页的“最新”栏目显示**: 176 | - 修复了当虚拟库类型为“全库”时,首页“最新”项目请求逻辑不正确的问题,确保能够正确展示所有媒体库的最新内容。 177 | - 通过强制筛选媒体类型,解决了“最新”栏目中错误地显示其他虚拟库(而非实际影视项目)的问题。 178 | 179 | --- 180 | 181 | ### 🚀 [1.3.3] - 2025-08-16 182 | - **重构**: 183 | - **移除访问控制**: 删除了之前版本中添加的密码保护和 API 密钥白名单功能。此功能与项目核心目标(增强媒体库管理)关联不大,且增加了不必要的复杂性。 184 | - **前端**: 从系统设置页面移除了相关配置项。 185 | - **后端**: 删除了 `handler_auth.py` 认证模块,并更新了 `proxy_server.py` 和 `models.py` 以移除所有相关逻辑和配置。 186 | 187 | --- 188 | 189 | ### 🚀 [1.3.2] - 2025-08-16 190 | - **新功能**: 191 | - **新增访问控制**: 为整个代理服务增加了可选的密码保护和 API 密钥白名单功能。 192 | - **密码保护**: 可在配置文件中设置密码,启用后,通过浏览器访问将需要输入密码进行验证。 193 | - **API密钥白名单**: 可在配置文件中设置一组受信的 Emby API 密钥,只有使用这些密钥的客户端(如 Infuse, Jellyfin APP等)才能访问,增强了安全性。 194 | - **IP信任机制**: 客户端通过验证后,其 IP 地址将被临时信任24小时,避免了重复验证。 195 | - **修复**: 彻底解决了因多种原因导致的视频播放和字幕加载失败问题,大幅提升了代理的稳定性和兼容性。 196 | - **健壮性**: 移除了实验性的 `PlaybackInfo` 拦截逻辑。该逻辑在处理部分客户端或 Emby 版本时不够稳定,是导致播放失败的潜在原因之一。现在代理将直接、可靠地转发所有播放信令。 197 | - **兼容性**: 解决了因 Emby 服务端启用 Brotli 压缩而代理服务器缺少相应解码支持的问题。通过在项目中添加 `Brotli` 依赖库,确保能正确处理各类压缩数据,消除了由此引发的 `502 Bad Gateway` 错误。 198 | 199 | --- 200 | 201 | ### [1.3.1] - 2025-08-11 202 | - **修复**: 解决了更新已有封面的虚拟库(如修改高级筛选器)后,会导致封面信息丢失的问题。现在,在保存虚拟库设置时,程序会正确保留其 `ImageTag`。 203 | - **修复**: 解决了启用“TMDB ID合并”功能时,因错误地在分页后的部分数据上执行合并而导致项目总数计算不正确的问题。现在,程序会先获取所有相关项目,在完整数据集上执行合并后,再进行分页,确保了项目总数的准确性。 204 | 205 | --- 206 | 207 | ### 🏗️ [1.3.0] - 2025-08-10 208 | - **架构升级**: 209 | - **部署模式简化**: 将原有的 `admin` 和 `proxy` 双容器架构,重构为使用 `supervisor` 管理的单容器架构。 210 | - **简化部署**: 更新了 `docker-compose.yml`,现在只需管理单个服务,部署和维护流程更简单。 211 | - **文档同步**: 同步更新了 `README.md` 中的快速开始指南,以匹配新的单容器部署模式。 212 | - **新功能**: 213 | - **一键清空封面**: 在“系统设置”中新增“清空所有本地封面”功能,方便用户一键删除所有已生成的封面并重置状态。 214 | 215 | --- 216 | 217 | ### ✨ [1.2.0] - 2025-08-10 218 | - **新功能**: 219 | - **多种封面样式**: 手动生成封面时,现在可以在三种不同的内置样式(一种多图、两种单图)中自由选择。 220 | - **全局默认样式**: 在“系统设置”中新增了“自动生成封面默认样式”选项,用于控制自动触发的封面生成所使用的样式,并会持久化保存。 221 | - **修复与优化**: 222 | - **修复封面生成器**: 解决了单图样式因参数不匹配而无法生成的问题,确保所有样式都能正常工作。 223 | - **优化UI/UX**: 224 | - 修复了亮色模式下“夜间模式”切换按钮几乎不可见的问题。 225 | - 在封面生成弹窗中增加了必要的操作说明,优化了用户体验。 226 | - 将UI中的“收藏夹”统一修正为“合集”,使其更符合 Emby/Jellyfin 的通用术语。 227 | 228 | --- 229 | 230 | ### 🚀 [1.1.0] - 2025-08-10 231 | - **增强兼容性**: 232 | - **新增非标准客户端兼容模式**: 针对部分行为特殊的第三方播放器(如某些版本的网易爆米花、Infuse 等),增加了后备处理方案。现在,即使客户端不按标准流程请求媒体库,也能正确识别并展示虚拟库。 233 | - **统一认证头转发**: 全面审查并统一了所有API处理器的请求头转发逻辑,确保 `X-Emby-Token` 等关键认证信息在所有情况下都能被正确传递,彻底解决 `401 Unauthorized` 错误。 234 | - **修复**: 235 | - **修正 `/Items/Latest` 响应格式**: 修复了“最近添加”接口返回的数据被错误包装在JSON对象中的问题。现在接口会直接返回客户端预期的JSON数组,解决了部分客户端无法加载首页最新项目的错误。 236 | 237 | --- 238 | 239 | ### 🎉 [1.0.0] - 2025-08-09 240 | - **项目首次发布**: 部署 Emby Virtual Proxy 初始版本。 241 | - **核心功能**: 242 | - 实现虚拟媒体库、高级内容过滤与聚合。 243 | - 支持为虚拟库自动生成风格化封面。 244 | - **管理后台**: 提供基于 Vue.js 的现代化 Web UI 用于全部功能配置。 245 | - **容器化**: 支持通过 Docker 和 Docker Compose 进行快速、一键式部署。 246 | 247 | --- 248 | 249 |
250 | 251 | ## 🚀 快速开始 (Docker Compose) 252 | 253 | 1. 在您的服务器上创建一个目录,例如 `emby-proxy`。 254 | 2. 在该目录下,创建一个名为 `docker-compose.yml` 的文件。 255 | 3. 将以下内容复制并粘贴到 `docker-compose.yml` 文件中: 256 | 257 | ```yaml 258 | version: '3.8' 259 | 260 | services: 261 | emby-proxy: 262 | image: pipi20xx/emby-virtual-proxy 263 | container_name: emby-proxy 264 | ports: 265 | # 管理后台端口,左边为主机端口,右边为容器端口 266 | - "8011:8001" 267 | # 代理核心端口,左边为主机端口,右边为容器端口 268 | - "8999:8999" 269 | volumes: 270 | # 挂载配置文件和生成的封面目录,确保数据持久化 271 | - ./config:/app/config 272 | # 挂载Docker sock,允许后台通过API重启服务 273 | - /var/run/docker.sock:/var/run/docker.sock 274 | environment: 275 | # 环境变量:告诉管理服务要重启的容器名(即自身) 276 | - PROXY_CONTAINER_NAME=emby-proxy 277 | # 环境变量:告诉管理服务如何访问同一容器内的代理服务 278 | - PROXY_CORE_URL=http://localhost:8999 279 | restart: unless-stopped 280 | ``` 281 | 282 | 4. 在 `docker-compose.yml` 文件所在的目录中,运行以下命令启动服务: 283 | ```bash 284 | docker-compose up -d 285 | ``` 286 | 287 | 5. 部署成功后: 288 | - **访问管理后台**: `http://<您的服务器IP>:8011` 289 | - **在Emby中配置代理**: 将Emby客户端(如Infuse, Emby Web)的服务器地址改为 `http://<您的服务器IP>:8999` 290 | 291 | --- 292 | 293 | ## ✨ 核心功能 294 | 295 | ### 1. 虚拟媒体库 (Virtual Libraries) 296 | - **动态创建**: 您可以创建任意数量的“虚拟媒体库”,这些库并不在 Emby 服务器上真实存在。 297 | - **内容聚合**: 虚拟库的内容可以基于 Emby 中已有的元数据动态生成,支持的源类型包括: 298 | - **合集 (Collections)** 299 | - **标签 (Tags)** 300 | - **类型 (Genres)** 301 | - **工作室 (Studios)** 302 | - **演职人员 (Persons)** 303 | - **应用场景**: 轻松创建如 “漫威电影宇宙”、“周星驰作品集”、“豆瓣Top250” 等完全自定义的媒体库,并让它们像真实库一样展示在主页上。 304 | 305 | ### 2. 高级内容过滤与聚合 (Advanced Filtering & Merging) 306 | - **TMDB ID 合并**: 自动将在不同资料库中但拥有相同 `TheMovieDb.org ID` 的电影或剧集进行聚合。当您在“最近添加”或媒体库视图中浏览时,将只看到一个条目,有效解决版本重复(如 1080p 和 4K 版本)的问题。 307 | - **高级过滤规则**: 提供了一个强大的规则引擎,允许您组合多个复杂的条件来过滤媒体内容,实现 Emby 原生无法做到的精确筛选。 308 | 309 | ### 3. 自定义封面生成 (Custom Cover Generation) 310 | - **自动化海报**: 可为创建的虚拟媒体库一键生成风格化的海报封面。 311 | - **智能素材抓取**: 该功能会自动从虚拟库中随机选取部分影视项目的现有封面作为素材。 312 | - **高度自定义**: 将抓取的素材智能拼接成一张精美的海报,并允许您添加自定义的中英文标题。 313 | 314 | ### 4. 现代化Web管理后台 (Modern Web Admin UI) 315 | - **一站式管理**: 项目内置一个基于 Vue.js 和 Element Plus 的美观、易用的网页管理界面。 316 | - **功能全面**: 您可以在此UI上完成所有配置和管理工作,包括: 317 | - 系统设置(连接 Emby 服务器、API 密钥等)。 318 | - 创建、编辑、删除虚拟库和高级过滤规则。 319 | - 通过拖拽调整虚拟库和真实库在 Emby 主页的显示顺序。 320 | - 手动触发封面生成、清除代理缓存等维护操作。 321 | 322 | ### 5. 容器化与易于部署 (Docker Ready) 323 | - **开箱即用**: 项目提供完整的 `Dockerfile` 和 `docker-compose.yml` 文件,支持使用 Docker 进行一键部署。 324 | - **服务分离**: 采用双容器架构(代理核心服务 + 管理后台服务),结构清晰,易于维护。 325 | - **API控制**: 管理后台可以通过 Docker API 直接控制代理核心,实现如“重启服务以清空缓存”等高级操作。 326 | 327 | --- 328 | 329 | ## 🛠️ 技术架构 330 | 331 | ## 📱 关于客户端与播放器兼容性 332 | 333 | 目前所有客户端都兼容个人只测试了小幻,yamby,EMBY小秘,Forward,senplay如果遇到不兼容请提出并留下客户端名称 334 | 335 | - **后端 (Backend)**: 336 | - **框架**: Python `FastAPI` 337 | - **异步处理**: `aiohttp` 用于与 Emby 服务器进行高性能的异步HTTP通信。 338 | - **缓存**: `cachetools` 用于实现内存缓存,加速API响应。 339 | - **前端 (Frontend)**: 340 | - **框架/构建**: `Vue.js 3` + `Vite` 341 | - **状态管理**: `Pinia` 342 | - **UI 组件库**: `Element Plus` 343 | 344 | --- 345 | 346 | ## 🙏 致谢 347 | 348 | 本项目的设计和功能受到了以下优秀项目的启发,特此感谢: 349 | 350 | - **[EkkoG/emby-virtual-lib](https://github.com/EkkoG/emby-virtual-lib)** 351 | - **[justzerock/MoviePilot-Plugins](https://github.com/justzerock/MoviePilot-Plugins/tree/8ef476f9e5ae4d3d549300bad74083c084a46f1d)** 352 | - **[HappyQuQu/jellyfin-library-poster](https://github.com/HappyQuQu/jellyfin-library-poster)** 353 | --- 354 | 355 | 总而言之,**Emby Virtual Proxy** 是一个为 Emby 高级玩家和收藏家设计的强大工具,它通过“代理”这一巧妙的方式,无侵入性地为您的 Emby 带来了前所未有的灵活性和可定制性。 356 | -------------------------------------------------------------------------------- /src/rss_processor/bangumi.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import logging 4 | import difflib 5 | import datetime 6 | import requests 7 | from bs4 import BeautifulSoup 8 | from .base_processor import BaseRssProcessor 9 | from db_manager import DBManager, BANGUMI_CACHE_DB 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | class BangumiProcessor(BaseRssProcessor): 14 | def __init__(self, vlib): 15 | super().__init__(vlib) 16 | self.bangumi_db = DBManager(BANGUMI_CACHE_DB) 17 | 18 | # 定义去后缀的正则列表 (编译一次以提高效率) 19 | self.truncation_patterns = [ 20 | re.compile(r'\s*第[一二三四五六七八九十\d]+[季期].*$', re.IGNORECASE), 21 | re.compile(r'\s*Season\s*\d+.*$', re.IGNORECASE), 22 | re.compile(r'\s*Part\s*\d+.*$', re.IGNORECASE), 23 | re.compile(r'\s*Cour\s*\d+.*$', re.IGNORECASE), 24 | re.compile(r'\s*-\s*season\s*\d+.*$', re.IGNORECASE), 25 | ] 26 | 27 | def _parse_source_ids(self, xml_content): 28 | """从 RSS XML 中解析出 Bangumi ID、标题和年份""" 29 | soup = BeautifulSoup(xml_content, 'xml') 30 | items_data = [] 31 | for item in soup.find_all('item'): 32 | link = item.find('link').text 33 | title_text = item.find('title').text 34 | 35 | bangumi_id_match = re.search(r'bangumi\.tv/subject/(\d+)', link) 36 | if not bangumi_id_match: 37 | bangumi_id_match = re.search(r'bgm\.tv/subject/(\d+)', link) 38 | 39 | if bangumi_id_match: 40 | bangumi_id = bangumi_id_match.group(1) 41 | 42 | # Bangumi 的 RSS 通常不含年份,这里设为 None 43 | items_data.append({ 44 | "id": bangumi_id, 45 | "title": title_text.strip(), 46 | "year": None 47 | }) 48 | logger.info(f"从 RSS 源中找到 {len(items_data)} 个 Bangumi 条目。") 49 | return items_data 50 | 51 | def _get_tmdb_info(self, source_info): 52 | """ 53 | 根据 Bangumi ID 获取 TMDB 信息。 54 | 流程: 查库 -> (未命中) -> Fetch Metadata -> Gen Strategies -> Search & Score -> Save -> Return 55 | """ 56 | bangumi_id = source_info['id'] 57 | 58 | # 1. 检查数据库映射 59 | existing = self.bangumi_db.fetchone( 60 | "SELECT tmdb_id, media_type, score FROM bangumi_tmdb_mapping WHERE bangumi_id = ?", 61 | (bangumi_id,) 62 | ) 63 | if existing: 64 | logger.info(f"Bangumi ID {bangumi_id}: 命中缓存映射 -> TMDB ID {existing['tmdb_id']} ({existing['media_type']}), Score: {existing['score']}") 65 | return [(existing['tmdb_id'], existing['media_type'])] 66 | 67 | # 2. 执行匹配逻辑 68 | logger.info(f"Bangumi ID {bangumi_id}: 未找到缓存,开始智能匹配流程...") 69 | match_result = self._process_bangumi_match(bangumi_id) 70 | 71 | if match_result: 72 | tmdb_id, tmdb_type, score, method = match_result 73 | # 3. 保存结果 74 | self.bangumi_db.execute( 75 | "INSERT OR REPLACE INTO bangumi_tmdb_mapping (bangumi_id, tmdb_id, media_type, match_method, score) VALUES (?, ?, ?, ?, ?)", 76 | (bangumi_id, tmdb_id, tmdb_type, method, score), 77 | commit=True 78 | ) 79 | logger.info(f"Bangumi ID {bangumi_id}: 匹配成功并保存 -> TMDB ID {tmdb_id} ({tmdb_type}) [Score: {score:.2f}]") 80 | return [(tmdb_id, tmdb_type)] 81 | else: 82 | logger.warning(f"Bangumi ID {bangumi_id}: 所有策略尝试完毕,未找到符合阈值的 TMDB 匹配。") 83 | return [] 84 | 85 | def _process_bangumi_match(self, bangumi_id): 86 | """核心匹配流程控制器""" 87 | # Step 1: 获取 Bangumi 元数据 88 | metadata = self._fetch_bangumi_metadata(bangumi_id) 89 | if not metadata: 90 | return None 91 | 92 | # Step 2: 生成搜索策略 93 | strategies = self._generate_search_strategies(metadata) 94 | 95 | # Step 3: 执行搜索与打分 96 | best_match = None 97 | best_score = 0 98 | THRESHOLD = 7.5 99 | 100 | for priority, (query, year, strategy_name) in enumerate(strategies, 1): 101 | logger.info(f" [策略 {priority}] {strategy_name}: 搜索 '{query}' (Year: {year})") 102 | 103 | candidates = self._search_tmdb_multi(query, year) 104 | if not candidates: 105 | logger.debug(f" - 无结果,跳过。") 106 | continue 107 | 108 | for candidate in candidates: 109 | score = self._calculate_score(candidate, metadata, query) 110 | logger.debug(f" - 候选 '{candidate.get('title') or candidate.get('name')}' ({candidate.get('id')}): {score:.2f}分") 111 | 112 | if score > best_score: 113 | best_score = score 114 | best_match = candidate 115 | best_match['match_method'] = strategy_name 116 | 117 | # 如果超过阈值,直接锁定胜局 (Early Exit) 118 | if score >= THRESHOLD: 119 | logger.info(f" >>> 触发阈值 ({score:.2f} >= {THRESHOLD}),锁定匹配。") 120 | return (str(best_match['id']), best_match['media_type'], score, strategy_name) 121 | 122 | # 如果循环结束还没有触发阈值,但有最高分结果(可选:根据需求决定是否要强制阈值。这里严格遵循“所有策略耗尽->匹配失败”如果不过阈值) 123 | # 但通常如果分数太低(比如<4),可能完全不对。 124 | # 逻辑描述说:"如果所有策略尝试完都没有结果超过阈值,则判定为匹配失败。" 125 | return None 126 | 127 | def _fetch_bangumi_metadata(self, bangumi_id): 128 | """获取 Bangumi 条目详情 (带缓存)""" 129 | # 查缓存 130 | cached = self.bangumi_db.fetchone("SELECT api_response FROM bangumi_api_cache WHERE bangumi_id = ?", (bangumi_id,)) 131 | if cached: 132 | return json.loads(cached['api_response']) 133 | 134 | # 调 API 135 | url = f"https://api.bgm.tv/v0/subjects/{bangumi_id}" 136 | headers = {"User-Agent": "emby-virtual-proxy/1.0 (https://github.com/orgs/bangumi/repositories)"} 137 | try: 138 | resp = requests.get(url, headers=headers, timeout=10) 139 | if resp.status_code == 404: 140 | logger.warning(f"Bangumi API 404: ID {bangumi_id} 不存在。") 141 | return None 142 | resp.raise_for_status() 143 | data = resp.json() 144 | 145 | # 存缓存 146 | self.bangumi_db.execute( 147 | "INSERT OR REPLACE INTO bangumi_api_cache (bangumi_id, api_response) VALUES (?, ?)", 148 | (bangumi_id, json.dumps(data, ensure_ascii=False)), 149 | commit=True 150 | ) 151 | return data 152 | except Exception as e: 153 | logger.error(f"Bangumi API 请求失败 ({bangumi_id}): {e}") 154 | return None 155 | 156 | def _generate_search_strategies(self, metadata): 157 | """生成 5 种搜索策略""" 158 | name_cn = metadata.get('name_cn') or "" 159 | name_original = metadata.get('name') or "" 160 | date_str = metadata.get('date') or "" 161 | air_year = date_str.split('-')[0] if date_str else None 162 | 163 | strategies = [] 164 | 165 | # Helper to clean suffixes 166 | def clean_suffix(text): 167 | if not text: return "" 168 | cleaned = text 169 | for pattern in self.truncation_patterns: 170 | cleaned = pattern.sub('', cleaned) 171 | return cleaned.strip() 172 | 173 | # 策略 1: 精确中文名 + 年份 174 | if name_cn and air_year: 175 | strategies.append((name_cn, air_year, "ExactCN_Year")) 176 | 177 | # 策略 2: 精确原名 + 年份 178 | if name_original and air_year: 179 | strategies.append((name_original, air_year, "ExactOrigin_Year")) 180 | 181 | # 策略 3: 基础中文名 (去后缀) + 年份 182 | base_cn = clean_suffix(name_cn) 183 | if base_cn and base_cn != name_cn and air_year: 184 | strategies.append((base_cn, air_year, "BaseCN_Year")) 185 | 186 | # 策略 4: 基础原名 (去后缀) + 年份 187 | base_origin = clean_suffix(name_original) 188 | if base_origin and base_origin != name_original and air_year: 189 | strategies.append((base_origin, air_year, "BaseOrigin_Year")) 190 | 191 | # 策略 5: 基础中文名 (无年份,作为保底) 192 | if base_cn: 193 | strategies.append((base_cn, None, "BaseCN_NoYear")) 194 | elif name_cn: 195 | strategies.append((name_cn, None, "ExactCN_NoYear")) 196 | 197 | return strategies 198 | 199 | def _search_tmdb_multi(self, query, year=None): 200 | """调用 TMDB /search/multi 接口""" 201 | if not self.config.tmdb_api_key: 202 | logger.error("未配置 TMDB API Key,无法搜索。") 203 | return [] 204 | 205 | url = "https://api.themoviedb.org/3/search/multi" 206 | params = { 207 | "api_key": self.config.tmdb_api_key, 208 | "query": query, 209 | "language": "zh-CN", 210 | "include_adult": "false" 211 | } 212 | 213 | # TMDB API 对 multi search 的年份过滤参数稍微有点特殊 214 | # 对于 movie 是 release_year, 对于 tv 是 first_air_date_year 215 | # 但 search/multi 并不总是完美支持单一参数过滤两者, 216 | # 不过我们可以先搜出来,然后在代码里也可以二次验证。 217 | # 实际上 search/multi 并没有统一的 'year' 参数,通常不传,或者靠打分阶段的年份匹配来降权/加权。 218 | # 但为了遵循“策略”,我们可以在 API 调用层面不传 year (因为 multi 接口对混合类型的 year 支持一般), 219 | # 或者尝试传 'year' (对 movie 有效) 和 'first_air_date_year' (对 tv 有效)。 220 | # 简单起见,这里不传 API 的 year 参数,依靠 query 召回,然后在 score 阶段验证年份? 221 | # 不,策略里明确说了 "+ 2023",这通常意味着在 query 里不包含 2023,而是作为过滤条件。 222 | # 让我们检查 TMDB 文档。Multi search 只有 `query`, `page`, `include_adult`, `language`, `region`. 223 | # 它 *没有* year 参数。 224 | # 所以必须在收到结果后,在代码层面过滤或打分。 225 | # 更新:Specific search (search/movie, search/tv) 有 year 参数。 226 | # 既然逻辑要求 "Search Strategies" 里带年份,最好的办法是搜索后在内存里优先匹配该年份。 227 | # 或者,如果结果太多,我们可能无法全部获取。 228 | # 这里我们按 query 搜,然后在 _calculate_score 里如果不匹配年份则大幅扣分或不加分。 229 | 230 | proxies = {"http": self.config.tmdb_proxy, "https": self.config.tmdb_proxy} if self.config.tmdb_proxy else None 231 | 232 | try: 233 | resp = requests.get(url, params=params, proxies=proxies, timeout=10) 234 | resp.raise_for_status() 235 | data = resp.json() 236 | return data.get('results', []) 237 | except Exception as e: 238 | logger.error(f"TMDB Search Error (Query: {query}): {e}") 239 | return [] 240 | 241 | def _calculate_score(self, tmdb_item, bgm_metadata, query_used): 242 | """ 243 | 智能打分系统 244 | @param tmdb_item: TMDB 返回的单个结果对象 245 | @param bgm_metadata: Bangumi 元数据 246 | @param query_used: 搜索使用的关键词 (用于计算文本相似度) 247 | """ 248 | score = 0.0 249 | 250 | # 提取 TMDB 信息 251 | media_type = tmdb_item.get('media_type') 252 | if media_type not in ['tv', 'movie']: 253 | return 0 # 忽略 person 等其他类型 254 | 255 | title = tmdb_item.get('title') if media_type == 'movie' else tmdb_item.get('name') 256 | original_title = tmdb_item.get('original_title') if media_type == 'movie' else tmdb_item.get('original_name') 257 | 258 | date_str = tmdb_item.get('release_date') if media_type == 'movie' else tmdb_item.get('first_air_date') 259 | tmdb_year = date_str.split('-')[0] if date_str else None 260 | 261 | bgm_year = None 262 | if bgm_metadata.get('date'): 263 | bgm_year = bgm_metadata.get('date').split('-')[0] 264 | 265 | # 1. 基础分: 文本相似度 (0-10) 266 | # 比较 query 和 (title, original_title) 中相似度较高者 267 | sim_title = difflib.SequenceMatcher(None, query_used.lower(), (title or "").lower()).ratio() 268 | sim_orig = difflib.SequenceMatcher(None, query_used.lower(), (original_title or "").lower()).ratio() 269 | base_score = max(sim_title, sim_orig) * 10 270 | score += base_score 271 | 272 | # 2. 动画类型奖励 (+5) 273 | # TMDB Genre ID 16 = Animation 274 | genre_ids = tmdb_item.get('genre_ids', []) 275 | if 16 in genre_ids: 276 | score += 5.0 277 | 278 | # 3. 媒体格式奖励 (+3) 279 | # Bangumi type: 1=Book, 2=Anime, 3=Music, 4=Game, 6=Real (Three-dimension) 280 | # Platform 字段更详细: TV, Web, OVA, Movie, etc. 281 | bgm_platform = bgm_metadata.get('platform', '').lower() 282 | 283 | is_bgm_movie = bgm_platform in ['movie', 'theater', '剧场版'] 284 | is_tmdb_movie = media_type == 'movie' 285 | 286 | # 简单判定:如果是 Movie 对 Movie -> 加分 287 | # 如果是 TV/Web/OVA 对 TV -> 加分 288 | if is_bgm_movie and is_tmdb_movie: 289 | score += 3.0 290 | elif (not is_bgm_movie) and (not is_tmdb_movie): # Assume TV 291 | score += 3.0 292 | 293 | # 4. (额外) 年份惩罚/过滤 294 | # 虽然逻辑说明里没明确写“年份不匹配扣分”,但“策略”里包含了年份。 295 | # 如果策略包含年份,而结果年份不匹配,理应降低相关性。 296 | # 简单的做法:如果年份差超过 1 年,扣 2 分;如果完全匹配,加 1 分。 297 | if tmdb_year and bgm_year: 298 | try: 299 | diff = abs(int(tmdb_year) - int(bgm_year)) 300 | if diff == 0: 301 | score += 1.0 # 年份完美匹配奖励 302 | elif diff > 1: 303 | score -= 2.0 # 年份严重不符惩罚 304 | except: 305 | pass 306 | 307 | return score -------------------------------------------------------------------------------- /src/proxy_handlers/handler_items.py: -------------------------------------------------------------------------------- 1 | # src/proxy_handlers/handler_items.py (高性能重构版) 2 | 3 | import logging 4 | import json 5 | from fastapi import Request, Response 6 | from aiohttp import ClientSession 7 | from models import AppConfig, AdvancedFilter 8 | from typing import List, Any, Dict 9 | 10 | from . import handler_merger, handler_views 11 | # 导入我们新的翻译器和旧的后筛选逻辑 12 | from ._filter_translator import translate_rules 13 | from .handler_rss import RssHandler 14 | from proxy_cache import vlib_items_cache 15 | logger = logging.getLogger(__name__) 16 | 17 | # --- 后筛选逻辑 (保留用于处理无法翻译的规则) --- 18 | def _get_nested_value(item: Dict[str, Any], field_path: str) -> Any: 19 | keys = field_path.split('.') 20 | value = item 21 | for key in keys: 22 | if isinstance(value, dict): value = value.get(key) 23 | else: return None 24 | return value 25 | 26 | def _check_condition(item_value: Any, operator: str, rule_value: str) -> bool: 27 | if operator not in ["is_empty", "is_not_empty"]: 28 | if item_value is None: return False 29 | if isinstance(item_value, list): 30 | if operator == "contains": return rule_value in item_value 31 | if operator == "not_contains": return rule_value not in item_value 32 | return False 33 | if operator in ["greater_than", "less_than"]: 34 | try: 35 | if operator == "greater_than": return float(item_value) > float(rule_value) 36 | if operator == "less_than": return float(item_value) < float(rule_value) 37 | except (ValueError, TypeError): return False 38 | item_value_str = str(item_value).lower() 39 | rule_value_str = str(rule_value).lower() 40 | if operator == "equals": return item_value_str == rule_value_str 41 | if operator == "not_equals": return item_value_str != rule_value_str 42 | if operator == "contains": return rule_value_str in item_value_str 43 | if operator == "not_contains": return rule_value_str not in item_value_str 44 | if operator == "is_empty": return item_value is None or item_value == '' or item_value == [] 45 | if operator == "is_not_empty": return item_value is not None and item_value != '' and item_value != [] 46 | return False 47 | 48 | def _apply_post_filter(items: List[Dict[str, Any]], post_filter_rules: List[Dict]) -> List[Dict[str, Any]]: 49 | if not post_filter_rules: return items 50 | logger.info(f"在 {len(items)} 个项目上应用 {len(post_filter_rules)} 条后筛选规则。") 51 | filtered_items = [] 52 | for item in items: 53 | if all(_check_condition(_get_nested_value(item, rule.field), rule.operator, rule.value) for rule in post_filter_rules): 54 | filtered_items.append(item) 55 | return filtered_items 56 | 57 | 58 | async def handle_virtual_library_items( 59 | request: Request, 60 | full_path: str, 61 | method: str, 62 | real_emby_url: str, 63 | session: ClientSession, 64 | config: AppConfig 65 | ) -> Response | None: 66 | if "/Items/Prefixes" in full_path or "/Items/Counts" in full_path or "/Items/Latest" in full_path: 67 | return None 68 | 69 | params = request.query_params 70 | found_vlib = None 71 | 72 | parent_id_from_param = params.get("ParentId") 73 | if parent_id_from_param: 74 | found_vlib = next((vlib for vlib in config.virtual_libraries if vlib.id == parent_id_from_param), None) 75 | 76 | if not found_vlib and method == "GET" and 'Items' in full_path: 77 | path_parts = full_path.split('/'); 78 | try: 79 | items_index = path_parts.index('Items') 80 | if items_index + 1 < len(path_parts): 81 | potential_path_vlib_id = path_parts[items_index + 1] 82 | found_vlib = next((vlib for vlib in config.virtual_libraries if vlib.id == potential_path_vlib_id), None) 83 | except ValueError: pass 84 | 85 | if not found_vlib: 86 | # 兼容Go版本的后备方案:处理非标准客户端(如网易爆米花)的请求 87 | # 这些客户端通过 /Users/xxx/Items 获取根视图,且请求参数中不含任何 'Id' 88 | has_id_param = any(key.lower().endswith('id') for key in params.keys()) 89 | 90 | if not has_id_param: 91 | logger.info("检测到非标准客户端请求 (无 'Id' 后缀参数),作为后备方案返回媒体库视图。") 92 | # 调用 handler_views 中的逻辑来返回一个伪造的根视图 93 | return await handler_views.handle_view_injection(request, full_path, method, real_emby_url, session, config) 94 | 95 | # 如果有 'Id' 参数但不是虚拟库,则为正常请求,放行 96 | return None 97 | 98 | logger.info(f"拦截到虚拟库 '{found_vlib.name}',开始高性能筛选流程。") 99 | 100 | user_id = params.get("UserId") 101 | if not user_id: 102 | # (保持原有的UserId查找逻辑) 103 | path_parts_for_user = full_path.split('/') 104 | if 'Users' in path_parts_for_user: 105 | try: 106 | user_id_index = path_parts_for_user.index('Users') + 1 107 | if user_id_index < len(path_parts_for_user): user_id = path_parts_for_user[user_id_index] 108 | except (ValueError, IndexError): pass 109 | if not user_id: return Response(content="UserId not found", status_code=400) 110 | 111 | # --- 开始构建请求 --- 112 | new_params = {} 113 | 114 | # 【【【核心优化点 1】】】: 默认继承客户端的所有安全参数,包括分页和排序! 115 | client_start_index = params.get("StartIndex", "0") 116 | client_limit = params.get("Limit", "50") 117 | safe_params_to_inherit = [ 118 | "SortBy", "SortOrder", "Fields", "EnableImageTypes", "ImageTypeLimit", 119 | "EnableTotalRecordCount", "X-Emby-Token", "StartIndex", "Limit" 120 | ] 121 | for key in safe_params_to_inherit: 122 | if key in params: new_params[key] = params[key] 123 | 124 | required_fields = ["ProviderIds", "Genres", "Tags", "Studios", "People", "OfficialRatings", "CommunityRating", "ProductionYear", "VideoRange", "Container"] 125 | if "Fields" in new_params: 126 | existing_fields = set(new_params["Fields"].split(',')) 127 | missing_fields = [f for f in required_fields if f not in existing_fields] 128 | if missing_fields: new_params["Fields"] += "," + ",".join(missing_fields) 129 | else: new_params["Fields"] = ",".join(required_fields) 130 | 131 | new_params["Recursive"] = "true" 132 | new_params["IncludeItemTypes"] = "Movie,Series,Video" 133 | 134 | resource_map = {"collection": "CollectionIds", "tag": "TagIds", "person": "PersonIds", "genre": "GenreIds", "studio": "StudioIds"} 135 | if found_vlib.resource_type in resource_map: 136 | new_params[resource_map[found_vlib.resource_type]] = found_vlib.resource_id 137 | # --- 【【【 新增:借鉴“缺失剧集”逻辑,重构 RSS 库的统一处理方案 】】】 --- 138 | elif found_vlib.resource_type == "rsshub": 139 | # 终极修复:将所有 RSS 逻辑委托给 RssHandler,并传入必要的上下文 140 | rss_handler = RssHandler() 141 | # 关键:传入 request.query_params 以便 RssHandler 可以继承 Fields 等参数 142 | # 关键:传入 user_id 和 session 以便 RssHandler 可以自己发起请求 143 | response_data = await rss_handler.handle( 144 | request_path=full_path, 145 | vlib_id=found_vlib.id, 146 | request_params=request.query_params, 147 | user_id=user_id, 148 | session=session, 149 | real_emby_url=real_emby_url, 150 | request_headers=request.headers 151 | ) 152 | 153 | # 手动分页 154 | start_idx = int(params.get("StartIndex", 0)) 155 | limit_str = params.get("Limit") 156 | final_items = response_data.get("Items", []) 157 | 158 | if limit_str: 159 | try: 160 | limit = int(limit_str) 161 | paginated_items = final_items[start_idx : start_idx + limit] 162 | except (ValueError, TypeError): 163 | paginated_items = final_items[start_idx:] 164 | else: 165 | paginated_items = final_items[start_idx:] 166 | 167 | final_response = {"Items": paginated_items, "TotalRecordCount": len(final_items)} 168 | return Response(content=json.dumps(final_response).encode('utf-8'), media_type="application/json") 169 | # --- 【【【 RSS 逻辑结束 】】】 --- 170 | 171 | # 【【【核心优化点 2】】】: 应用高级筛选器翻译 172 | post_filter_rules = [] 173 | if found_vlib.advanced_filter_id: 174 | adv_filter = next((f for f in config.advanced_filters if f.id == found_vlib.advanced_filter_id), None) 175 | if adv_filter: 176 | logger.info(f"正在为高级筛选器 '{adv_filter.name}' 翻译规则...") 177 | emby_native_params, post_filter_rules = translate_rules(adv_filter.rules) 178 | new_params.update(emby_native_params) 179 | if post_filter_rules: logger.info(f"有 {len(post_filter_rules)} 条规则需要在代理端后筛选。") 180 | else: 181 | logger.warning(f"虚拟库配置了高级筛选器ID '{found_vlib.advanced_filter_id}',但未找到。") 182 | 183 | # 修复:如果 IsMovie 为 true,则强制 IncludeItemTypes 为 Movie 184 | if new_params.get("IsMovie") == "true": 185 | new_params["IncludeItemTypes"] = "Movie" 186 | logger.info("检测到 IsMovie: 'true',强制设置 IncludeItemTypes 为 'Movie'。") 187 | elif new_params.get("IsSeries") == "true": 188 | new_params["IncludeItemTypes"] = "Series" 189 | logger.info("检测到 IsSeries: 'true',强制设置 IncludeItemTypes 为 'Series'。") 190 | 191 | # 【【【核心优化点 3】】】: 处理合并的特殊情况 192 | # 如果启用了TMDB合并,我们需要获取一个更大的数据集来进行有效的合并,然后再在代理端进行分页。 193 | # 这是一种混合模式,仍然远比获取所有项目要高效。 194 | is_tmdb_merge_enabled = found_vlib.merge_by_tmdb_id or config.force_merge_by_tmdb_id 195 | 196 | target_emby_api_path = f"Users/{user_id}/Items" 197 | search_url = f"{real_emby_url}/emby/{target_emby_api_path}" 198 | 199 | headers_to_forward = { 200 | k: v for k, v in request.headers.items() 201 | if k.lower() in [ 202 | 'accept', 'accept-language', 'user-agent', 203 | 'x-emby-authorization', 'x-emby-client', 'x-emby-device-name', 204 | 'x-emby-device-id', 'x-emby-client-version', 'x-emby-language', 205 | 'x-emby-token' 206 | ] 207 | } 208 | 209 | logger.debug(f"向真实 Emby 发起优化后的最终请求: URL={search_url}, Params={new_params}") 210 | 211 | # 如果不启用TMDB合并,或者有无法翻译的后筛选规则,则走常规分页逻辑 212 | if not is_tmdb_merge_enabled or post_filter_rules: 213 | if is_tmdb_merge_enabled and post_filter_rules: 214 | logger.warning("TMDB合并已启用,但存在无法翻译的后筛选规则,合并将在当前页进行,可能不完整。") 215 | 216 | async with session.request(method, search_url, params=new_params, headers=headers_to_forward) as resp: 217 | if resp.status != 200: 218 | content = await resp.read() 219 | return Response(content=content, status_code=resp.status, headers=resp.headers) 220 | 221 | content = await resp.read() 222 | response_headers = {k: v for k, v in resp.headers.items() if k.lower() not in ('transfer-encoding', 'connection', 'content-encoding', 'content-length')} 223 | 224 | if "application/json" in resp.headers.get("Content-Type", ""): 225 | try: 226 | data = json.loads(content) 227 | items_list = data.get("Items", []) 228 | 229 | if post_filter_rules: 230 | items_list = _apply_post_filter(items_list, post_filter_rules) 231 | 232 | if is_tmdb_merge_enabled: 233 | logger.info("正在对当前页的数据集执行TMDB合并...") 234 | items_list = await handler_merger.merge_items_by_tmdb(items_list) 235 | 236 | data["Items"] = items_list 237 | logger.info(f"原生筛选/合并完成。Emby返回总数: {data.get('TotalRecordCount')}, 当前页项目数: {len(items_list)}") 238 | 239 | final_items_to_return = data.get("Items", []) 240 | if final_items_to_return: 241 | vlib_items_cache[found_vlib.id] = final_items_to_return 242 | logger.info(f"✅ 已为虚拟库 '{found_vlib.name}' 缓存 {len(final_items_to_return)} 个项目以供封面生成使用。") 243 | 244 | content = json.dumps(data).encode('utf-8') 245 | except (json.JSONDecodeError, Exception) as e: 246 | logger.error(f"处理响应时发生错误: {e}") 247 | 248 | return Response(content=content, status_code=resp.status, headers=response_headers) 249 | 250 | # --- TMDB合并的全量获取逻辑 --- 251 | else: 252 | logger.info("TMDB合并已启用,开始获取全量数据...") 253 | all_items = [] 254 | start_index = 0 255 | limit = 200 # 每次请求200个 256 | 257 | # 移除客户端的分页参数,因为我们要自己控制 258 | new_params.pop("StartIndex", None) 259 | new_params.pop("Limit", None) 260 | 261 | while True: 262 | fetch_params = new_params.copy() 263 | fetch_params["StartIndex"] = str(start_index) 264 | fetch_params["Limit"] = str(limit) 265 | 266 | logger.debug(f"正在获取批次: StartIndex={start_index}, Limit={limit}") 267 | async with session.request(method, search_url, params=fetch_params, headers=headers_to_forward) as resp: 268 | if resp.status != 200: 269 | logger.error(f"获取批次失败,状态码: {resp.status}") 270 | # 返回错误或一个空的成功响应 271 | return Response(content=json.dumps({"Items": [], "TotalRecordCount": 0}), status_code=200, media_type="application/json") 272 | 273 | batch_data = await resp.json() 274 | batch_items = batch_data.get("Items", []) 275 | 276 | if not batch_items: 277 | logger.info("已获取所有数据。") 278 | break 279 | 280 | all_items.extend(batch_items) 281 | start_index += len(batch_items) 282 | 283 | # 如果返回的项目数小于请求的limit,说明是最后一页 284 | if len(batch_items) < limit: 285 | logger.info("已到达最后一页。") 286 | break 287 | 288 | logger.info(f"全量数据获取完成,总共 {len(all_items)} 个项目。") 289 | 290 | # 1. 应用TMDB合并 291 | logger.info("正在对获取到的全量数据集执行TMDB合并...") 292 | merged_items = await handler_merger.merge_items_by_tmdb(all_items) 293 | 294 | # 2. 对合并后的结果进行手动分页 295 | total_record_count = len(merged_items) 296 | start_idx = int(client_start_index) 297 | limit_count = int(client_limit) 298 | paginated_items = merged_items[start_idx : start_idx + limit_count] 299 | 300 | # 3. 构建最终的响应 301 | final_data = { 302 | "Items": paginated_items, 303 | "TotalRecordCount": total_record_count, 304 | "StartIndex": start_idx 305 | } 306 | logger.info(f"合并后手动分页完成。总数: {total_record_count}, 返回页面项目数: {len(paginated_items)}") 307 | 308 | if paginated_items: 309 | vlib_items_cache[found_vlib.id] = paginated_items 310 | logger.info(f"✅ 已为虚拟库 '{found_vlib.name}' 缓存 {len(paginated_items)} 个项目以供封面生成使用。") 311 | 312 | content = json.dumps(final_data).encode('utf-8') 313 | # 伪造一个成功的响应头 314 | response_headers = { 315 | 'Content-Type': 'application/json; charset=utf-8', 316 | 'Content-Length': str(len(content)) 317 | } 318 | return Response(content=content, status_code=200, headers=response_headers) 319 | 320 | return None 321 | -------------------------------------------------------------------------------- /src/cover_generator/style_single_2.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import os 3 | import random 4 | import colorsys 5 | from collections import Counter 6 | from io import BytesIO 7 | from pathlib import Path 8 | 9 | import numpy as np 10 | from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps 11 | 12 | import logging 13 | logger = logging.getLogger(__name__) 14 | 15 | # ========== 配置 ========== 16 | canvas_size = (1920, 1080) 17 | 18 | def is_not_black_white_gray_near(color, threshold=20): 19 | """判断颜色既不是黑、白、灰,也不是接近黑、白。""" 20 | r, g, b = color 21 | if (r < threshold and g < threshold and b < threshold) or \ 22 | (r > 255 - threshold and g > 255 - threshold and b > 255 - threshold): 23 | return False 24 | gray_diff_threshold = 10 25 | if abs(r - g) < gray_diff_threshold and abs(g - b) < gray_diff_threshold and abs(r - b) < gray_diff_threshold: 26 | return False 27 | return True 28 | 29 | def rgb_to_hsv(color): 30 | """将 RGB 颜色转换为 HSV 颜色。""" 31 | r, g, b = [x / 255.0 for x in color] 32 | return colorsys.rgb_to_hsv(r, g, b) 33 | 34 | def hsv_to_rgb(h, s, v): 35 | """将 HSV 颜色转换为 RGB 颜色。""" 36 | r, g, b = colorsys.hsv_to_rgb(h, s, v) 37 | return (int(r * 255), int(g * 255), int(b * 255)) 38 | 39 | def adjust_to_macaron(h, s, v, target_saturation_range=(0.2, 0.7), target_value_range=(0.55, 0.85)): 40 | """将颜色的饱和度和亮度调整到接近马卡龙色系的范围,同时避免颜色过亮。""" 41 | adjusted_s = min(max(s, target_saturation_range[0]), target_saturation_range[1]) 42 | adjusted_v = min(max(v, target_value_range[0]), target_value_range[1]) 43 | return adjusted_s, adjusted_v 44 | 45 | def find_dominant_vibrant_colors(image, num_colors=5): 46 | """ 47 | 从图像中提取出现次数较多的前 N 种非黑非白非灰的颜色, 48 | 并将其调整到接近马卡龙色系。 49 | """ 50 | img = image.copy() 51 | img.thumbnail((100, 100)) 52 | img = img.convert('RGB') 53 | pixels = list(img.getdata()) 54 | filtered_pixels = [p for p in pixels if is_not_black_white_gray_near(p)] 55 | if not filtered_pixels: 56 | return [] 57 | color_counter = Counter(filtered_pixels) 58 | dominant_colors = color_counter.most_common(num_colors * 3) # 提取更多候选 59 | 60 | macaron_colors = [] 61 | seen_hues = set() # 避免提取过于相似的颜色 62 | 63 | for color, count in dominant_colors: 64 | h, s, v = rgb_to_hsv(color) 65 | adjusted_s, adjusted_v = adjust_to_macaron(h, s, v) 66 | adjusted_rgb = hsv_to_rgb(h, adjusted_s, adjusted_v) 67 | 68 | # 可以加入一些色调的判断,例如避免过于接近的色调 69 | hue_degree = int(h * 360) 70 | is_similar_hue = any(abs(hue_degree - seen) < 15 for seen in seen_hues) # 15度范围内的色调认为是相似的 71 | 72 | if not is_similar_hue and adjusted_rgb not in macaron_colors: 73 | macaron_colors.append(adjusted_rgb) 74 | seen_hues.add(hue_degree) 75 | if len(macaron_colors) >= num_colors: 76 | break 77 | 78 | return macaron_colors 79 | 80 | def darken_color(color, factor=0.7): 81 | """ 82 | 将颜色加深。 83 | """ 84 | r, g, b = color 85 | return (int(r * factor), int(g * factor), int(b * factor)) 86 | 87 | 88 | def add_film_grain(image, intensity=0.05): 89 | """添加胶片颗粒效果""" 90 | img_array = np.array(image) 91 | 92 | # 创建随机噪点 93 | noise = np.random.normal(0, intensity * 255, img_array.shape) 94 | 95 | # 应用噪点 96 | img_array = img_array + noise 97 | img_array = np.clip(img_array, 0, 255).astype(np.uint8) 98 | 99 | return Image.fromarray(img_array) 100 | 101 | 102 | def crop_to_16_9(img): 103 | """直接将图片裁剪为16:9的比例""" 104 | target_ratio = 16 / 9 105 | current_ratio = img.width / img.height 106 | 107 | if current_ratio > target_ratio: 108 | # 图片太宽,裁剪两侧 109 | new_width = int(img.height * target_ratio) 110 | left = (img.width - new_width) // 2 111 | img = img.crop((left, 0, left + new_width, img.height)) 112 | else: 113 | # 图片太高,裁剪上下 114 | new_height = int(img.width / target_ratio) 115 | top = (img.height - new_height) // 2 116 | img = img.crop((0, top, img.width, top + new_height)) 117 | 118 | return img 119 | 120 | 121 | def align_image_right(img, canvas_size): 122 | """ 123 | 将图片调整为与画布相同高度,裁剪出画布60%宽度的部分, 124 | 然后将裁剪后的图片靠右放置(因为左侧40%会被其他内容遮盖)。 125 | """ 126 | canvas_width, canvas_height = canvas_size 127 | target_width = int(canvas_width * 0.675) # 只需要画布60%的宽度 128 | img_width, img_height = img.size 129 | 130 | # 计算缩放比例以匹配画布高度 131 | scale_factor = canvas_height / img_height 132 | new_img_width = int(img_width * scale_factor) 133 | resized_img = img.resize((new_img_width, canvas_height), Image.LANCZOS) 134 | 135 | # 检查缩放后的图片是否足够宽以覆盖目标宽度 136 | if new_img_width < target_width: 137 | # 如果图片不够宽,基于宽度而非高度进行缩放 138 | scale_factor = target_width / img_width 139 | new_img_height = int(img_height * scale_factor) 140 | resized_img = img.resize((target_width, new_img_height), Image.LANCZOS) 141 | 142 | # 将图片垂直居中裁剪 143 | if new_img_height > canvas_height: 144 | crop_top = (new_img_height - canvas_height) // 2 145 | resized_img = resized_img.crop((0, crop_top, target_width, crop_top + canvas_height)) 146 | 147 | # 创建画布并将图片靠右放置 148 | final_img = Image.new("RGB", canvas_size) 149 | final_img.paste(resized_img, (canvas_width - target_width, 0)) 150 | return final_img 151 | 152 | # 以下是原始图片足够宽的情况处理 153 | 154 | # 计算图片中心,确保主体在截取的部分中居中 155 | resized_img_center_x = new_img_width / 2 156 | 157 | # 计算裁剪的左右边界,使目标部分居中 158 | crop_left = max(0, resized_img_center_x - target_width / 2) 159 | # 确保右边界不超过图片宽度 160 | if crop_left + target_width > new_img_width: 161 | crop_left = new_img_width - target_width 162 | crop_right = crop_left + target_width 163 | 164 | # 确保裁剪边界不为负 165 | crop_left = max(0, crop_left) 166 | crop_right = min(new_img_width, crop_right) 167 | 168 | # 进行裁剪 169 | cropped_img = resized_img.crop((int(crop_left), 0, int(crop_right), canvas_height)) 170 | 171 | # 创建画布并将裁剪后的图片靠右放置 172 | final_img = Image.new("RGB", canvas_size) 173 | paste_x = canvas_width - cropped_img.width + int(canvas_width * 0.075) 174 | final_img.paste(cropped_img, (paste_x, 0)) 175 | 176 | return final_img 177 | 178 | def create_diagonal_mask(size, split_top=0.5, split_bottom=0.33): 179 | """ 180 | 创建斜线分割的蒙版。左侧为背景 (255),右侧为前景 (0)。 181 | """ 182 | mask = Image.new('L', size, 255) 183 | draw = ImageDraw.Draw(mask) 184 | width, height = size 185 | top_x = int(width * split_top) 186 | bottom_x = int(width * split_bottom) 187 | 188 | # 绘制前景区域 (右侧) - 填充为黑色 189 | draw.polygon( 190 | [ 191 | (top_x, 0), 192 | (width, 0), 193 | (width, height), 194 | (bottom_x, height) 195 | ], 196 | fill=0 197 | ) 198 | 199 | # 绘制背景区域 (左侧) - 填充为白色 200 | draw.polygon( 201 | [ 202 | (0, 0), 203 | (top_x, 0), 204 | (bottom_x, height), 205 | (0, height) 206 | ], 207 | fill=255 208 | ) 209 | return mask 210 | 211 | def create_shadow_mask(size, split_top=0.5, split_bottom=0.33, feather_size=40): 212 | """ 213 | 创建一个阴影蒙版,用于左侧图片向右侧图片投射阴影 214 | """ 215 | width, height = size 216 | top_x = int(width * split_top) 217 | bottom_x = int(width * split_bottom) 218 | 219 | # 创建基础蒙版 - 左侧完全透明,右侧完全不透明 220 | mask = Image.new('L', size, 0) 221 | draw = ImageDraw.Draw(mask) 222 | 223 | # 阴影宽度再缩小一半 (原来的六分之一) 224 | shadow_width = feather_size // 3 225 | 226 | # 绘制阴影区域的多边形 - 向左靠拢 227 | draw.polygon( 228 | [ 229 | (top_x - 5, 0), # 向左偏移5像素,确保没有空隙 230 | (top_x - 5 + shadow_width, 0), 231 | (bottom_x - 5 + shadow_width, height), 232 | (bottom_x - 5, height) 233 | ], 234 | fill=255 235 | ) 236 | 237 | # 模糊阴影边缘,创造渐变效果,但保持较小的模糊半径 238 | mask = mask.filter(ImageFilter.GaussianBlur(radius=feather_size//3)) 239 | 240 | return mask 241 | 242 | def create_style_single_2(image_path, title, font_path, font_size=(1,1), blur_size=50, color_ratio=0.8): 243 | try: 244 | zh_font_path, en_font_path = font_path 245 | title_zh, title_en = title 246 | 247 | zh_font_size_ratio, en_font_size_ratio = font_size 248 | 249 | if int(blur_size) < 0: 250 | blur_size = 50 251 | 252 | if float(color_ratio) < 0 or float(color_ratio) > 1: 253 | color_ratio = 0.8 254 | 255 | if not float(zh_font_size_ratio) > 0: 256 | zh_font_size_ratio = 1 257 | if not float(en_font_size_ratio) > 0: 258 | en_font_size_ratio = 1 259 | 260 | # 定义斜线分割位置 261 | split_top = 0.55 # 顶部分割点在画面五分之三的位置 262 | split_bottom = 0.4 # 底部分割点在画面二分之一的位置 263 | 264 | # 加载前景图片并处理 265 | fg_img_original = Image.open(image_path).convert("RGB") 266 | # 以画面四分之三处为中心处理前景图 267 | fg_img = align_image_right(fg_img_original, canvas_size) 268 | 269 | # 获取前景图中最鲜明的颜色 270 | vibrant_colors = find_dominant_vibrant_colors(fg_img) 271 | 272 | # 柔和的颜色备选(马卡龙风格) 273 | soft_colors = [ 274 | (237, 159, 77), # 原默认色 275 | (255, 183, 197), # 淡粉色 276 | (186, 225, 255), # 淡蓝色 277 | (255, 223, 186), # 浅橘色 278 | (202, 231, 200), # 淡绿色 279 | (245, 203, 255), # 淡紫色 280 | ] 281 | # 如果有鲜明的颜色,则选择第一个(饱和度最高)作为背景色,否则使用默认颜色 282 | if vibrant_colors: 283 | bg_color = vibrant_colors[0] 284 | else: 285 | bg_color = random.choice(soft_colors) # 默认橙色 286 | shadow_color = darken_color(bg_color, 0.5) # 加深阴影颜色到50% 287 | 288 | # 加载背景图片 289 | bg_img_original = Image.open(image_path).convert("RGB") 290 | bg_img = ImageOps.fit(bg_img_original, canvas_size, method=Image.LANCZOS) 291 | 292 | # 强烈模糊化背景图 293 | bg_img = bg_img.filter(ImageFilter.GaussianBlur(radius=int(blur_size))) 294 | 295 | # 将背景图片与背景色混合 296 | bg_color = darken_color(bg_color, 0.85) 297 | bg_img_array = np.array(bg_img, dtype=float) 298 | bg_color_array = np.array([[bg_color]], dtype=float) 299 | 300 | # 混合背景图和颜色 (10% 背景图 + 90% 颜色) - 使原图几乎不可见,只保留极少纹理 301 | blended_bg = bg_img_array * (1 - float(color_ratio)) + bg_color_array * float(color_ratio) 302 | blended_bg = np.clip(blended_bg, 0, 255).astype(np.uint8) 303 | blended_bg_img = Image.fromarray(blended_bg) 304 | 305 | # 添加胶片颗粒效果增强纹理感 306 | blended_bg_img = add_film_grain(blended_bg_img, intensity=0.05) 307 | 308 | # 创建斜线分割的蒙版 309 | diagonal_mask = create_diagonal_mask(canvas_size, split_top, split_bottom) 310 | 311 | # 创建基础画布 - 前景图 312 | canvas = fg_img.copy() 313 | 314 | # 创建阴影蒙版 - 使用加深的背景色作为阴影颜色,减小阴影距离 315 | shadow_mask = create_shadow_mask(canvas_size, split_top, split_bottom, feather_size=30) 316 | 317 | # 创建阴影层 - 使用更加深的背景色 318 | shadow_layer = Image.new('RGB', canvas_size, shadow_color) 319 | 320 | # 创建临时画布用于组合 321 | temp_canvas = Image.new('RGB', canvas_size) 322 | 323 | # 应用阴影到前景图(先将阴影应用到前景图上) 324 | temp_canvas.paste(canvas) 325 | temp_canvas.paste(shadow_layer, mask=shadow_mask) 326 | 327 | # 使用蒙版将背景图应用到画布上(背景图会覆盖前景图的左侧部分) 328 | canvas = Image.composite(blended_bg_img, temp_canvas, diagonal_mask) 329 | 330 | # ===== 标题绘制 ===== 331 | # 使用RGBA模式进行绘制,以便设置文字透明度 332 | 333 | canvas_rgba = canvas.convert('RGBA') 334 | text_layer = Image.new('RGBA', canvas_size, (255, 255, 255, 0)) 335 | shadow_layer = Image.new("RGBA", canvas_size, (0, 0, 0, 0)) 336 | 337 | shadow_draw = ImageDraw.Draw(shadow_layer) 338 | draw = ImageDraw.Draw(text_layer) 339 | 340 | # 计算左侧区域的中心 X 位置 (画布宽度的四分之一处) 341 | left_area_center_x = int(canvas_size[0] * 0.25) 342 | left_area_center_y = canvas_size[1] // 2 343 | 344 | zh_font_size = int(canvas_size[1] * 0.17 * float(zh_font_size_ratio)) 345 | en_font_size = int(canvas_size[1] * 0.07 * float(en_font_size_ratio)) 346 | 347 | zh_font = ImageFont.truetype(str(zh_font_path), zh_font_size) 348 | en_font = ImageFont.truetype(str(en_font_path), en_font_size) 349 | 350 | # 设置80%透明度的文字颜色 (255, 255, 255, 204) - 204是80%不透明度 351 | text_color = (255, 255, 255, 229) 352 | shadow_color = darken_color(bg_color, 0.8) + (75,) # 原始阴影透明度 353 | shadow_offset = 12 354 | shadow_alpha = 75 355 | # 计算中文标题的位置 356 | zh_bbox = draw.textbbox((0, 0), title_zh, font=zh_font) 357 | zh_text_w = zh_bbox[2] - zh_bbox[0] 358 | zh_text_h = zh_bbox[3] - zh_bbox[1] 359 | zh_x = left_area_center_x - zh_text_w // 2 360 | zh_y = left_area_center_y - zh_text_h - en_font_size // 2 - 5 361 | 362 | # 恢复原始的字体阴影效果 - 完全参考原代码 363 | for offset in range(3, shadow_offset + 1, 2): 364 | # shadow_alpha = int(210 * (1 - offset / shadow_offset)) 365 | current_shadow_color = shadow_color[:3] + (shadow_alpha,) 366 | shadow_draw.text((zh_x + offset, zh_y + offset), title_zh, font=zh_font, fill=current_shadow_color) 367 | 368 | # 80%透明度的主文字 369 | draw.text((zh_x, zh_y), title_zh, font=zh_font, fill=text_color) 370 | 371 | # 计算英文标题的位置 372 | if title_en: 373 | en_bbox = draw.textbbox((0, 0), title_en, font=en_font) 374 | en_text_w = en_bbox[2] - en_bbox[0] 375 | en_text_h = en_bbox[3] - en_bbox[1] 376 | en_x = left_area_center_x - en_text_w // 2 377 | en_y = zh_y + zh_text_h + en_font_size 378 | # 恢复原始的英文标题阴影效果 379 | for offset in range(2, shadow_offset // 2 + 1): 380 | # shadow_alpha = int(210 * (1 - offset / (shadow_offset // 2))) 381 | current_shadow_color = shadow_color[:3] + (shadow_alpha,) 382 | shadow_draw.text((en_x + offset, en_y + offset), title_en, font=en_font, fill=current_shadow_color) 383 | 384 | # 80%透明度的英文主文字 385 | draw.text((en_x, en_y), title_en, font=en_font, fill=text_color) 386 | 387 | blurred_shadow = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_offset)) 388 | 389 | combined = Image.alpha_composite(canvas_rgba, blurred_shadow) 390 | # 把 text_layer 合并到 canvas_rgba 上 391 | combined = Image.alpha_composite(combined, text_layer) 392 | 393 | def image_to_base64(image, format="auto", quality=85): 394 | buffer = BytesIO() 395 | if format.lower() == "auto": 396 | if image.mode == "RGBA" or (image.info.get('transparency') is not None): 397 | format = "PNG" 398 | else: 399 | try: 400 | image.save(buffer, format="WEBP", quality=quality, optimize=True) 401 | base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8') 402 | return base64_str 403 | except Exception: 404 | format = "JPEG" # Fallback to JPEG if WebP fails 405 | if format.lower() == "png": 406 | image.save(buffer, format="PNG", optimize=True) 407 | base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8') 408 | return base64_str 409 | elif format.lower() == "jpeg": 410 | image = image.convert("RGB") # Ensure RGB for JPEG 411 | image.save(buffer, format="JPEG", quality=quality, optimize=True, progressive=True) 412 | base64_str = base64.b64encode(buffer.getvalue()).decode('utf-8') 413 | return base64_str 414 | else: 415 | raise ValueError(f"Unsupported format: {format}") 416 | 417 | return image_to_base64(combined) 418 | except Exception as e: 419 | logger.error(f"创建单图封面时出错: {e}") 420 | return False 421 | -------------------------------------------------------------------------------- /frontend/src/components/LibraryEditDialog.vue: -------------------------------------------------------------------------------- 1 | 190 | 191 | 381 | 382 | 423 | --------------------------------------------------------------------------------