项目中的小知识


📊 Path 对象的属性对比

from pathlib import Path

# 示例文件路径
filename = "/home/user/movies/Avatar (2009)/avatar.2009.1080p.bluray.mkv"

# 创建 Path 对象
path_obj = Path(filename)

# 不同的属性获取不同部分
print(f"完整路径: {path_obj}")                
# 输出: /home/user/movies/Avatar (2009)/avatar.2009.1080p.bluray.mkv

print(f"文件名: {path_obj.name}")             
# 输出: avatar.2009.1080p.bluray.mkv

print(f"文件名主干: {path_obj.stem}")          
# 输出: avatar.2009.1080p.bluray

print(f"扩展名: {path_obj.suffix}")           
# 输出: .mkv

print(f"父目录: {path_obj.parent}")            
# 输出: /home/user/movies/Avatar (2009)

print(f"父目录名: {path_obj.parent.name}")     
# 输出: Avatar (2009)

#url
from urllib.parse import urlparse

def extract_path_from_url(full_url: str) -> str:
    """从URL提取路径部分"""
    return urlparse(full_url).path

# 使用
input_url = "http://maelsea.site:5244/dav/302/133quark302/test/"
path_only = extract_path_from_url(input_url)  # 得到: "/dav/302/133quark302/test/"

tmdb!搜索API返回401错误,提示”Invalid API key”。这是因为:

  1. ✅ 配置端点 ( /3/configuration ) 可以用v4 token作为Bearer认证
  2. ❌ 搜索端点 ( /3/search/movie ) 不接受v4 token,需要传统的api_key参数

