mirror of
https://github.com/jxxghp/MoviePilot-Plugins.git
synced 2026-05-24 23:16:49 +00:00
389 lines
13 KiB
Python
389 lines
13 KiB
Python
"""
|
||
流媒体推荐 — TMDB Discover 直连服务
|
||
|
||
直接调用 TMDB discover API,不走 MoviePilot RecommendChain。
|
||
原因:RecommendChain 不支持 with_watch_providers + 时间窗口组合筛选。
|
||
|
||
支持的能力:
|
||
- 按流媒体平台聚合(Netflix / Disney+ / Apple TV+ / Prime Video)
|
||
- 严格按时间窗口过滤(本月 / 近N天)
|
||
- 按热度 + 评分 + 投票人数综合排序
|
||
- 区分电影 / 剧集 / 全部
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
from datetime import date, timedelta
|
||
from typing import Any, Dict, List, Optional
|
||
from urllib.parse import urlencode
|
||
from urllib.request import Request as UrlRequest, urlopen
|
||
|
||
# TMDB Watch Provider ID 映射
|
||
PROVIDER_MAP: Dict[str, int] = {
|
||
"netflix": 8,
|
||
"disney": 337,
|
||
"disney+": 337,
|
||
"apple": 384,
|
||
"apple tv+": 384,
|
||
"prime": 10,
|
||
"prime video": 10,
|
||
"amazon": 10,
|
||
}
|
||
|
||
# 默认聚合平台
|
||
DEFAULT_PROVIDER_IDS: List[int] = [8, 337, 384, 10]
|
||
|
||
# 默认地区(CN / US)
|
||
DEFAULT_WATCH_REGION = "US"
|
||
|
||
# 综合排序权重
|
||
SCORE_WEIGHTS = {
|
||
"popularity": 0.4,
|
||
"vote_average": 0.35,
|
||
"vote_count_norm": 0.15,
|
||
"freshness": 0.1,
|
||
}
|
||
|
||
|
||
class StreamingRecommendService:
|
||
"""TMDB Discover 直连,返回流媒体推荐列表"""
|
||
|
||
def __init__(self, tmdb_api_key: str):
|
||
self._api_key = tmdb_api_key.strip()
|
||
|
||
# ─── 公开入口 ────────────────────────────────────────────────
|
||
|
||
async def query(
|
||
self,
|
||
*,
|
||
media_type: str = "all",
|
||
intent: str = "hot",
|
||
start_date: str = "",
|
||
end_date: str = "",
|
||
window_days: int = 90,
|
||
providers: Optional[List[int]] = None,
|
||
watch_region: str = DEFAULT_WATCH_REGION,
|
||
limit: int = 15,
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
查询流媒体推荐。
|
||
|
||
返回:
|
||
{
|
||
"success": bool,
|
||
"message": str,
|
||
"items": [ { index, title, year, media_type, release_date,
|
||
popularity, vote_average, vote_count,
|
||
providers_str, reason } ],
|
||
"query_params": { ... },
|
||
}
|
||
"""
|
||
if not self._api_key:
|
||
return {
|
||
"success": False,
|
||
"message": "TMDB API Key 未配置,无法查询流媒体推荐。",
|
||
"items": [],
|
||
"query_params": {},
|
||
}
|
||
|
||
provider_ids = providers or DEFAULT_PROVIDER_IDS
|
||
media_type = (media_type or "all").lower()
|
||
intent = (intent or "hot").lower()
|
||
|
||
# ── 时间窗口 ──
|
||
final_start, final_end = self._resolve_time_range(
|
||
start_date=start_date,
|
||
end_date=end_date,
|
||
window_days=window_days,
|
||
)
|
||
|
||
# ── 分别查电影和剧集 ──
|
||
all_items: List[Dict[str, Any]] = []
|
||
|
||
if media_type in ("movie", "all"):
|
||
movie_items = await self._discover(
|
||
media_category="movie",
|
||
intent=intent,
|
||
start_date=final_start,
|
||
end_date=final_end,
|
||
provider_ids=provider_ids,
|
||
watch_region=watch_region,
|
||
limit=limit if media_type == "movie" else limit * 2,
|
||
)
|
||
all_items.extend(movie_items)
|
||
|
||
if media_type in ("tv", "all"):
|
||
tv_items = await self._discover(
|
||
media_category="tv",
|
||
intent=intent,
|
||
start_date=final_start,
|
||
end_date=final_end,
|
||
provider_ids=provider_ids,
|
||
watch_region=watch_region,
|
||
limit=limit if media_type == "tv" else limit * 2,
|
||
)
|
||
all_items.extend(tv_items)
|
||
|
||
# ── 综合排序并截断 ──
|
||
ranked = self._rank(all_items, intent=intent)
|
||
trimmed = ranked[:limit]
|
||
|
||
# ── 编号 & 推荐理由 ──
|
||
for idx, item in enumerate(trimmed, start=1):
|
||
item["index"] = idx
|
||
item["reason"] = self._generate_reason(item, intent)
|
||
|
||
return {
|
||
"success": True,
|
||
"message": "",
|
||
"items": trimmed,
|
||
"query_params": {
|
||
"media_type": media_type,
|
||
"intent": intent,
|
||
"start_date": final_start,
|
||
"end_date": final_end,
|
||
"provider_ids": provider_ids,
|
||
"watch_region": watch_region,
|
||
"count": len(trimmed),
|
||
},
|
||
}
|
||
|
||
# ─── TMDB Discover 直连 ──────────────────────────────────────
|
||
|
||
async def _discover(
|
||
self,
|
||
*,
|
||
media_category: str,
|
||
intent: str,
|
||
start_date: str,
|
||
end_date: str,
|
||
provider_ids: List[int],
|
||
watch_region: str,
|
||
limit: int,
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
调用 TMDB discover/movie 或 discover/tv。
|
||
返回标准化的条目列表。
|
||
"""
|
||
endpoint = "movie" if media_category == "movie" else "tv"
|
||
date_field = (
|
||
"primary_release_date.gte" if media_category == "movie"
|
||
else "first_air_date.gte"
|
||
)
|
||
date_field_end = (
|
||
"primary_release_date.lte" if media_category == "movie"
|
||
else "first_air_date.lte"
|
||
)
|
||
|
||
params: Dict[str, Any] = {
|
||
"api_key": self._api_key,
|
||
"language": "zh-CN",
|
||
"sort_by": "popularity.desc",
|
||
"watch_region": watch_region,
|
||
"with_watch_providers": "|".join(str(p) for p in provider_ids),
|
||
"with_watch_monetization_types": "flatrate",
|
||
"vote_count.gte": self._min_vote_count(intent),
|
||
"page": 1,
|
||
}
|
||
|
||
# 严格时间过滤
|
||
if start_date:
|
||
params[date_field] = start_date
|
||
if end_date:
|
||
params[date_field_end] = end_date
|
||
|
||
url = f"https://api.themoviedb.org/3/discover/{endpoint}?" + urlencode(params)
|
||
|
||
try:
|
||
request = UrlRequest(url=url, headers={"Accept": "application/json"})
|
||
with urlopen(request, timeout=20) as response:
|
||
payload = json.loads(response.read().decode("utf-8", "ignore"))
|
||
except Exception as exc:
|
||
return []
|
||
|
||
raw_results = payload.get("results") or []
|
||
if not isinstance(raw_results, list):
|
||
return []
|
||
|
||
items: List[Dict[str, Any]] = []
|
||
for raw in raw_results:
|
||
if not isinstance(raw, dict):
|
||
continue
|
||
item = self._normalize_item(raw, media_category)
|
||
if item:
|
||
items.append(item)
|
||
|
||
return items[:limit * 2]
|
||
|
||
def _normalize_item(self, raw: Dict[str, Any], media_category: str) -> Optional[Dict[str, Any]]:
|
||
"""把 TMDB 原始条目转为标准格式"""
|
||
title = (
|
||
raw.get("title")
|
||
or raw.get("name")
|
||
or raw.get("original_title")
|
||
or raw.get("original_name")
|
||
or ""
|
||
).strip()
|
||
if not title:
|
||
return None
|
||
|
||
release_date = raw.get("release_date") or raw.get("primary_release_date") or raw.get("first_air_date") or ""
|
||
year = str(release_date)[:4] if release_date else ""
|
||
|
||
popularity = float(raw.get("popularity") or 0)
|
||
vote_average = float(raw.get("vote_average") or 0)
|
||
vote_count = int(raw.get("vote_count") or 0)
|
||
|
||
# 处理 media_type
|
||
raw_type = raw.get("media_type") or ""
|
||
if media_category == "movie":
|
||
display_type = "电影"
|
||
elif media_category == "tv":
|
||
display_type = "剧集"
|
||
else:
|
||
display_type = "电影" if raw_type == "movie" else "剧集"
|
||
|
||
# provider_ids 从原数据获取
|
||
provider_ids_raw = raw.get("origin_country") or []
|
||
|
||
return {
|
||
"title": title,
|
||
"year": year,
|
||
"media_type": display_type,
|
||
"release_date": release_date,
|
||
"popularity": round(popularity, 1),
|
||
"vote_average": round(vote_average, 1),
|
||
"vote_count": vote_count,
|
||
"tmdb_id": raw.get("id"),
|
||
"provider_ids_raw": provider_ids_raw,
|
||
}
|
||
|
||
# ─── 综合排序 ────────────────────────────────────────────────
|
||
|
||
def _rank(self, items: List[Dict[str, Any]], intent: str) -> List[Dict[str, Any]]:
|
||
"""
|
||
按综合分排序。
|
||
intent 影响权重:big_titles 偏重评分,hot 偏重热度,new 偏重新鲜度。
|
||
"""
|
||
if not items:
|
||
return []
|
||
|
||
weights = dict(SCORE_WEIGHTS)
|
||
if intent == "big_titles":
|
||
weights["vote_average"] = 0.45
|
||
weights["popularity"] = 0.25
|
||
weights["vote_count_norm"] = 0.2
|
||
weights["freshness"] = 0.1
|
||
elif intent == "new":
|
||
weights["freshness"] = 0.3
|
||
weights["popularity"] = 0.3
|
||
weights["vote_average"] = 0.25
|
||
weights["vote_count_norm"] = 0.15
|
||
|
||
# 归一化基准
|
||
max_pop = max((i.get("popularity") or 0) for i in items) or 1
|
||
max_votes = max((i.get("vote_count") or 0) for i in items) or 1
|
||
|
||
today = date.today()
|
||
|
||
def score(item: Dict[str, Any]) -> float:
|
||
pop = (item.get("popularity") or 0) / max_pop
|
||
avg = (item.get("vote_average") or 0) / 10.0
|
||
vc = (item.get("vote_count") or 0) / max_votes
|
||
# 新鲜度:发布越近分越高(90天内线性衰减)
|
||
try:
|
||
rd = item.get("release_date") or ""
|
||
days_ago = (today - date.fromisoformat(rd[:10])).days if rd and len(rd) >= 10 else 180
|
||
except Exception:
|
||
days_ago = 180
|
||
freshness = max(0.0, 1.0 - days_ago / 180.0)
|
||
return (
|
||
weights["popularity"] * pop
|
||
+ weights["vote_average"] * avg
|
||
+ weights["vote_count_norm"] * vc
|
||
+ weights["freshness"] * freshness
|
||
)
|
||
|
||
items.sort(key=score, reverse=True)
|
||
return items
|
||
|
||
# ─── 推荐理由 ────────────────────────────────────────────────
|
||
|
||
def _generate_reason(self, item: Dict[str, Any], intent: str) -> str:
|
||
"""基于数据生成一句话推荐理由,不经过 LLM"""
|
||
avg = item.get("vote_average") or 0
|
||
pop = item.get("popularity") or 0
|
||
votes = item.get("vote_count") or 0
|
||
|
||
if intent == "big_titles":
|
||
if avg >= 8.0 and votes >= 500:
|
||
return "高口碑大作"
|
||
if avg >= 7.0 and votes >= 200:
|
||
return "口碑不错"
|
||
return "值得关注"
|
||
|
||
if intent == "new":
|
||
if pop >= 500:
|
||
return "新上线即爆"
|
||
if avg >= 7.0:
|
||
return "新上线口碑佳"
|
||
return "新上线"
|
||
|
||
# hot
|
||
if avg >= 8.0 and pop >= 600:
|
||
return "口碑热度双高"
|
||
if pop >= 800:
|
||
return "热度爆棚"
|
||
if avg >= 7.5:
|
||
return "口碑出色"
|
||
if votes >= 1000:
|
||
return "大众高关注"
|
||
return "近期热门"
|
||
|
||
# ─── 时间范围 ────────────────────────────────────────────────
|
||
|
||
@staticmethod
|
||
def _resolve_time_range(
|
||
*,
|
||
start_date: str = "",
|
||
end_date: str = "",
|
||
window_days: int = 90,
|
||
) -> tuple[str, str]:
|
||
"""
|
||
给定起止日期或窗口天数,返回严格时间范围(YYYY-MM-DD)。
|
||
- specific_month / this_month:严格按自然月
|
||
- recent:从今天往前推 window_days 天
|
||
- last_month:上一个自然月
|
||
"""
|
||
today = date.today()
|
||
|
||
# 如果都给了就直接用
|
||
if start_date and end_date:
|
||
return start_date[:10], end_date[:10]
|
||
|
||
# 如果只给了 start_date,end_date 默认今天
|
||
if start_date and not end_date:
|
||
return start_date[:10], today.isoformat()
|
||
|
||
# 如果只给了 end_date,start_date 往前推 window_days
|
||
if end_date and not start_date:
|
||
try:
|
||
end_d = date.fromisoformat(end_date[:10])
|
||
except Exception:
|
||
end_d = today
|
||
start_d = end_d - timedelta(days=window_days)
|
||
return start_d.isoformat(), end_d.isoformat()
|
||
|
||
# 都没给:默认最近 window_days
|
||
start_d = today - timedelta(days=window_days)
|
||
return start_d.isoformat(), today.isoformat()
|
||
|
||
@staticmethod
|
||
def _min_vote_count(intent: str) -> int:
|
||
"""不同 intent 的最低投票人数门槛"""
|
||
if intent == "big_titles":
|
||
return 300
|
||
if intent == "new":
|
||
return 30
|
||
return 100
|