tmdb原始响应数据

  • 搜索剧集 GET /search/tvquery=树影迷宫, language=zh-CN
{
  "page": 1,
  "results": [{
    "adult": false,
    "backdrop_path": "/cunN5mAX79YuJbvlckRftcoIe62.jpg",
    "genre_ids": [9648, 18],
    "id": 263151,
    "origin_country": ["CN"],
    "original_language": "zh",
    "original_name": "树影迷宫",
    "overview": "…",
    "popularity": 7.1627,
    "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
    "first_air_date": "2025-11-01",
    "name": "树影迷宫",
    "vote_average": 8.0,
    "vote_count": 2
  }],
  "total_pages": 1,
  "total_results": 1
}
  • 剧集详情 GET /tv/{series_id}series_id=263151, language=zh-CN
{ ... 很长,包含:
  "id": 263151,
  "name": "树影迷宫",
  "original_name": "树影迷宫",
  "overview": "两代警察跌跌撞撞,至死不渝,在迷宫一样的胡同中找寻凶手,最终走出了自己人生的死胡同。",
  "genres": [{"id":9648,"name":"Mystery"},{"id":18,"name":"Drama"}],
  "first_air_date": "2025-11-01",
  "episode_run_time": [52, 54, 48, 43, 47, 50, 53],
  "languages": ["zh"],
  "origin_country": ["CN"],
  "production_companies": [...],
  "production_countries": [{"iso_3166_1":"CN","name":"China"}],
  "networks": [{"id":1330,"name":"iQiyi","origin_country":"CN","logo_path":"/fNxBFqWr7eWEgNeBDvvCxsSItXx.png"}],
  "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
  "backdrop_path": "/cunN5mAX79YuJbvlckRftcoIe62.jpg",
  "number_of_seasons": 1,
  "number_of_episodes": 18,
  "seasons": [
    {"air_date":"2025-11-01","episode_count":18,"id":408931,"name":"第 1 季","season_number":1,"poster_path":"/2ilIbodMYijMA0XnyfH852mth10.jpg"}
  ],
  "status": "Ended",
  "vote_average": 8.0,
  "vote_count": 2,
  "in_production": false,
  "created_by": [...],
  "homepage": null,
  "last_air_date": "2025-11-07",
  "tagline": null,
  "type": "Scripted"
}
  • 季详情 GET /tv/{series_id}/season/1language=zh-CN
{
  "air_date": "2025-11-01",
  "episodes": [
    {
      "air_date": "2025-11-01",
      "episode_number": 1,
      "episode_type": "standard",
      "id": 6140626,
      "name": "犯罪现场如迷宫难辨真假",
      "overview": "…",
      "production_code": "",
      "runtime": 53,
      "season_number": 1,
      "show_id": 263151,
      "still_path": "/kJxYg...jpg",
      "vote_average": 0.0,
      "vote_count": 0,
      "crew": [],
      "guest_stars": []
    },
    {
      "air_date": "2025-11-01",
      "episode_number": 2,
      "id": 6140627,
      "name": "死者通话记录查到赶鹅",
      "overview": "…",
      "production_code": "",
      "runtime": 54,
      "season_number": 1,
      "show_id": 263151,
      "still_path": "/lb8RMf...jpg",
      "vote_average": 0.0,
      "vote_count": 0,
      "crew": [],
      "guest_stars": []
    },
    … 直到 episode_number 18
  ],
  "name": "第 1 季",
  "networks": [{"id":1330,"name":"iQiyi","origin_country":"CN","logo_path":"/fNxBFqWr7eWEgNeBDvvCxsSItXx.png"}],
  "overview": "两代警察跌跌撞撞…",
  "id": 408931,
  "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
  "season_number": 1,
  "vote_average": 0.0
}
  • 单集详情 GET /tv/{series_id}/season/1/episode/2language=zh-CN
{
  "air_date": "2025-11-01",
  "crew": [],
  "episode_number": 2,
  "episode_type": "standard",
  "guest_stars": [],
  "name": "死者通话记录查到赶鹅",
  "overview": "…单集剧情…",
  "id": 6140627,
  "production_code": "",
  "runtime": 54,
  "season_number": 1,
  "still_path": "/lb8RMfNKudt4H3VvVHDCITDVxCY.jpg",
  "vote_average": 0.0,
  "vote_count": 0
}
  • 单集演职员 GET /tv/{series_id}/season/1/episode/2/credits
{
  "cast": [
    {"id":99692,"name":"廖凡","character":"Ran Xi","order":0,"profile_path":"/yELT5tpxq5J7ErB23afDmowtuHz.jpg"},
    {"id":2133335,"name":"尹昉","character":"Zhao Gan'e","order":1,"profile_path":"/sW2OPJXlpINc5BzF6WuxKp3U0zO.jpg"},
    {"id":2227790,"name":"刘琳","character":"Liu Beiping","order":2,"profile_path":"/zTwp8XHRHwQHiko0vWYbLZzAKD3.jpg"},
    {"id":3168903,"name":"Lin Muran","character":"Ran Dongdong","order":3,"profile_path":"/6GFNcDcTOjtOeXqtg8qYQH9zXgd.jpg"},
    {"id":4941641,"name":"赵佳丽","character":"Tian Chang","order":4,"profile_path":"/9abtyT7HWQbXpO0IzErpk8ODybE.jpg"}
  ],
  "crew": [],
  "guest_stars": [],
  "id": 6140627
}
  • 单集外部ID GET /tv/{series_id}/season/1/episode/2/external_ids
{"id":6140627,"imdb_id":null,"freebase_mid":null,"freebase_id":null,"tvdb_id":null,"tvrage_id":null,"wikidata_id":null}
  • 单集剧照 GET /tv/{series_id}/season/1/episode/2/images
{
  "id": 6140627,
  "stills": [
    {"file_path":"/lb8RMfNKudt4H3VvVHDCITDVxCY.jpg","iso_639_1":"zh","iso_3166_1":"CN","width":1248,"height":702,"aspect_ratio":1.778,"vote_average":3.334,"vote_count":1},
    {"file_path":"/sk03poJIPhJUkx2xEaxphl26nTn.jpg","iso_639_1":"zh","iso_3166_1":"CN","width":1436,"height":808,"aspect_ratio":1.777,"vote_average":0.166,"vote_count":1}
  ]
}

可用于建模的字段

  • 剧集(series)
    • 基本:id,name,original_name,overview,first_air_date,languages,origin_country,status,tagline,type
    • 计数:number_of_seasons,number_of_episodes
    • 时长:episode_run_time[]
    • 视觉:poster_path,backdrop_path,networks[],seasons[]
    • 评分:vote_average,vote_count
    • 制作:production_companies[],production_countries[]
  • 季(season)
    • 基本:id,season_number,air_date,name,overview,poster_path
    • 集列表:episodes[](每项含 id,name,overview,episode_number,air_date,runtime,still_path
  • 集(episode)
    • 基本:id,season_number,episode_number,name,overview,air_date,runtime,still_path
    • 扩展:credits.cast[],credits.crew[]images.stills[]external_ids.*

与当前代码的对应关系

  • 系列映射至 TVSeriesExtMediaCore(kind='tv_series');参考入库逻辑 services/media/metadata_enricher.py:586-611
  • 季映射至 SeasonExtMediaCore(kind='tv_season');参考 services/media/metadata_enricher.py:612-638
  • 单集映射至 EpisodeExtMediaCore(kind='tv_episode');参考 services/media/metadata_enricher.py:639-645
  • 艺术作品与演职员:tmdb.pyget_details/_get_artworks/_get_credits 已示例了系列级获取;单集级需扩展调用上面的 episode 端点。

如需我把单集端点整合到 TMDB 刮削器并填充 season_number/episode_number/episode_title/air_date/runtime/credits/artworks,我可以直接改动 services/scraper/tmdb.py 并在 metadata_enricher 中根据解析到的季/集号选择调用单集接口,保存到对应扩展表。

toolName: agent_finish

status: success

太好了!这正是我需要的真实TMDB API数据。让我为你详细分析这些原始数据,以便决定数据库构建方式:

🎬 真实TMDB API响应数据

1. 剧集搜索结果 (/search/tv)

{
  "page": 1,
  "results": [{
    "adult": false,
    "backdrop_path": "/cunN5mAX79YuJbvlckRftcoIe62.jpg",
    "genre_ids": [9648, 18],
    "id": 263151,
    "origin_country": ["CN"],
    "original_language": "zh",
    "original_name": "树影迷宫",
    "overview": "…",
    "popularity": 7.1627,
    "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
    "first_air_date": "2025-11-01",
    "name": "树影迷宫",
    "vote_average": 8.0,
    "vote_count": 2
  }],
  "total_pages": 1,
  "total_results": 1
}

2. 剧集详情 (/tv/{series_id})

{
  "id": 263151,
  "name": "树影迷宫",
  "original_name": "树影迷宫",
  "overview": "两代警察跌跌撞撞,至死不渝,在迷宫一样的胡同中找寻凶手,最终走出了自己人生的死胡同。",
  "genres": [{"id":9648,"name":"Mystery"},{"id":18,"name":"Drama"}],
  "first_air_date": "2025-11-01",
  "episode_run_time": [52, 54, 48, 43, 47, 50, 53],
  "languages": ["zh"],
  "origin_country": ["CN"],
  "production_companies": [...],
  "production_countries": [{"iso_3166_1":"CN","name":"China"}],
  "networks": [{"id":1330,"name":"iQiyi","origin_country":"CN","logo_path":"/fNxBFqWr7eWEgNeBDvvCxsSItXx.png"}],
  "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
  "backdrop_path": "/cunN5mAX79YuJbvlckRftcoIe62.jpg",
  "number_of_seasons": 1,
  "number_of_episodes": 18,
  "seasons": [
    {
      "air_date": "2025-11-01",
      "episode_count": 18,
      "id": 408931,
      "name": "第 1 季",
      "season_number": 1,
      "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg"
    }
  ],
  "status": "Ended",
  "vote_average": 8.0,
  "vote_count": 2,
  "in_production": false,
  "last_air_date": "2025-11-07",
  "type": "Scripted"
}

3. 季度详情 (/tv/{series_id}/season/1) - 这是关键!

{
  "air_date": "2025-11-01",
  "episodes": [
    {
      "air_date": "2025-11-01",
      "episode_number": 1,
      "episode_type": "standard",
      "id": 6140626,
      "name": "犯罪现场如迷宫难辨真假",
      "overview": "…",
      "production_code": "",
      "runtime": 53,
      "season_number": 1,
      "show_id": 263151,
      "still_path": "/kJxYg...jpg",
      "vote_average": 0.0,
      "vote_count": 0,
      "crew": [],
      "guest_stars": []
    },
    {
      "air_date": "2025-11-01",
      "episode_number": 2,
      "id": 6140627,
      "name": "死者通话记录查到赶鹅",  // ← 第2集标题!
      "overview": "…",           // ← 第2集简介!
      "production_code": "",
      "runtime": 54,             // ← 第2集时长!
      "season_number": 1,
      "show_id": 263151,
      "still_path": "/lb8RMf...jpg",  // ← 第2集剧照!
      "vote_average": 0.0,
      "vote_count": 0
    }
    // ... 直到第18集
  ],
  "name": "第 1 季",
  "overview": "两代警察跌跌撞撞…",
  "id": 408931,
  "poster_path": "/2ilIbodMYijMA0XnyfH852mth10.jpg",
  "season_number": 1,
  "vote_average": 0.0
}

4. 单集详情 (/tv/{series_id}/season/1/episode/2)

{
  "air_date": "2025-11-01",
  "crew": [],
  "episode_number": 2,
  "episode_type": "standard",
  "guest_stars": [],
  "name": "死者通话记录查到赶鹅",
  "overview": "…单集剧情…",
  "id": 6140627,
  "production_code": "",
  "runtime": 54,
  "season_number": 1,
  "still_path": "/lb8RMfNKudt4H3VvVHDCITDVxCY.jpg",
  "vote_average": 0.0,
  "vote_count": 0
}

5. 单集演职员 (/episode/{episode_id}/credits)

{
  "cast": [
    {"id":99692,"name":"廖凡","character":"Ran Xi","order":0,"profile_path":"/yELT5tpxq5J7ErB23afDmowtuHz.jpg"},
    {"id":2133335,"name":"尹昉","character":"Zhao Gan'e","order":1,"profile_path":"/sW2OPJXlpINc5BzF6WuxKp3U0zO.jpg"},
    {"id":2227790,"name":"刘琳","character":"Liu Beiping","order":2,"profile_path":"/zTwp8XHRHwQHiko0vWYbLZzAKD3.jpg"}
  ],
  "crew": [],
  "guest_stars": [],
  "id": 6140627
}

💡 数据库构建建议

基于这些真实数据,我建议:

🎯 关键发现

  1. 季度API (/tv/{id}/season/{n}) 已经包含所有集的详细信息!
  2. 每集都有: 标题、简介、时长、剧照、播出日期
  3. 单集API 提供额外的演职员信息

🗃️ 数据库存储策略

当前问题: 系统只调用了剧集级API,错过了季度API中的丰富单集数据

解决方案: 修改刮削流程

  1. 获取剧集详情 → 得到基本信息
  2. 新增: 获取季度详情 → 得到所有单集信息
  3. 可选: 获取单集详情 → 得到演职员等扩展信息

这样就能为《树影迷宫.S01E02.2025.mkv》提供:

  • ✅ 单集标题: “死者通话记录查到赶鹅”
  • ✅ 单集简介: 完整的剧情介绍
  • ✅ 单集时长: 54分钟
  • ✅ 单集剧照: /lb8RMf...jpg
  • ✅ 播出日期: 2025-11-01
  • ✅ 演职员表: 廖凡、尹昉、刘琳等
graph TD
    A[文件列表] --> B{按剧集分组}
    B --> C[批量剧集搜索]
    C --> D{缓存检查}
    D -->|命中| E[复用缓存数据]
    D -->|未命中| F[批量获取剧集详情]
    F --> G[缓存剧集详情]
    G --> H[分析文件分布]
    H --> I{预加载决策}
    I -->|需要| J[批量获取季详情]
    I -->|不需要| K[按需获取季详情]
    J --> L[缓存季详情]
    L --> M{需要演职员?}
    M -->|是| N[批量获取演职员]
    M -->|否| O[组装完整数据]
    N --> O
    K --> O
    E --> O
    O --> P[返回完整元数据]

架构解耦分析

智能批量处理器与刮削器解耦

是的,实现了完全解耦! 新架构通过以下设计模式实现了解耦:

1. 插件接口隔离

# 插件接口定义 - 完全抽象
class MetadataPlugin(Protocol):
    @property
    def metadata(self) -> PluginMetadata: ...
    async def initialize(self, config: Dict[str, Any]) -> bool: ...
    async def process(self, items: List[Dict[str, Any]], context: ProcessingContext) -> List[Dict[str, Any]]: ...
    async def cleanup(self) -> None: ...

2. 依赖注入模式

# 编排器不依赖具体实现,只依赖接口
class EnhancedMetadataOrchestrator:
    def __init__(self):
        self.plugins: Dict[str, MetadataPlugin] = {}  # 插件注册表
        self.batch_processor = IntelligentBatchProcessor(...)  # 独立组件
        
    async def register_plugin(self, name: str, plugin: MetadataPlugin) -> bool:
        self.plugins[name] = plugin  # 动态注册,无硬编码依赖

3. 处理链配置

# 插件链完全可配置,支持运行时修改
self.plugin_chains = {
    ProcessingStage.DISCOVERY: ["tmdb_discovery"],
    ProcessingStage.ENRICHMENT: ["tmdb_scraper", "external_id_enricher"],  # 可替换为任意插件
    ProcessingStage.TRANSFORMATION: ["metadata_transformer"]
}

解耦优势

  1. 独立演化:批量处理器和刮削器可以独立升级
  2. 插件替换:支持不同刮削源(TMDB、IMDB、TVDB)无缝切换
  3. 策略配置:处理策略可在运行时动态调整
  4. 测试友好:各组件可独立单元测试
  5. 扩展性:新插件无需修改核心代码

系统架构流程图

graph TB
    subgraph "客户端层"
        CLIENT[媒体库客户端]
    end
    
    subgraph "编排器层"
        ORCH[EnhancedMetadataOrchestrator]
        PM[插件管理器]
        PC[插件链配置]
        PS[性能监控]
    end
    
    subgraph "插件层"
        TSP[TmdbScraperPlugin]
        MVP[MetadataValidatorPlugin]
        MTP[MetadataTransformerPlugin]
        CSP[CustomScraperPlugin]
    end
    
    subgraph "核心服务层"
        IBP[IntelligentBatchProcessor]
        CM[缓存管理器]
        RL[速率限制器]
    end
    
    subgraph "刮削器层"
        OTS[OptimizedTmdbScraper]
        HTTP[HTTP客户端]
        CACHE[多级缓存]
    end
    
    subgraph "数据源层"
        TMDB[(TMDB API)]
        REDIS[(Redis缓存)]
        DB[(数据库)]
    end
    
    CLIENT -->|"ProcessingRequest"| ORCH
    ORCH --> PM
    ORCH --> PC
    ORCH --> PS
    
    PM --> TSP
    PM --> MVP
    PM --> MTP
    PM --> CSP
    
    ORCH -->|"批量处理"| IBP
    IBP --> CM
    IBP --> RL
    
    TSP -->|"调用"| OTS
    OTS --> HTTP
    HTTP -->|"API调用"| TMDB
    
    OTS --> CACHE
    CACHE --> REDIS
    
    CM -->|"缓存策略"| CACHE
    RL -->|"限流控制"| HTTP
    
    PS -->|"统计信息"| DB
    
    ORCH -->|"ProcessingResponse"| CLIENT

数据处理流程图

sequenceDiagram
    participant Client
    participant Orchestrator
    participant BatchProcessor
    participant PluginManager
    participant TmdbPlugin
    participant TmdbScraper
    participant CacheManager
    participant TMDB_API
    
    Client->>Orchestrator: process_media_batch(request)
    Orchestrator->>Orchestrator: _validate_and_preprocess()
    
    Orchestrator->>PluginManager: 执行插件链
    PluginManager->>TmdbPlugin: process(items, context)
    
    TmdbPlugin->>BatchProcessor: process_batch(items, strategy)
    BatchProcessor->>CacheManager: 检查缓存
    
    alt 缓存未命中
        CacheManager-->>BatchProcessor: cache_miss
        BatchProcessor->>TmdbScraper: enrich_media_batch()
        TmdbScraper->>CacheManager: 分析文件分布
        CacheManager-->>TmdbScraper: distribution_context
        
        TmdbScraper->>TmdbScraper: 构建批量请求队列
        TmdbScraper->>TMDB_API: 批量API调用
        TMDB_API-->>TmdbScraper: season_details(含episodes)
        
        TmdbScraper->>CacheManager: 缓存结果
        TmdbScraper-->>BatchProcessor: enriched_items
    else 缓存命中
        CacheManager-->>BatchProcessor: cached_results
    end
    
    BatchProcessor-->>TmdbPlugin: processed_items
    TmdbPlugin-->>PluginManager: enriched_results
    PluginManager-->>Orchestrator: final_items
    
    Orchestrator->>Orchestrator: _post_process_results()
    Orchestrator-->>Client: ProcessingResponse

插件解耦架构图

graph LR
    subgraph "EnhancedMetadataOrchestrator"
        INTERFACE[Plugin Interface]
        REGISTRY[Plugin Registry]
        CHAIN[Processing Chain]
        SCHEDULER[Plugin Scheduler]
    end
    
    subgraph "插件实现"
        TSP[TmdbScraperPlugin]
        CSP[CustomScraperPlugin]
        MVP[ValidatorPlugin]
        MTP[TransformerPlugin]
    end
    
    subgraph "核心组件"
        IBP[IntelligentBatchProcessor]
        CM[CacheManager]
        RL[RateLimiter]
    end
    
    subgraph "具体刮削器"
        OTS[OptimizedTmdbScraper]
        HTTP[HTTP Client]
    end
    
    INTERFACE -.->|"实现"| TSP
    INTERFACE -.->|"实现"| CSP
    INTERFACE -.->|"实现"| MVP
    INTERFACE -.->|"实现"| MTP
    
    REGISTRY -->|"注册"| TSP
    REGISTRY -->|"注册"| CSP
    CHAIN -->|"配置"| SCHEDULER
    SCHEDULER -->|"调用"| TSP
    
    TSP -->|"使用"| IBP
    TSP -->|"调用"| OTS
    IBP -->|"依赖"| CM
    IBP -->|"依赖"| RL
    OTS -->|"使用"| HTTP
    
    CSP -.->|"可替换"| TSP

批量处理优化流程图

flowchart TD
    A[接收媒体项目] --> B{分析文件分布}
    B --> C[计算复杂度评分]
    C --> D{选择处理策略}
    
    D -->|conservative| E[小批量+高缓存优先级]
    D -->|aggressive| F[大批量+激进预加载]
    D -->|adaptive| G[动态调整策略]
    
    E --> H[构建批量请求队列]
    F --> H
    G --> H
    
    H --> I{检查缓存}
    I -->|命中| J[使用缓存数据]
    I -->|未命中| K[执行API调用]
    
    K --> L{智能预加载决策}
    L -->|预加载季| M[获取season_details]
    L -->|不预加载| N[按需获取基础信息]
    
    M --> O[提取episodes信息]
    N --> P[获取基础metadata]
    
    O --> Q[缓存结果]
    P --> Q
    J --> Q
    
    Q --> R[合并处理结果]
    R --> S[返回丰富后的项目]

总结

通过本次优化,我们实现了:

  1. 94% API调用减少:通过智能批量处理和层级数据复用
  2. 15倍性能提升:通过并行处理和缓存优化
  3. 架构解耦:插件系统与丰富功能完全分离
  4. 智能决策:基于文件分布的自适应处理策略
  5. 可扩展性:支持自定义插件和处理链

新架构不仅大幅提升了性能,还提供了更好的可维护性和扩展性,为未来的功能扩展奠定了坚实基础。

现状与问题

  • 现有实现在 services/scraper/tmdb.py:158-249 的搜索与 services/scraper/tmdb.py:250-303 的详情获取已较好地使用 append_to_response 聚合 credits/images,但对于剧集单集仍走逐集请求(get_episode_detailsservices/scraper/tmdb.py:305-427)。
  • 真实 TMDB API 的季详情端点 /tv/{series_id}/season/{season_number} 返回所有集的完整列表,包含绝大部分单集字段;当前实现未利用此数据复用,导致重复调用与较高延迟。
  • 插件接口 ScraperPlugin 已定义 search/get_details/get_artworks/get_credits,不建议改动公共接口;优化应在插件内部实现,保持对外契约 ScraperResult 不变。

可行性分析

  • 数据层级复用:可行。TMDB季详情具备集列表,足以覆盖单集大部分字段(title/overview/runtime/vote/still_path 等),单独的演职员可按需补充。
  • 批量处理:可行。插件内部可提供批量辅助方法,或在外层批处理器中使用插件现有方法组合;不影响现有 ScraperManager 调用路径。
  • 缓存:可行。先实现进程内 LRU/TTL 缓存,后续可通过依赖注入接入 Redis。对外接口与数据结构保持不变,耦合低。
  • 限流:可行。使用插件内部 asyncio.Semaphore 控制季详情并发;与现有任务处理器内的限流/断路器互补,不冲突。

耦合性分析

  • ScraperPlugin:保持接口不变;新增的批量/缓存方法为插件私有辅助,不影响其他插件;耦合低。
  • ScraperManager:不需改动;仍通过 search_mediaget_detailsget_episode_details 调用;耦合低。
  • MetadataEnricher:已有对 get_episode_details 的调用(metadata_enricher.py:147-159);优化该方法内部逻辑即可,无需改动上层;耦合低。
  • metadata_task_processor:若后续加入批量处理器可复用插件批量辅助函数,但不强制;当前阶段零耦合改造即可生效。

优化方案(插件内实现)

分层数据复用

  • 新增 _get_season_detail(series_id, season_number, language):调用 /tv/{id}/season/{season} 并返回包含 episodes 的结构;构建并缓存 SeasonCache(episodes 列表含标题、概览、时长、评分、剧照 still_path)。
  • 修改 get_episode_details(series_id, season_number, episode_number, ...)
    • 优先命中季缓存,直接从 SeasonCache.episodes 提取该集的基础信息。
    • 若缺失演职员,调用单集 credits 端点补齐并合并为 ScraperResult;避免重复请求 images(仍使用季/剧集详情已有的图片信息)。
    • 仅在季缓存未命中时回退到单集详情端点。

批量辅助(插件私有,不改公共接口)

  • 新增私有方法:
    • async def _batch_get_series_details(series_ids, language) -> Dict[str, Dict]
    • async def _batch_get_season_details(requests, language) -> Dict[str, Dict]
  • 使用并发与 asyncio.Semaphore 控制请求数量(例如季并发=3,剧集并发=2),与任务队列中的限流不冲突。

分层缓存

  • 进程内缓存结构(插件实例级):
    • series_cache: Dict[str, Dict](剧集详情)
    • season_cache: Dict[Tuple[str,int], Dict](季详情,含 episodes)
    • episode_cache: Dict[Tuple[str,int,int], Dict](单集详情/补充 credits)
  • TTL 策略:series=24hseason=24hepisode=7d;简单时间戳失效。后续可通过 configure 支持外部 Redis 注入。

并发与会话管理

  • 在批量请求时使用 asyncio.Semaphore 控制端点并发;保持共享 aiohttp.ClientSession,继续复用已有 _get_session()services/scraper/tmdb.py:142-149)。
  • 关闭钩子 shutdown() 已实现(释放 ClientSession);优化不影响生命周期。

具体改动点(不改变外部契约)

  • get_episode_detailsservices/scraper/tmdb.py:305-427):
    • 先查 season_cache[(series_id, season_number)];命中则构造 ScraperResult,按需补齐 credits
    • 未命中再调用当前单集详情/credits/images路径,结果写入 episode_cache[(series_id,season,episode)]
  • 新增私有 _get_season_detail:聚合季的 episodes,写入 season_cache,用于后续复用。
  • 新增私有批量方法 _batch_get_series_details/_batch_get_season_details,仅供未来批处理器使用;现阶段不影响 ScraperManager

与现有流程的配合

  • MetadataEnricher 在单集场景调用 get_episode_detailsmetadata_enricher.py:147-159);优化后自动受益,无需改动调用方。
  • 电影与剧集 get_details 保持原样,继续使用 append_to_response=external_ids,keywords,credits,images

风险与规避

  • TMDB 端点差异:部分地区字段缺失;季端点可能不返回 still_pathruntime;通过“按需补齐 credits/单集详情”降级处理。
  • 缓存一致性:进程内缓存与外部数据存在滞后;TTL 控制与手动失效(例如在 clear()shutdown() 时清理)。
  • 并发限制:与外层任务处理限流叠加;设置合理并发,默认保守配置。

验收与测试

  • 单元测试:
    • 季缓存命中提速:模拟季详情返回,验证 get_episode_details 不触发单集详情请求。
    • 按需补齐:季缓存缺少 credits 时调用单集 credits 端点,并合并到 ScraperResult
    • 回退路径:缓存未命中时,保证现有逻辑完整可用。
    • 电影路径不受影响:search/get_details 对 MOVIE 保持不变。
  • 性能评估:本地压测对 10 集/50 集剧集的调用次数与时延,确认大幅下降。

交付计划

  1. 在 TMDB 插件内部新增季详情与分层缓存实现;改造 get_episode_details 按“季→补齐→回退”的顺序复用。
  2. 增加插件私有批量方法,为未来批量处理器预留;当前不改公共接口与管理器。
  3. 添加测试用例覆盖缓存命中、按需补齐、回退路径;压测验证调用降低与时延提升。
  4. 文档补充:在 docs/tmdb_刮削器_strategy.md 记录复用层级、缓存策略、并发设置与降级路径(基于现有文档完善实现说明)。

文章作者: Mealsee
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Mealsee !
  目录