From 2c18adac40882138eb3295105be80070a3d101ca Mon Sep 17 00:00:00 2001 From: kevinplus66 Date: Mon, 13 Jul 2026 03:54:49 +0800 Subject: [PATCH 1/4] Fix media wall reliability --- AGENT_DEPLOY.md | 2 +- CHANGELOG.md | 9 + README.md | 4 +- README.zh-CN.md | 4 +- app/main.py | 22 + app/models.py | 3 + app/routes/home.py | 77 +- app/services/media_wall.py | 700 +++++++++++++----- frontend/app/page.tsx | 32 +- frontend/components/home/media-wall.tsx | 153 ++-- frontend/hooks/use-home-media-wall.ts | 14 +- frontend/lib/api.ts | 3 + frontend/lib/api/generated.ts | 15 + frontend/lib/types.ts | 2 + frontend/tests/home-media-wall.test.tsx | 150 +++- frontend/tests/home-page.test.tsx | 125 ++++ frontend/tests/use-home-media-wall.test.ts | 49 ++ openapi.json | 15 + tests/test_health_status.py | 35 +- tests/test_home_poster.py | 96 ++- tests/test_media_wall.py | 791 ++++++++++++++++++++- tests/test_security.py | 16 + 22 files changed, 1987 insertions(+), 330 deletions(-) create mode 100644 frontend/tests/home-page.test.tsx create mode 100644 frontend/tests/use-home-media-wall.test.ts diff --git a/AGENT_DEPLOY.md b/AGENT_DEPLOY.md index 738cccb..b9b2f2d 100644 --- a/AGENT_DEPLOY.md +++ b/AGENT_DEPLOY.md @@ -78,7 +78,7 @@ Notes: - `MTEAM_PROACTIVE_ENABLED=false` disables system-owned M-Team background access (country preload, FREE refresh loop, media-wall refresh loop, and PILOT guarded cache refresh) while preserving user-initiated RADAR/search/download endpoints. - `PANEL_COLLECT_INTERVAL` defaults to `60` seconds, controlling PANEL history collection frequency. - `FREE_REFRESH_FAILURE_BACKOFF_SECONDS` defaults to `7200` seconds; on a FREE refresh failure it keeps the old cache and retries after cooldown. -- `MEDIA_WALL_REFRESH_INTERVAL` defaults to `21600` seconds (6 hours), do not set it below `21600`; this is the per-source refresh interval, and `latest / movies / series / hot` rotate on a stagger, refreshing roughly one source every 90 minutes by default. +- `MEDIA_WALL_REFRESH_INTERVAL` defaults to `21600` seconds (6 hours), do not set it below `21600`; this is the per-source steady-state interval. Missing `latest / movies / series / hot` sources bootstrap no more than 90 minutes apart, then resume the configured stagger. - `MEDIA_WALL_STARTUP_DELAY` defaults to `420` seconds, used to stagger away from SONAR's first refresh after container start. - `MEDIA_WALL_REFRESH_FAILURE_BACKOFF_SECONDS` defaults to `7200` seconds; on a media-wall source refresh failure it keeps the old cache and retries after cooldown. - `MEDIA_WALL_DOUBAN_POSTER_FETCHES` defaults to `3`, reading Douban subject pages at low frequency only when M-Team metadata lacks a poster; do not raise it to a large value just to chase full poster coverage. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a14e17..892c37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ ## [Unreleased] +### Home Media Wall Reliability + +- Fixed regional rail conflicts so explicit country/language text overrides stale numeric hints, cached cards with a clear conflicting region are rerouted, and country ID `8` is no longer treated as Chinese. +- Treat missing, empty, or overdue sources as stale, preserve the previous source on invalid empty responses, and cap missing-source bootstrap spacing at 90 minutes even when the steady-state refresh interval is much longer. +- Prioritize never-enriched media within the metadata budget, deduplicate across every normalized Douban/IMDb identity, prefer release-name years, reject known Douban placeholder posters, and keep old films out of `高质量新片新剧`. +- Fixed zero-rating fallback, stale detail sheets, permanent poster failures, error/empty states, rail scrolling, empty-rail layout, source diagnostics, and media-wall polling on the frontend. +- Project internal source caches out before poster rewriting, bound poster proxy responses to 10 MiB with asynchronous DNS lookup, proxy Amazon posters, and add cache headers for the media response and hashed Next.js assets. +- Exposed media metadata TTL/budget in `/api/status` and report stale media walls or unavailable country mappings through runtime warnings. + ### Freeleech Auto-Delete Safety - RADAR(`雷达下载`)下载是用户主动搜索想看的资源,免费监控不再把它当作"免费下车"对象自动删除:`雷达下载` 现在是一票否决标签,即使同时带 `PILOT`/`声呐做种`(qB 按 hash 合并标签时会出现)也不会被自动删;纯 `声呐做种`/`PILOT` 仍按原逻辑清理。 diff --git a/README.md b/README.md index 03ccefa..9c6171c 100644 --- a/README.md +++ b/README.md @@ -188,11 +188,11 @@ If you use PILOT auto-download, also confirm: | `API_DELAY` | No | M-Team API request interval, clamped to 3-10 seconds; default is 6 seconds, below 3 seconds tends to trigger dynamic rate limiting | `6` | | `MTEAM_PROACTIVE_ENABLED` | No | Whether system-owned background jobs may call M-Team; set `false` for a manual-only instance while keeping user-initiated RADAR/search/download available | `true` | | `PANEL_COLLECT_INTERVAL` | No | PANEL data collection interval, in seconds | `60` | -| `MEDIA_WALL_REFRESH_INTERVAL` | No | HOME media-wall per-source background refresh interval, in seconds; defaults to 6 hours, not allowed below 21600; the four sources rotate on a stagger | `21600` | +| `MEDIA_WALL_REFRESH_INTERVAL` | No | HOME media-wall per-source background refresh interval, in seconds; defaults to 6 hours, not allowed below 21600; missing sources bootstrap no more than 90 minutes apart before normal staggering resumes | `21600` | | `MEDIA_WALL_REFRESH_FAILURE_BACKOFF_SECONDS` | No | Backoff after a HOME media-wall source refresh failure, in seconds; keeps the old cache and retries after cooldown | `7200` | | `MEDIA_WALL_STARTUP_DELAY` | No | Delay before the media wall's first refresh after container start, to avoid overlapping SONAR's first refresh | `420` | | `MEDIA_WALL_METADATA_TTL` | No | Cache TTL for M-Team media metadata such as posters, year, and summary, in seconds | `604800` | -| `MEDIA_WALL_MAX_METADATA_FETCHES` | No | Metadata backfill budget per full rotation cycle; a single source refresh uses about 1/4 of the budget | `40` | +| `MEDIA_WALL_MAX_METADATA_FETCHES` | No | Metadata backfill budget per full rotation cycle; a single source refresh uses about 1/4 and prioritizes media never enriched before refreshing old entries | `40` | | `MEDIA_WALL_DOUBAN_POSTER_FETCHES` | No | Max number of Douban-page posters fetched at low frequency per media-wall source refresh; used only when M-Team metadata lacks a poster | `3` | | `PUSHPLUS_TOKEN` | No | PushPlus WeChat push Token | - | | `QBITTORRENT_URL` | No | qBittorrent Web UI URL; use a pinned qB image tag on NAS until intentionally upgrading | - | diff --git a/README.zh-CN.md b/README.zh-CN.md index a4ce1dc..8989f75 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -188,11 +188,11 @@ curl http://localhost:5050/api/status | `API_DELAY` | 否 | M-Team API 请求间隔,限制 3-10 秒,默认 6 秒;低于 3 秒容易触发动态限流 | `6` | | `MTEAM_PROACTIVE_ENABLED` | 否 | 是否允许系统后台主动访问 M-Team;手动专用实例可设为 `false`,仍保留用户主动 RADAR 搜索/下载 | `true` | | `PANEL_COLLECT_INTERVAL` | 否 | PANEL 数据采集间隔,单位秒 | `60` | -| `MEDIA_WALL_REFRESH_INTERVAL` | 否 | HOME 媒体墙每个 source 的后台刷新间隔,单位秒;默认 6 小时,不允许低于 21600;四个 source 会自动错峰轮转 | `21600` | +| `MEDIA_WALL_REFRESH_INTERVAL` | 否 | HOME 媒体墙每个 source 的后台刷新间隔,单位秒;默认 6 小时,不允许低于 21600;缺失 source 会以不超过 90 分钟的间隔先补齐,再恢复正常错峰 | `21600` | | `MEDIA_WALL_REFRESH_FAILURE_BACKOFF_SECONDS` | 否 | HOME 媒体墙 source 刷新失败退避时间,单位秒;失败时保留旧缓存,冷却后再试 | `7200` | | `MEDIA_WALL_STARTUP_DELAY` | 否 | 容器启动后媒体墙首刷延迟,避免和 SONAR 首轮刷新叠加 | `420` | | `MEDIA_WALL_METADATA_TTL` | 否 | 海报、年份、简介等 M-Team 媒体元数据缓存时间,单位秒 | `604800` | -| `MEDIA_WALL_MAX_METADATA_FETCHES` | 否 | 媒体墙完整轮转周期的元数据补充预算;单个 source 刷新会使用约 1/4 预算 | `40` | +| `MEDIA_WALL_MAX_METADATA_FETCHES` | 否 | 媒体墙完整轮转周期的元数据补充预算;单个 source 使用约 1/4,并优先补从未获取过元数据的媒体 | `40` | | `MEDIA_WALL_DOUBAN_POSTER_FETCHES` | 否 | 每个媒体墙 source 刷新时最多低频抓取的豆瓣页面海报数;只在 M-Team 元数据缺海报时使用 | `3` | | `PUSHPLUS_TOKEN` | 否 | PushPlus 微信推送 Token | - | | `QBITTORRENT_URL` | 否 | qBittorrent Web UI 地址;NAS 建议固定 qB 镜像 tag,明确升级时再改 | - | diff --git a/app/main.py b/app/main.py index 220b27d..d003af2 100644 --- a/app/main.py +++ b/app/main.py @@ -23,6 +23,8 @@ from app.config import BEIJING_TZ from app.config import BUILD_COMMIT from app.config import MEDIA_WALL_DOUBAN_POSTER_FETCHES +from app.config import MEDIA_WALL_MAX_METADATA_FETCHES +from app.config import MEDIA_WALL_METADATA_TTL from app.config import MEDIA_WALL_REFRESH_INTERVAL from app.config import MEDIA_WALL_STARTUP_DELAY from app.config import MTEAM_PROACTIVE_ENABLED @@ -59,6 +61,7 @@ from app.routes.torrents import api_torrents from app.services.http_client import http_client from app.services.mteam_api import mt_client +from app.services.media_wall import MISSING_SOURCE_STAGGER_MAX_SECONDS from app.services.media_wall import SOURCE_REFRESH_ORDER from app.services.media_wall import media_wall_service from app.services.panel_collector import get_panel_collector_status @@ -140,6 +143,7 @@ def build_status_warnings( cache_status: dict, panel_collector_status: dict, dependencies: dict, + media_wall_snapshot: Optional[dict] = None, ) -> List[str]: warnings = [] if cache_status.get("stale"): @@ -159,6 +163,14 @@ def build_status_warnings( warnings.append("qbittorrent_unhealthy") if MTEAM_PROACTIVE_ENABLED and not dependencies.get("mteam", {}).get("ok", False): warnings.append("mteam_unhealthy") + if MTEAM_PROACTIVE_ENABLED and (media_wall_snapshot or {}).get("stale"): + warnings.append("media_wall_stale") + if ( + MTEAM_PROACTIVE_ENABLED + and dependencies.get("mteam", {}).get("ok", False) + and not state.COUNTRY_LABELS + ): + warnings.append("country_labels_unavailable") return warnings @@ -305,6 +317,11 @@ async def lifespan(app: FastAPI): async def add_security_headers(request: Request, call_next): """Add security headers to all responses""" response = await call_next(request) + if ( + request.url.path.startswith("/_next/static/") + and response.status_code in {200, 304} + ): + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" # Prevent clickjacking response.headers["X-Frame-Options"] = "DENY" # Prevent MIME sniffing @@ -438,6 +455,7 @@ async def api_status(): cache_status = build_cache_status() panel_collector_status = get_panel_collector_status() dependencies = runtime_status.as_dict() + media_wall_snapshot = media_wall_service.get_snapshot() status_payload = { "status": "ok", @@ -454,6 +472,9 @@ async def api_status(): "media_wall_refresh_interval_seconds": MEDIA_WALL_REFRESH_INTERVAL, "media_wall_startup_delay_seconds": MEDIA_WALL_STARTUP_DELAY, "media_wall_source_stagger_seconds": MEDIA_WALL_REFRESH_INTERVAL // len(SOURCE_REFRESH_ORDER), + "media_wall_missing_source_stagger_max_seconds": MISSING_SOURCE_STAGGER_MAX_SECONDS, + "media_wall_metadata_ttl_seconds": MEDIA_WALL_METADATA_TTL, + "media_wall_max_metadata_fetches": MEDIA_WALL_MAX_METADATA_FETCHES, "media_wall_douban_poster_fetches": MEDIA_WALL_DOUBAN_POSTER_FETCHES, "api_delay_seconds": API_DELAY, "qbittorrent_configured": bool(QBITTORRENT_URL and QBITTORRENT_USER and QBITTORRENT_PASSWORD), @@ -465,6 +486,7 @@ async def api_status(): cache_status, panel_collector_status, dependencies, + media_wall_snapshot, ), } return JSONResponse(status_payload) diff --git a/app/models.py b/app/models.py index ede989e..5defb2c 100644 --- a/app/models.py +++ b/app/models.py @@ -258,6 +258,9 @@ class RuntimeConfigStatus(BaseModel): media_wall_refresh_interval_seconds: int = 0 media_wall_startup_delay_seconds: int = 0 media_wall_source_stagger_seconds: int = 0 + media_wall_missing_source_stagger_max_seconds: int = 0 + media_wall_metadata_ttl_seconds: int = 0 + media_wall_max_metadata_fetches: int = 0 media_wall_douban_poster_fetches: int = 0 api_delay_seconds: float qbittorrent_configured: bool diff --git a/app/routes/home.py b/app/routes/home.py index c2c7ec3..b161d97 100644 --- a/app/routes/home.py +++ b/app/routes/home.py @@ -1,5 +1,6 @@ """Read-only Home media wall API.""" +import asyncio import ipaddress import socket from typing import cast @@ -19,6 +20,18 @@ router = APIRouter() POSTER_PROXY_TIMEOUT_SECONDS = 10.0 +POSTER_MAX_BYTES = 10 * 1024 * 1024 +POSTER_PROXY_CONCURRENCY = 4 +POSTER_ALLOWED_CONTENT_TYPES = frozenset( + { + "image/avif", + "image/gif", + "image/jpeg", + "image/png", + "image/webp", + } +) +_poster_proxy_semaphore = asyncio.Semaphore(POSTER_PROXY_CONCURRENCY) _BLOCKED_IP_NETWORKS = tuple( ipaddress.ip_network(network) @@ -35,6 +48,7 @@ ) ) POSTER_CACHE_CONTROL = "public, max-age=86400" +MEDIA_WALL_CACHE_CONTROL = "private, max-age=60" @router.get("/api/home/poster", include_in_schema=False) @@ -50,7 +64,7 @@ async def get_home_poster(u: str): if hostname is None: raise HTTPException(status_code=400, detail="Invalid poster URL") try: - resolved_ips = _resolve_host_ips(hostname) + resolved_ips = await asyncio.to_thread(_resolve_host_ips, hostname) except Exception as exc: raise HTTPException(status_code=502, detail="Bad Gateway") from exc if not resolved_ips: @@ -63,27 +77,46 @@ async def get_home_poster(u: str): headers["Referer"] = "https://movie.douban.com/" try: - client = await get_http_client() - response = await client.get( - u, - headers=headers, - follow_redirects=False, - timeout=POSTER_PROXY_TIMEOUT_SECONDS, - ) + async with _poster_proxy_semaphore: + client = await get_http_client() + async with client.stream( + "GET", + u, + headers=headers, + follow_redirects=False, + timeout=POSTER_PROXY_TIMEOUT_SECONDS, + ) as response: + if 300 <= response.status_code < 400: + raise HTTPException(status_code=502, detail="Bad Gateway") + if not 200 <= response.status_code < 300: + raise HTTPException(status_code=502, detail="Bad Gateway") + try: + content_length = int(response.headers.get("content-length", "0")) + except ValueError: + content_length = 0 + if content_length > POSTER_MAX_BYTES: + raise HTTPException(status_code=502, detail="Bad Gateway") + content_type = ( + response.headers.get("content-type", "") + .split(";", 1)[0] + .strip() + .lower() + ) + if content_type not in POSTER_ALLOWED_CONTENT_TYPES: + raise HTTPException(status_code=502, detail="Bad Gateway") + + content = bytearray() + async for chunk in response.aiter_bytes(): + if len(content) + len(chunk) > POSTER_MAX_BYTES: + raise HTTPException(status_code=502, detail="Bad Gateway") + content.extend(chunk) + except HTTPException: + raise except Exception as exc: raise HTTPException(status_code=502, detail="Bad Gateway") from exc - if 300 <= response.status_code < 400: - raise HTTPException(status_code=502, detail="Bad Gateway") - if not 200 <= response.status_code < 300: - raise HTTPException(status_code=502, detail="Bad Gateway") - content_type = ( - response.headers.get("content-type", "").split(";", 1)[0].strip().lower() - ) - if not content_type.startswith("image/"): - raise HTTPException(status_code=502, detail="Bad Gateway") return Response( - content=response.content, + content=memoryview(content), media_type=content_type, headers={"Cache-Control": POSTER_CACHE_CONTROL}, ) @@ -111,6 +144,10 @@ def _is_allowlisted_poster_host_for_suffix(hostname: str, suffix: str) -> bool: @router.get("/api/home/media-wall", response_model=MediaWallResponse) -async def get_home_media_wall(): +async def get_home_media_wall(response: Response): """Return the cached Home media wall snapshot without refreshing M-Team.""" - return proxy_media_wall_posters(media_wall_service.get_snapshot()) + response.headers["Cache-Control"] = MEDIA_WALL_CACHE_CONTROL + payload = MediaWallResponse.model_validate( + media_wall_service.get_snapshot() + ).model_dump() + return proxy_media_wall_posters(payload) diff --git a/app/services/media_wall.py b/app/services/media_wall.py index 26c9267..8c993e2 100644 --- a/app/services/media_wall.py +++ b/app/services/media_wall.py @@ -43,6 +43,7 @@ ALLOWED_POSTER_HOST_SUFFIXES: Tuple[str, ...] = ( "doubanio.com", "image.tmdb.org", + "media-amazon.com", "m-team.co", "m-team.cc", "ptdream.net", @@ -192,20 +193,19 @@ def proxy_media_wall_posters(snapshot: Dict[str, Any]) -> Dict[str, Any]: r"成人", ] NON_MOVIE_RE = re.compile("|".join(NON_MOVIE_PATTERNS), re.IGNORECASE) -WESTERN_REGION_RE = re.compile( +WESTERN_COUNTRY_RE = re.compile( r"美国|英[国國]|加拿大|澳大利亚|澳大利亞|新西兰|新西蘭|爱尔兰|愛爾蘭|" r"Ireland|United States|\bUSA\b|U\.S\.|United Kingdom|\bUK\b|Britain|England|" r"Canada|Australia|New Zealand", re.IGNORECASE, ) -ASIAN_DRAMA_REGION_RE = re.compile( - r"韩国|韓國|日本|South Korea|Korea|Japan|韩语|韓語|日语|日語|Korean|Japanese", +CHINESE_LANGUAGE_RE = re.compile( + r"汉语|漢語|普通话|普通話|粤语|粵語|华语|華語|中文(?!\s*字幕)|" + r"\bChinese\b(?!\s*(?:Sub|Subs|Subtitle|Subtitles))|Mandarin|Cantonese", re.IGNORECASE, ) -CHINESE_REGION_RE = re.compile( - r"中国大陆|中國大陸|大陆|大陸|中国|中國|香港|台湾|台灣|新加坡|马来西亚|馬來西亞|" - r"Mainland China|\bChina\b|Hong Kong|Taiwan|Singapore|Malaysia|汉语|漢語|普通话|普通話|粤语|粵語|" - r"华语|華語|中文(?!\s*字幕)|\bChinese\b(?!\s*(?:Sub|Subs|Subtitle|Subtitles))|Mandarin|Cantonese", +ASIAN_DRAMA_LANGUAGE_RE = re.compile( + r"韩语|韓語|日语|日語|Korean|Japanese", re.IGNORECASE, ) ENGLISH_AUDIO_RE = re.compile( @@ -214,8 +214,13 @@ def proxy_media_wall_posters(snapshot: Dict[str, Any]) -> Dict[str, Any]: r"\bEnglish\b(?!\s*(?:Sub|Subs|Subtitle|Subtitles|soft\s*sub|softsubs))", re.IGNORECASE, ) -MAINLAND_CHINA_RE = re.compile( - r"中国大陆|中國大陸|大陆|大陸|Mainland China", +CHINESE_COUNTRY_RE = re.compile( + r"中国大陆|中國大陸|大陆|大陸|中国|中國|香港|台湾|台灣|新加坡|马来西亚|馬來西亞|" + r"Mainland China|\bChina\b|Hong Kong|Taiwan|Singapore|Malaysia", + re.IGNORECASE, +) +ASIAN_DRAMA_COUNTRY_RE = re.compile( + r"韩国|韓國|日本|South Korea|Korea|Japan", re.IGNORECASE, ) @@ -240,11 +245,12 @@ def proxy_media_wall_posters(snapshot: Dict[str, Any]) -> Dict[str, Any]: FALLBACK_RAIL_IDS = frozenset(("quality_latest", "popular_media")) SOURCE_REFRESH_ORDER = ["latest", "movies", "series", "hot"] MEDIA_WALL_SOURCE_PAGE_SIZE = 200 +MISSING_SOURCE_STAGGER_MAX_SECONDS = 90 * 60 RECENT_UPLOAD_WINDOW_DAYS = 180 RECENT_COMPLETED_SERIES_YEAR_WINDOW = 2 RECENT_FOREIGN_MOVIE_YEAR_WINDOW = 3 RELAXED_RAIL_VISIBLE_FLOOR = 6 -CHINESE_REGION_COUNTRY_IDS = {1, 2, 3, 8, 108, 109, 110} +CHINESE_REGION_COUNTRY_IDS = {1, 2, 3, 108, 109, 110} ASIAN_REGION_COUNTRY_IDS = {5, 6} WESTERN_REGION_COUNTRY_IDS = {4, 7} COMPLETE_SEASON_RE = re.compile( @@ -292,9 +298,10 @@ def __init__( self.snapshot: Optional[Dict[str, Any]] = _read_json(self.snapshot_path) self.metadata_cache: Dict[str, Any] = _read_json(self.metadata_path) or {} self._refreshing = False + self._volatile_refresh_error: Optional[Dict[str, Any]] = None def should_refresh(self, now: Optional[datetime] = None) -> bool: - """Return True when no snapshot exists or the 6-hour interval elapsed.""" + """Return True when no snapshot exists or the configured interval elapsed.""" now = now or datetime.now(BEIJING_TZ) if not self.snapshot: return True @@ -309,14 +316,18 @@ def get_snapshot(self, now: Optional[datetime] = None) -> Dict[str, Any]: """Return the current cached snapshot without touching M-Team.""" now = now or datetime.now(BEIJING_TZ) if not self.snapshot: - return self._empty_snapshot(now, status="empty") - - payload = _sanitize_snapshot(self.snapshot, now) - payload["stale"] = ( - bool(payload.get("stale")) - or self.should_refresh(now) - or self._active_refresh_backoff_until(now) is not None - ) + payload = self._empty_snapshot(now, status="empty") + else: + payload = _sanitize_snapshot(self.snapshot, now) + payload["stale"] = ( + bool(payload.get("stale")) + or self.should_refresh(now) + or self._active_refresh_backoff_until(now) is not None + ) + _apply_source_health(payload, now, self.refresh_interval_seconds) + if self._volatile_refresh_error: + payload.update(self._volatile_refresh_error) + payload["stale"] = True return payload async def refresh_once(self, now: Optional[datetime] = None) -> Dict[str, Any]: @@ -346,9 +357,12 @@ async def refresh_once(self, now: Optional[datetime] = None) -> Dict[str, Any]: "sources": _source_snapshot_payload(torrents_by_source, now), **rail_payload, } - self.snapshot = snapshot - self._persist_snapshot() - self._persist_metadata() + _apply_source_health(snapshot, now, self.refresh_interval_seconds) + try: + self._commit_snapshot(snapshot) + except Exception as exc: + logger.error("媒体墙缓存写入失败: %s", exc) + return self._snapshot_after_persist_failure(now, str(exc)) return snapshot except Exception as exc: logger.error("媒体墙刷新失败: %s", exc) @@ -371,9 +385,7 @@ async def refresh_source_once(self, source: str, now: Optional[datetime] = None) try: payload = _search_payloads()[source] data = await self.client.search_torrents(payload, label=f"media-wall:{source}") - if not isinstance(data, dict) or "data" not in data: - raise RuntimeError(f"M-Team source {source} returned an invalid search payload") - items = _dict_items(data.get("data", [])) + items = _validated_search_items(data, source) sources = self._sources_from_snapshot() sources[source] = { @@ -406,9 +418,12 @@ async def refresh_source_once(self, source: str, now: Optional[datetime] = None) "sources": sources, **rail_payload, } - self.snapshot = snapshot - self._persist_snapshot() - self._persist_metadata() + _apply_source_health(snapshot, now, self.refresh_interval_seconds) + try: + self._commit_snapshot(snapshot) + except Exception as exc: + logger.error("媒体墙缓存写入失败: %s", exc) + return self._snapshot_after_persist_failure(now, str(exc)) return snapshot except Exception as exc: logger.error("媒体墙 source 刷新失败 (%s): %s", source, exc) @@ -424,13 +439,19 @@ async def run_background_loop(self) -> None: now = datetime.now(BEIJING_TZ) source = self._next_due_source(now) if source: - await self.refresh_source_once(source, now=now) + result = await self.refresh_source_once(source, now=now) after_refresh = datetime.now(BEIJING_TZ) - sleep_seconds = ( - self._seconds_until_next_due(after_refresh) - if self._active_refresh_backoff_until(after_refresh) is not None - else self.source_stagger_seconds - ) + if result.get("refresh_status") == "error": + next_refresh = _parse_datetime(result.get("next_refresh")) + sleep_seconds = ( + max(1, ceil((next_refresh - after_refresh).total_seconds())) + if next_refresh and next_refresh > after_refresh + else self.refresh_failure_backoff_seconds + ) + else: + sleep_seconds = self._post_source_refresh_delay( + self._sources_from_snapshot(), after_refresh + ) await asyncio.sleep(sleep_seconds) continue except Exception as exc: @@ -445,9 +466,7 @@ async def _fetch_torrents_by_source(self) -> Dict[str, List[Dict[str, Any]]]: payload, label=f"media-wall:{source}", ) - if not isinstance(data, dict) or "data" not in data: - raise RuntimeError(f"M-Team source {source} returned an invalid search payload") - result[source] = _dict_items(data.get("data", [])) + result[source] = _validated_search_items(data, source) return result async def _metadata_for_torrents( @@ -462,7 +481,14 @@ async def _metadata_for_torrents( poster_fetches = 0 poster_fetch_limit = min(self.max_douban_poster_fetches, fetch_limit) - for torrent in _flatten_sources(torrents_by_source): + torrents = _flatten_sources(torrents_by_source) + torrents.sort( + key=lambda torrent: _has_compatible_cached_metadata( + self.metadata_cache, torrent + ) + ) + + for torrent in torrents: for cache_key, cache_aliases, source_type, source_value in _metadata_sources(torrent): cached = _lookup_cache_entry(self.metadata_cache, [cache_key, *cache_aliases]) cached_data = dict((cached or {}).get("data") or {}) @@ -498,7 +524,8 @@ async def _metadata_for_torrents( if ( source_type == "douban" and data - and not data.get("coverUrl") + and not _metadata_poster_url(data) + and not _metadata_conflicts_with_torrent(torrent, data) and poster_fetches < poster_fetch_limit and not _is_recent_douban_poster_attempt( cache_entry or cached, @@ -524,7 +551,8 @@ async def _metadata_for_torrents( if data: _store_metadata_aliases(metadata, [source_value, cache_key, *cache_aliases], data) - break + if not _metadata_conflicts_with_torrent(torrent, data): + break return metadata @@ -535,7 +563,7 @@ def _sources_from_snapshot(self) -> Dict[str, Dict[str, Any]]: return { source: { "last_refreshed": data.get("last_refreshed"), - "items": _dict_items(data.get("items", [])), + "items": _valid_source_items(data.get("items", [])), } for source, data in sources.items() if source in SOURCE_REFRESH_ORDER and isinstance(data, dict) @@ -546,7 +574,9 @@ def _next_due_source(self, now: datetime) -> Optional[str]: return None sources = self._sources_from_snapshot() for source in SOURCE_REFRESH_ORDER: - if source not in sources: + if source not in sources or not _valid_source_items( + sources[source].get("items", []) + ): return source due_sources = [] @@ -573,26 +603,49 @@ def _seconds_until_next_due_from_sources( sources: Dict[str, Dict[str, Any]], now: datetime, ) -> int: - if len(sources) < len(SOURCE_REFRESH_ORDER): - return self.source_stagger_seconds + if any( + source not in sources + or not _valid_source_items((sources.get(source) or {}).get("items", [])) + for source in SOURCE_REFRESH_ORDER + ): + return self._source_stagger_for(sources) due_times = [] for source in SOURCE_REFRESH_ORDER: last_refreshed = _parse_datetime((sources.get(source) or {}).get("last_refreshed")) if not last_refreshed: - return self.source_stagger_seconds + return self._source_stagger_for(sources) due_times.append(last_refreshed + timedelta(seconds=self.refresh_interval_seconds)) next_due = min(due_times) return max(60, int((next_due - now).total_seconds())) + def _source_stagger_for(self, sources: Dict[str, Dict[str, Any]]) -> int: + if any( + source not in sources + or not _valid_source_items((sources.get(source) or {}).get("items", [])) + for source in SOURCE_REFRESH_ORDER + ): + return min(self.source_stagger_seconds, MISSING_SOURCE_STAGGER_MAX_SECONDS) + return self.source_stagger_seconds + + def _post_source_refresh_delay( + self, + sources: Dict[str, Dict[str, Any]], + now: datetime, + ) -> int: + return max( + self._source_stagger_for(sources), + self._seconds_until_next_due_from_sources(sources, now), + ) + def _next_refresh_at( self, now: datetime, sources: Optional[Dict[str, Dict[str, Any]]] = None, ) -> datetime: seconds = ( - self._seconds_until_next_due_from_sources(sources, now) + self._post_source_refresh_delay(sources, now) if sources is not None else self._seconds_until_next_due(now) ) @@ -632,8 +685,12 @@ def _snapshot_with_active_backoff( snapshot = dict(self.snapshot or self._empty_snapshot(now, status="empty")) snapshot["stale"] = True snapshot["next_refresh"] = backoff_until.isoformat() + try: + self._persist_snapshot(snapshot) + except Exception as exc: + logger.error("媒体墙 backoff 缓存写入失败: %s", exc) + return snapshot self.snapshot = snapshot - self._persist_snapshot() return snapshot def _snapshot_after_refresh_failure(self, now: datetime, error: str) -> Dict[str, Any]: @@ -649,8 +706,26 @@ def _snapshot_after_refresh_failure(self, now: datetime, error: str) -> Dict[str snapshot["last_error"] = error snapshot["refresh_backoff_until"] = backoff_until.isoformat() snapshot["refresh_backoff_reason"] = error + try: + self._persist_snapshot(snapshot) + except Exception as exc: + logger.error("媒体墙失败状态写入失败: %s", exc) + self._volatile_refresh_error = _refresh_error_fields(snapshot) + return snapshot self.snapshot = snapshot - self._persist_snapshot() + self._volatile_refresh_error = None + return snapshot + + def _snapshot_after_persist_failure(self, now: datetime, error: str) -> Dict[str, Any]: + backoff_until = now + timedelta(seconds=self.refresh_failure_backoff_seconds) + snapshot = dict(self.snapshot or self._empty_snapshot(now, status="empty")) + snapshot["next_refresh"] = backoff_until.isoformat() + snapshot["stale"] = True + snapshot["refresh_status"] = "error" + snapshot["last_error"] = error + snapshot["refresh_backoff_until"] = backoff_until.isoformat() + snapshot["refresh_backoff_reason"] = error + self._volatile_refresh_error = _refresh_error_fields(snapshot) return snapshot def _empty_snapshot( @@ -670,8 +745,14 @@ def _empty_snapshot( "diagnostics": _empty_diagnostics(), } - def _persist_snapshot(self) -> None: - _write_json(self.snapshot_path, self.snapshot or {}) + def _commit_snapshot(self, snapshot: Dict[str, Any]) -> None: + self._persist_metadata() + self._persist_snapshot(snapshot) + self.snapshot = snapshot + self._volatile_refresh_error = None + + def _persist_snapshot(self, snapshot: Optional[Dict[str, Any]] = None) -> None: + _write_json(self.snapshot_path, snapshot if snapshot is not None else self.snapshot or {}) def _persist_metadata(self) -> None: _write_json(self.metadata_path, self.metadata_cache) @@ -752,11 +833,7 @@ def is_recent_series_update_candidate( if not is_media_wall_series_candidate(torrent): return False - year = ( - _metadata_year(metadata) - or _year_from_torrent_name(str(torrent.get("name", ""))) - or _year_from_torrent_name(str(torrent.get("smallDescr", ""))) - ) + year = _media_year(torrent, metadata) if year is not None: if year >= _min_series_update_year(now): return True @@ -801,57 +878,97 @@ def _torrent_text(torrent: Dict[str, Any]) -> str: ) -def _combined_text(torrent: Dict[str, Any], metadata: Dict[str, Any]) -> str: - return " | ".join(part for part in (_metadata_text(metadata), _torrent_text(torrent)) if part) +def _first_region_hint(text: str, patterns: Tuple[Tuple[str, Any], ...]) -> Optional[str]: + matches = [ + (match.start(), region) + for region, pattern in patterns + if (match := pattern.search(text)) is not None + ] + return min(matches)[1] if matches else None + -def _metadata_region_hint(metadata: Dict[str, Any]) -> Optional[str]: - structured_parts: List[str] = [] - for key in ("country", "countries", "language", "languages", "originalTitle"): - value = metadata.get(key) +def _country_region_hint(text: str) -> Optional[str]: + return _first_region_hint( + text, + ( + ("chinese", CHINESE_COUNTRY_RE), + ("asian", ASIAN_DRAMA_COUNTRY_RE), + ("western", WESTERN_COUNTRY_RE), + ), + ) + + +def _language_region_hint(text: str) -> Optional[str]: + return _first_region_hint( + text, + ( + ("chinese", CHINESE_LANGUAGE_RE), + ("asian", ASIAN_DRAMA_LANGUAGE_RE), + ("western", ENGLISH_AUDIO_RE), + ), + ) + + +def _text_region_hint(text: str) -> Optional[str]: + return _country_region_hint(text) or _language_region_hint(text) + + +def _metadata_fields_text(metadata: Dict[str, Any], field_names: Tuple[str, ...]) -> str: + parts: List[str] = [] + for field_name in field_names: + value = metadata.get(field_name) if isinstance(value, list): - structured_parts.extend(str(item) for item in value if item) + parts.extend(str(item) for item in value if item) elif isinstance(value, dict): - structured_parts.extend(str(item) for item in value.values() if item) + parts.extend(str(item) for item in value.values() if item) elif value: - structured_parts.append(str(value)) + parts.append(str(value)) + return " | ".join(parts) - for text in (" | ".join(structured_parts), _metadata_text(metadata)): - if not text: - continue - if CHINESE_REGION_RE.search(text): - return "chinese" - if ASIAN_DRAMA_REGION_RE.search(text): - return "asian" - if WESTERN_REGION_RE.search(text) or ENGLISH_AUDIO_RE.search(text): - return "western" - if structured_parts: - break - return None +def _metadata_country_region_hint(metadata: Dict[str, Any]) -> Optional[str]: + return _country_region_hint(_metadata_fields_text(metadata, ("country", "countries"))) -def _torrent_region_hint(torrent: Dict[str, Any]) -> Optional[str]: + +def _metadata_language_region_hint(metadata: Dict[str, Any]) -> Optional[str]: + return _language_region_hint( + _metadata_fields_text(metadata, ("language", "languages", "originalTitle")) + ) + + +def _torrent_country_region_hint(torrent: Dict[str, Any]) -> Optional[str]: if _has_chinese_country_code(torrent): return "chinese" if _has_asian_country_code(torrent): return "asian" if _has_western_country_code(torrent): return "western" + return None + +def _torrent_region_hint(torrent: Dict[str, Any]) -> Optional[str]: text = _torrent_text(torrent) - if CHINESE_REGION_RE.search(text): - return "chinese" - if ASIAN_DRAMA_REGION_RE.search(text): - return "asian" - if WESTERN_REGION_RE.search(text) or ENGLISH_AUDIO_RE.search(text): - return "western" - return None + return ( + _country_region_hint(text) + or _language_region_hint(text) + or _torrent_country_region_hint(torrent) + ) + + +def _media_region_hint(torrent: Dict[str, Any], metadata: Dict[str, Any]) -> Optional[str]: + torrent_text = _torrent_text(torrent) + return ( + _country_region_hint(torrent_text) + or _metadata_country_region_hint(metadata) + or _language_region_hint(torrent_text) + or _metadata_language_region_hint(metadata) + or _torrent_country_region_hint(torrent) + or _text_region_hint(_metadata_text(metadata)) + ) def _series_region_hint(torrent: Dict[str, Any], metadata: Dict[str, Any]) -> Optional[str]: - metadata_region = _metadata_region_hint(metadata) - if metadata_region: - return metadata_region - return _torrent_region_hint(torrent) + return _media_region_hint(torrent, metadata) @@ -906,41 +1023,6 @@ def _is_chinese_series_match(torrent: Dict[str, Any], metadata: Dict[str, Any], ) -def _is_western_series(torrent: Dict[str, Any], metadata: Dict[str, Any], now: datetime) -> bool: - return _is_western_series_match(torrent, metadata, now) and has_4k_quality(torrent) - - -def _is_asian_series(torrent: Dict[str, Any], metadata: Dict[str, Any], now: datetime) -> bool: - return _is_asian_series_match(torrent, metadata, now) and has_4k_quality(torrent) - - -def _is_chinese_series(torrent: Dict[str, Any], metadata: Dict[str, Any], now: datetime) -> bool: - return _is_chinese_series_match(torrent, metadata, now) and has_4k_quality(torrent) - - -def _is_foreign_recent_movie(torrent: Dict[str, Any], metadata: Dict[str, Any], now: datetime) -> bool: - if classify_media_type(torrent) != "movie": - return False - if not has_4k_quality(torrent): - return False - if not is_media_wall_movie_candidate(torrent): - return False - if _has_chinese_country_code(torrent): - return False - - year = _metadata_year(metadata) or _year_from_torrent_name(str(torrent.get("name", ""))) - if year is None: - return False - if year >= now.year - 2: - return not CHINESE_REGION_RE.search(_combined_text(torrent, metadata)) - if year < now.year - RECENT_FOREIGN_MOVIE_YEAR_WINDOW: - return False - - return _is_recent_upload(torrent, now) and not CHINESE_REGION_RE.search( - _combined_text(torrent, metadata) - ) - - def is_media_wall_movie_candidate(torrent: Dict[str, Any]) -> bool: """Return True for movie-like items suitable for the Home media wall.""" text = " ".join( @@ -1014,8 +1096,8 @@ def _created_timestamp(card: Dict[str, Any]) -> float: return parsed.timestamp() if parsed else 0.0 -def _sort_home_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - ranked = sorted( +def _rank_home_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return sorted( cards, key=lambda card: ( _quality_score(card), @@ -1025,7 +1107,10 @@ def _sort_home_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: ), reverse=True, ) - return _dedupe_cards(ranked) + + +def _sort_home_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + return _dedupe_cards(_rank_home_cards(cards)) def build_media_wall_rails( @@ -1177,9 +1262,31 @@ def _source_snapshot_payload( } +def _validated_search_items(data: Any, source: str) -> List[Dict[str, Any]]: + if not isinstance(data, dict) or not isinstance(data.get("data"), list): + raise RuntimeError(f"M-Team source {source} returned an invalid search payload") + items = _valid_source_items(data["data"]) + if not items: + raise RuntimeError(f"M-Team source {source} returned an empty search payload") + return items + + +def _refresh_error_fields(snapshot: Dict[str, Any]) -> Dict[str, Any]: + return { + field_name: snapshot.get(field_name) + for field_name in ( + "next_refresh", + "refresh_status", + "last_error", + "refresh_backoff_until", + "refresh_backoff_reason", + ) + } + + def _source_items(sources: Dict[str, Dict[str, Any]]) -> Dict[str, List[Dict[str, Any]]]: return { - source: _dict_items((sources.get(source) or {}).get("items", [])) + source: _valid_source_items((sources.get(source) or {}).get("items", [])) for source in SOURCE_REFRESH_ORDER } @@ -1233,9 +1340,26 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any if isinstance(item, dict) and _is_valid_cached_media_wall_item(item) ] if rail_id in current_rail_ids: - items_by_rail[rail_id].extend( - item for item in items if _is_cached_family_item_allowed(rail_id, item, now) - ) + for item in items: + if not _is_cached_family_item_allowed(rail_id, item, now): + continue + target_rail = rail_id + if rail_id in {"western_series", "asian_series", "chinese_series"}: + region = _cached_item_region_hint(item) + if region: + target_rail = { + "western": "western_series", + "asian": "asian_series", + "chinese": "chinese_series", + }[region] + item = dict(item) + label = {"western": "英美", "asian": "日韩", "chinese": "华语"}[region] + item["rail_reason"] = ( + f"{label} 4K 剧集更新" + if has_4k_quality(item) + else f"{label}高质量剧集更新" + ) + items_by_rail[target_rail].append(item) continue for item in items: @@ -1247,12 +1371,12 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any claimed_keys: Set[str] = set() for rail_id, _, _ in RAIL_DEFINITIONS: cards = [] - for item in _sort_home_cards(items_by_rail[rail_id]): - key = _card_key(item) - if key in claimed_keys: + for item in _rank_home_cards(items_by_rail[rail_id]): + keys = _card_identity_keys(item) + if keys & claimed_keys: continue cards.append(item) - claimed_keys.add(key) + claimed_keys.update(keys) sanitized_rails.append(_rail(rail_id, cards)) payload["rails"] = sanitized_rails @@ -1274,7 +1398,7 @@ def _diagnostics_for_sanitized_snapshot( if not isinstance(source_data, dict): source_diagnostics[source] = 0 continue - source_diagnostics[source] = len(_dict_items(source_data.get("items", []))) + source_diagnostics[source] = len(_valid_source_items(source_data.get("items", []))) rail_diagnostics = {} for rail in rails: @@ -1300,6 +1424,39 @@ def _diagnostics_for_sanitized_snapshot( } +def _apply_source_health( + payload: Dict[str, Any], + now: datetime, + refresh_interval_seconds: int, +) -> Dict[str, Any]: + sources = payload.get("sources") if isinstance(payload.get("sources"), dict) else {} + missing_sources: List[str] = [] + stale_sources: List[str] = [] + for source in SOURCE_REFRESH_ORDER: + source_data = sources.get(source) + items = ( + _valid_source_items(source_data.get("items", [])) + if isinstance(source_data, dict) + else [] + ) + if not items: + missing_sources.append(source) + continue + last_refreshed = _parse_datetime(source_data.get("last_refreshed")) + if ( + last_refreshed is None + or (now - last_refreshed).total_seconds() >= refresh_interval_seconds + ): + stale_sources.append(source) + + diagnostics = dict(payload.get("diagnostics") or {}) + diagnostics["missing_sources"] = missing_sources + diagnostics["stale_sources"] = stale_sources + payload["diagnostics"] = diagnostics + payload["stale"] = bool(payload.get("stale") or missing_sources or stale_sources) + return payload + + def _is_cached_family_item_allowed(rail_id: str, item: Dict[str, Any], now: datetime) -> bool: if item.get("media_type") == "anime": return False @@ -1309,7 +1466,9 @@ def _is_cached_family_item_allowed(rail_id: str, item: Dict[str, Any], now: date return _has_quality_for_relaxed_fill(item) and _is_cached_foreign_movie_item_allowed(item, now) if rail_id == "classic_restorations": return _is_cached_classic_item_allowed(item, now) - if rail_id in {"quality_latest", "popular_media"}: + if rail_id == "quality_latest": + return _is_cached_quality_latest_item_allowed(item, now) + if rail_id == "popular_media": return _is_cached_fallback_item_allowed(item, now) return False @@ -1326,6 +1485,17 @@ def _is_cached_fallback_item_allowed(item: Dict[str, Any], now: datetime) -> boo return _is_cached_recent_series_update_allowed(item, now) return False + +def _is_cached_quality_latest_item_allowed(item: Dict[str, Any], now: datetime) -> bool: + if not _is_cached_fallback_item_allowed(item, now): + return False + if item.get("media_type") != "movie": + return True + year = _cached_item_year(item) + pseudo_torrent = _cached_item_as_torrent(item, media_type="movie") + pseudo_torrent["createdDate"] = item.get("created_date") + return bool(year and year >= now.year - 2 and _is_recent_upload(pseudo_torrent, now)) + def _cached_item_as_torrent(item: Dict[str, Any], *, media_type: str) -> Dict[str, Any]: category = next(iter(SERIES_CATEGORIES if media_type == "series" else MOVIE_CATEGORIES)) return { @@ -1336,12 +1506,32 @@ def _cached_item_as_torrent(item: Dict[str, Any], *, media_type: str) -> Dict[st } +def _cached_item_region_hint(item: Dict[str, Any]) -> Optional[str]: + stored = item.get("region_hint") + if stored in {"western", "asian", "chinese"}: + return str(stored) + + for segment in re.split(r"[||]", str(item.get("description") or "")): + text = segment.strip() + if not text: + continue + for region, pattern in ( + ("chinese", CHINESE_COUNTRY_RE), + ("asian", ASIAN_DRAMA_COUNTRY_RE), + ("western", WESTERN_COUNTRY_RE), + ("chinese", CHINESE_LANGUAGE_RE), + ("asian", ASIAN_DRAMA_LANGUAGE_RE), + ("western", ENGLISH_AUDIO_RE), + ): + if pattern.fullmatch(text): + return region + return None + + def _is_cached_foreign_movie_item_allowed(item: Dict[str, Any], now: datetime) -> bool: if item.get("media_type") != "movie" or not _is_cached_movie_item_allowed(item): return False - year = _safe_int(item.get("year")) or _year_from_torrent_name( - str(item.get("torrent_name") or item.get("title") or "") - ) + year = _cached_item_year(item) if not year: return False if year >= now.year - 2: @@ -1355,20 +1545,13 @@ def _is_cached_foreign_movie_item_allowed(item: Dict[str, Any], now: datetime) - return False else: return False - text = " ".join( - str(item.get(field_name, "")) - for field_name in ("torrent_name", "title", "description") - if item.get(field_name) - ) - return not CHINESE_REGION_RE.search(text) + return _cached_item_region_hint(item) != "chinese" def _is_cached_classic_item_allowed(item: Dict[str, Any], now: datetime) -> bool: if item.get("media_type") != "movie" or not _is_cached_movie_item_allowed(item): return False - year = _safe_int(item.get("year")) or _year_from_torrent_name( - str(item.get("torrent_name") or item.get("title") or "") - ) + year = _cached_item_year(item) if not year: return False if year and year > now.year - 3: @@ -1411,9 +1594,7 @@ def _is_cached_recent_series_update_allowed(item: Dict[str, Any], now: datetime) if not _is_cached_series_item_allowed(item): return False - year = _safe_int(item.get("year")) or _year_from_torrent_name( - str(item.get("torrent_name") or item.get("title") or "") - ) + year = _cached_item_year(item) if year: if year >= _min_series_update_year(now): return True @@ -1429,6 +1610,14 @@ def _is_cached_recent_series_update_allowed(item: Dict[str, Any], now: datetime) return extract_episode_token(text) is not None +def _cached_item_year(item: Dict[str, Any]) -> Optional[int]: + media_type = str(item.get("media_type") or "movie") + return _media_year( + _cached_item_as_torrent(item, media_type=media_type), + {"year": item.get("year"), "title": item.get("title")}, + ) + + def _is_cached_movie_item_allowed(item: Dict[str, Any]) -> bool: text = " ".join( str(item.get(field_name, "")) @@ -1530,16 +1719,17 @@ def _merge_strict_and_relaxed( limit: int, ) -> Tuple[List[Dict[str, Any]], Dict[str, int]]: strict = _sort_home_cards(strict_cards) - relaxed = _sort_home_cards(relaxed_cards) + relaxed = _rank_home_cards(relaxed_cards) cards = strict[:limit] floor = min(limit, RELAXED_RAIL_VISIBLE_FLOOR) if len(cards) < floor: claimed = _card_keys(cards) for card in relaxed: - if _card_key(card) in claimed: + keys = _card_identity_keys(card) + if keys & claimed: continue cards.append(card) - claimed.add(_card_key(card)) + claimed.update(keys) if len(cards) >= floor: break return cards, _rail_counts(cards) @@ -1563,7 +1753,7 @@ def _rail_counts( def _source_diagnostics(torrents_by_source: Dict[str, List[Dict[str, Any]]]) -> Dict[str, int]: return { - source: len(_dict_items(torrents_by_source.get(source, []))) + source: len(_valid_source_items(torrents_by_source.get(source, []))) for source in SOURCE_REFRESH_ORDER } @@ -1571,6 +1761,8 @@ def _source_diagnostics(torrents_by_source: Dict[str, List[Dict[str, Any]]]) -> def _empty_diagnostics() -> Dict[str, Any]: return { "sources": {source: 0 for source in SOURCE_REFRESH_ORDER}, + "missing_sources": list(SOURCE_REFRESH_ORDER), + "stale_sources": [], "rails": { rail_id: {"items": 0, "strict": 0, "relaxed": 0, "fallback": 0} for rail_id, _, _ in RAIL_DEFINITIONS @@ -1601,20 +1793,18 @@ def _is_foreign_recent_movie_match(torrent: Dict[str, Any], metadata: Dict[str, return False if not is_media_wall_movie_candidate(torrent): return False - if _has_chinese_country_code(torrent): + if _media_region_hint(torrent, metadata) == "chinese": return False - year = _metadata_year(metadata) or _year_from_torrent_name(str(torrent.get("name", ""))) + year = _media_year(torrent, metadata) if year is None: return False if year >= now.year - 2: - return not CHINESE_REGION_RE.search(_combined_text(torrent, metadata)) + return True if year < now.year - RECENT_FOREIGN_MOVIE_YEAR_WINDOW: return False - return _is_recent_upload(torrent, now) and not CHINESE_REGION_RE.search( - _combined_text(torrent, metadata) - ) + return _is_recent_upload(torrent, now) def _series_items_for_region( @@ -1638,12 +1828,12 @@ def _series_items_for_region( continue if has_4k_quality(item): card = _to_media_card(item, metadata_by_key, strict_reason) - if _card_key(card) not in excluded_keys: + if not (_card_identity_keys(card) & excluded_keys): strict_cards.append(card) continue if _has_high_quality_1080p(item): card = _to_media_card(item, metadata_by_key, relaxed_reason) - if _card_key(card) not in excluded_keys: + if not (_card_identity_keys(card) & excluded_keys): relaxed_cards.append(card) return _merge_strict_and_relaxed(strict_cards, relaxed_cards, limit) @@ -1689,7 +1879,7 @@ def _classic_collection_items( if not is_media_wall_movie_candidate(item): continue metadata = _lookup_metadata(item, metadata_by_key) - year = _metadata_year(metadata) or _year_from_torrent_name(str(item.get("name", ""))) + year = _media_year(item, metadata) if year is None or year > max_recent_year: continue card = _to_media_card( @@ -1697,7 +1887,7 @@ def _classic_collection_items( metadata_by_key, "经典 4K 高质量收藏" if has_4k_quality(item) else "经典高质量收藏", ) - if _card_key(card) in excluded_keys: + if _card_identity_keys(card) & excluded_keys: continue tags = set(card.get("quality_tags", [])) if has_4k_quality(card): @@ -1718,8 +1908,13 @@ def _is_quality_latest_candidate( media_type = classify_media_type(item) if media_type == "movie": metadata = _lookup_metadata(item, metadata_by_key) - year = _metadata_year(metadata) or _year_from_torrent_name(str(item.get("name", ""))) - return bool(year) and is_media_wall_movie_candidate(item) and _is_recent_upload(item, now) + year = _media_year(item, metadata) + return bool( + year + and year >= now.year - 2 + and is_media_wall_movie_candidate(item) + and _is_recent_upload(item, now) + ) if media_type == "series": return is_recent_series_update_candidate(item, _lookup_metadata(item, metadata_by_key), now) return False @@ -1744,7 +1939,7 @@ def _quality_latest_items( continue reason = "近期 4K 影视补充" if has_4k_quality(item) else "近期高质量影视补充" card = _to_media_card(item, metadata_by_key, reason) - if _card_key(card) in excluded_keys: + if _card_identity_keys(card) & excluded_keys: continue if has_4k_quality(card): strict_cards.append(card) @@ -1792,7 +1987,7 @@ def _popular_media_items( if not _is_popular_media_candidate(item, metadata_by_key, now): continue card = _to_media_card(item, metadata_by_key, "热门高质量影视资源") - if _card_key(card) in excluded_keys: + if _card_identity_keys(card) & excluded_keys: continue cards.append(card) ranked = sorted( @@ -1815,18 +2010,21 @@ def _to_media_card( rail_reason: str, ) -> Dict[str, Any]: metadata = _lookup_metadata(torrent, metadata_by_key) + douban = _compatible_external_id(torrent, metadata_by_key, "douban") + imdb = _compatible_external_id(torrent, metadata_by_key, "imdb") + identity_torrent = {**torrent, "douban": douban, "imdb": imdb} status = torrent.get("status") or {} media_type = classify_media_type(torrent) torrent_name = str(torrent.get("name", "")) title = str(metadata.get("title") or _title_from_torrent_name(torrent_name)) - year = str(metadata.get("year") or "") + year = str(_media_year(torrent, metadata) or "") episode = extract_series_token(torrent) quality_tags = extract_quality_tags(torrent_name) created_date = str(torrent.get("createdDate") or "") return { "id": str(torrent.get("id", "")), - "media_key": _media_key(torrent), + "media_key": _media_key(identity_torrent), "title": title, "torrent_name": torrent_name, "poster_url": _metadata_poster_url(metadata) or _torrent_poster_url(torrent), @@ -1843,11 +2041,12 @@ def _to_media_card( "leechers": _safe_int(status.get("leechers")), "times_completed": _safe_int(status.get("timesCompleted")), "discount": str(status.get("discount") or "NORMAL"), - "douban": torrent.get("douban"), - "imdb": torrent.get("imdb"), - "douban_rating": torrent.get("doubanRating"), - "imdb_rating": torrent.get("imdbRating"), + "douban": douban, + "imdb": imdb, + "douban_rating": torrent.get("doubanRating") if douban else None, + "imdb_rating": torrent.get("imdbRating") if imdb else None, "description": metadata.get("intro") or torrent.get("smallDescr"), + "region_hint": _media_region_hint(torrent, metadata), } @@ -1855,25 +2054,62 @@ def _dedupe_cards(cards: List[Dict[str, Any]]) -> List[Dict[str, Any]]: seen: Set[str] = set() result = [] for card in cards: - key = _card_key(card) - if key in seen: + keys = _card_identity_keys(card) + if keys & seen: continue - seen.add(key) result.append(card) + seen.update(keys) return result def _card_keys(cards: List[Dict[str, Any]]) -> Set[str]: - return {_card_key(card) for card in cards} + return { + key + for card in cards + for key in _card_identity_keys(card) + } -def _card_key(card: Dict[str, Any]) -> str: +def _card_identity_keys(card: Dict[str, Any]) -> Set[str]: + keys: Set[str] = set() key = str(card.get("media_key") or "") if key.startswith("douban:"): - return f"douban:{_normalize_douban_code(key.removeprefix('douban:'))}" - if key.startswith("imdb:"): - return f"imdb:{_normalize_imdb_code(key.removeprefix('imdb:'))}" - return key or str(card.get("id")) + value = _normalize_douban_code(key.removeprefix("douban:")) + if value: + keys.add(f"douban:{value}") + elif key.startswith("imdb:"): + value = _normalize_imdb_code(key.removeprefix("imdb:")) + if value: + keys.add(f"imdb:{value}") + elif key: + keys.add(key) + + if card.get("douban"): + keys.add(f"douban:{_normalize_douban_code(str(card['douban']))}") + if card.get("imdb"): + keys.add(f"imdb:{_normalize_imdb_code(str(card['imdb']))}") + return keys or {str(card.get("id"))} + + +def _compatible_external_id( + torrent: Dict[str, Any], + metadata_by_key: Dict[str, Dict[str, Any]], + source_type: str, +) -> Optional[str]: + raw_value = torrent.get(source_type) + if not raw_value: + return None + value = str(raw_value) + normalized = ( + _normalize_douban_code(value) + if source_type == "douban" + else _normalize_imdb_code(value) + ) + for key in (value, f"{source_type}:{value}", normalized, f"{source_type}:{normalized}"): + metadata = metadata_by_key.get(key) + if metadata: + return None if _metadata_conflicts_with_torrent(torrent, metadata) else value + return value def _lookup_metadata( @@ -1881,8 +2117,9 @@ def _lookup_metadata( metadata_by_key: Dict[str, Dict[str, Any]], ) -> Dict[str, Any]: for key in _metadata_keys(torrent): - if key in metadata_by_key: - return metadata_by_key[key] + metadata = metadata_by_key.get(key) + if metadata and not _metadata_conflicts_with_torrent(torrent, metadata): + return metadata return {} @@ -1926,11 +2163,65 @@ def _metadata_year(metadata: Dict[str, Any]) -> Optional[int]: return int(match.group(0)) if match else None +def _torrent_year(torrent: Dict[str, Any]) -> Optional[int]: + return _year_from_torrent_name(str(torrent.get("name", ""))) or _year_from_torrent_name( + str(torrent.get("smallDescr", "")) + ) + + +def _media_year(torrent: Dict[str, Any], metadata: Dict[str, Any]) -> Optional[int]: + torrent_year = _torrent_year(torrent) + metadata_year = _metadata_year(metadata) + name_year = _year_from_torrent_name(str(torrent.get("name", ""))) + description_year = _year_from_torrent_name( + " ".join( + str(torrent.get(field_name) or "") + for field_name in ("smallDescr", "description") + ) + ) + if name_year and description_year and name_year != description_year: + if classify_media_type(torrent) in {"series", "anime"}: + torrent_year = max(name_year, description_year) + elif _metadata_title_contains_year(metadata, name_year): + torrent_year = description_year + if ( + torrent_year + and metadata_year + and abs(torrent_year - metadata_year) > 2 + and _metadata_title_contains_year(metadata, torrent_year) + ): + return metadata_year + return torrent_year or metadata_year + + +def _metadata_conflicts_with_torrent( + torrent: Dict[str, Any], metadata: Dict[str, Any] +) -> bool: + if classify_media_type(torrent) in {"series", "anime"}: + return False + torrent_year = _torrent_year(torrent) + metadata_year = _metadata_year(metadata) + return bool( + torrent_year + and metadata_year + and abs(torrent_year - metadata_year) > 2 + and not _metadata_title_contains_year(metadata, torrent_year) + ) + + +def _metadata_title_contains_year(metadata: Dict[str, Any], year: int) -> bool: + title = " ".join( + str(metadata.get(field_name) or "") + for field_name in ("title", "originalTitle") + ) + return bool(re.search(rf"\b{year}\b", title)) + + def _metadata_poster_url(metadata: Dict[str, Any]) -> Optional[str]: for field_name in ("coverUrl", "photo", "posterUrl", "poster", "image"): - value = metadata.get(field_name) - if isinstance(value, str) and value.strip(): - return value + poster_url = _normalize_poster_url(metadata.get(field_name)) + if poster_url: + return poster_url return None @@ -1961,8 +2252,8 @@ def _poster_url_from_value(value: Any) -> Optional[str]: def _year_from_torrent_name(name: str) -> Optional[int]: - match = re.search(r"\b((?:19|20)\d{2})\b", name) - return int(match.group(1)) if match else None + matches = re.findall(r"\b((?:19|20)\d{2})\b", name) + return int(matches[-1]) if matches else None def _chinese_number_to_int(value: str) -> Optional[int]: @@ -2008,10 +2299,18 @@ def _dict_items(items: Any) -> List[Dict[str, Any]]: return [item for item in items if isinstance(item, dict)] +def _valid_source_items(items: Any) -> List[Dict[str, Any]]: + return [ + item + for item in _dict_items(items) + if str(item.get("id") or "").strip() and str(item.get("name") or "").strip() + ] + + def _flatten_sources(torrents_by_source: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: seen: Set[str] = set() flattened = [] - source_items = [_dict_items(items) for items in torrents_by_source.values()] + source_items = [_valid_source_items(items) for items in torrents_by_source.values()] max_length = max((len(items) for items in source_items), default=0) for index in range(max_length): for items in source_items: @@ -2149,6 +2448,12 @@ def _normalize_poster_url(value: Any) -> Optional[str]: text = unescape(str(value)).replace("\\/", "/").strip() if not re.match(r"https?://", text, flags=re.IGNORECASE): return None + parsed = urlparse(text) + if ( + (parsed.hostname or "").lower().endswith("doubanio.com") + and parsed.path.endswith("/pics/subject/movie_large.jpg") + ): + return None return text @@ -2176,6 +2481,17 @@ def _lookup_cache_entry( return None +def _has_compatible_cached_metadata( + cache: Dict[str, Any], torrent: Dict[str, Any] +) -> bool: + for cache_key, cache_aliases, _source_type, _source_value in _metadata_sources(torrent): + cached = _lookup_cache_entry(cache, [cache_key, *cache_aliases]) or {} + data = cached.get("data") + if isinstance(data, dict) and data and not _metadata_conflicts_with_torrent(torrent, data): + return True + return False + + def _store_metadata_aliases( metadata: Dict[str, Dict[str, Any]], keys: List[str], diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index e78fdd4..3e406e2 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -14,6 +14,13 @@ import { useHomeMediaWall } from "@/hooks/use-home-media-wall"; export default function Home() { const { data, error, isLoading, mutate, isValidating } = useHomeMediaWall(); const hasItems = data?.rails.some((rail) => rail.items.length > 0) ?? false; + const emptySnapshotError = !hasItems && data?.refresh_status === "error"; + const incompleteSources = [ + ...(data?.diagnostics?.missing_sources ?? []), + ...(data?.diagnostics?.stale_sources ?? []), + ]; + const emptyIncompleteSnapshot = + !hasItems && !emptySnapshotError && data?.stale === true && incompleteSources.length > 0; return ( )} - {data && (hasItems ? : )} + {!error && emptySnapshotError && ( + + )} + {!error && emptyIncompleteSnapshot && ( + + )} + {data && hasItems && } + {data && + !hasItems && + !error && + !emptySnapshotError && + !emptyIncompleteSnapshot && } ); } diff --git a/frontend/components/home/media-wall.tsx b/frontend/components/home/media-wall.tsx index 7dbc974..26489c2 100644 --- a/frontend/components/home/media-wall.tsx +++ b/frontend/components/home/media-wall.tsx @@ -31,6 +31,8 @@ const mediaTypeLabels: Record = { }; const numberFormatter = new Intl.NumberFormat("zh-CN"); +const loadingRailKeys = Array.from({ length: 7 }, (_, index) => index); +const loadingItemKeys = Array.from({ length: 6 }, (_, index) => index); function formatNumber(value: number) { return numberFormatter.format(value); @@ -84,7 +86,20 @@ function formatDiagnostics( const sourceCount = sumDiagnosticCounts(diagnostics.sources); const relaxedCount = sumRailDiagnosticCounts(diagnostics.rails, "relaxed", "relaxed_count"); const fallbackCount = sumRailDiagnosticCounts(diagnostics.rails, "fallback", "fallback_count"); - return `来源 ${formatNumber(sourceCount)} 项 · 放宽填充 ${formatNumber(relaxedCount)} 项 · 兜底 ${formatNumber(fallbackCount)} 项`; + const sourceWarnings = [ + diagnostics.missing_sources?.length + ? `缺失 ${diagnostics.missing_sources.join("/")}` + : null, + diagnostics.stale_sources?.length + ? `过期 ${diagnostics.stale_sources.join("/")}` + : null, + ].filter(Boolean); + return [ + `来源 ${formatNumber(sourceCount)} 项`, + `放宽填充 ${formatNumber(relaxedCount)} 项`, + `兜底 ${formatNumber(fallbackCount)} 项`, + ...sourceWarnings, + ].join(" · "); } const fallbackPosterPalettes = [ @@ -136,36 +151,37 @@ interface HomeMediaWallProps { export function HomeMediaWall({ data }: HomeMediaWallProps) { const { - item: selectedItem, + item: selectedItemId, open: isDetailOpen, openWithItem: openDetail, setOpen: setIsDetailOpen, reset: resetDetailSheet, handleOpenChangeComplete, - } = useDeferredSheetState(); - + } = useDeferredSheetState(); const totalItems = useMemo( () => data.rails.reduce((sum, rail) => sum + rail.items.length, 0), [data.rails], ); + const selectedItem = selectedItemId + ? data.rails + .flatMap((rail) => rail.items) + .find((item) => item.id === selectedItemId) ?? null + : null; + const populatedRails = data.rails.filter((rail) => rail.items.length > 0); + const emptyRailTitles = data.rails + .filter((rail) => rail.items.length === 0) + .map((rail) => rail.title); + const selectedItemMissing = selectedItemId !== null && selectedItem === null; useEffect(() => { - if (!selectedItem) return; - - for (const rail of data.rails) { - for (const item of rail.items) { - if (item.id === selectedItem.id) return; - } - } - - resetDetailSheet(); - }, [data.rails, resetDetailSheet, selectedItem]); + if (selectedItemMissing) resetDetailSheet(); + }, [resetDetailSheet, selectedItemMissing]); return (
- {data.rails.map((rail) => ( + {populatedRails.map((rail) => (
- {rail.items.length > 0 ? ( - - {rail.items.map((item, index) => ( - openDetail(item)} - /> - ))} - - ) : ( -
-

当前筛选分组暂无匹配资源

-
- )} + + {rail.items.map((item, index) => ( + openDetail(item.id)} + /> + ))} + ))} + {emptyRailTitles.length > 0 && ( +

+ 暂无匹配资源:{emptyRailTitles.join("、")} +

+ )} { - const viewport = viewportRef.current; - if (!viewport) return; - - const handleWheel = (event: WheelEvent) => { - if (viewport.scrollWidth <= viewport.clientWidth) return; - - const horizontalIntent = Math.abs(event.deltaX) > 0.5; - if (horizontalIntent || event.shiftKey || event.deltaY === 0) { - return; - } - - // Pixel-mode wheel events are typically trackpads / Apple pointing devices. - // Let Safari/Chrome handle those natively so OS-tuned inertia stays intact. - if (event.deltaMode === 0) return; - - const deltaUnit = - event.deltaMode === 1 - ? 16 - : viewport.clientWidth; - const delta = event.deltaY * deltaUnit * 0.65; - const maxScrollLeft = viewport.scrollWidth - viewport.clientWidth; - const nextScrollLeft = Math.min( - maxScrollLeft, - Math.max(0, viewport.scrollLeft + delta), - ); - if (nextScrollLeft === viewport.scrollLeft) return; - - event.preventDefault(); - viewport.scrollLeft = nextScrollLeft; - }; - - viewport.addEventListener("wheel", handleWheel, { passive: false }); - return () => viewport.removeEventListener("wheel", handleWheel); - }, []); - - return (
formatDiagnostics(data.diagnostics), [data.diagnostics], ); - const warning = totalItems > 0 + const incompleteSources = [ + ...(data.diagnostics?.missing_sources ?? []), + ...(data.diagnostics?.stale_sources ?? []), + ]; + let warning = totalItems > 0 ? "正在显示已缓存内容;刷新异常已记录,后台会在下一轮刷新。" : "媒体墙刷新暂未产生可展示资源,后台会在下一轮刷新。"; + if (incompleteSources.length > 0) { + warning = "正在显示已缓存内容;部分来源缺失或过期,后台会继续补齐。"; + } return (
@@ -384,7 +369,7 @@ function MediaPosterCard({ sizes="(max-width: 640px) 42vw, (max-width: 1024px) 20vw, 200px" />
- + {mediaTypeLabels[item.media_type]} {item.episode && ( @@ -421,7 +406,7 @@ function MediaDetailSheet({ const isMobile = useIsMobile(); if (!item) return null; const radarHref = buildRadarHref(item); - const rating = firstString(item.douban_rating, item.imdb_rating); + const rating = firstRating(item.douban_rating, item.imdb_rating); return ( (null); - - if (item.poster_url && item.poster_url !== failedPosterUrl) { + const [failedPoster, setFailedPoster] = useState<{ + item: MediaWallItem; + url: string; + } | null>(null); + const posterUrl = item.poster_url; + const posterFailed = failedPoster?.item === item && failedPoster.url === posterUrl; + + if (posterUrl && !posterFailed) { return ( {`${item.title} setFailedPosterUrl(item.poster_url || null)} + onError={() => setFailedPoster({ item, url: posterUrl })} /> ); } @@ -659,10 +649,11 @@ function hashString(value: string) { return hash; } -function firstString(...values: unknown[]) { +function firstRating(...values: unknown[]) { for (const value of values) { - if (value === null || value === undefined || value === "") continue; - return typeof value === "number" ? formatNumber(value) : String(value); + const rating = typeof value === "number" ? value : Number(String(value).trim()); + if (!Number.isFinite(rating) || rating <= 0) continue; + return typeof value === "number" ? formatNumber(value) : String(value).trim(); } return ""; } @@ -681,14 +672,14 @@ export function HomeMediaWallLoading() {
- {[0, 1, 2, 3, 4].map((section) => ( + {loadingRailKeys.map((section) => (
- {[0, 1, 2, 3, 4, 5].map((item) => ( + {loadingItemKeys.map((item) => (
diff --git a/frontend/hooks/use-home-media-wall.ts b/frontend/hooks/use-home-media-wall.ts index b1fa64b..4bb690e 100644 --- a/frontend/hooks/use-home-media-wall.ts +++ b/frontend/hooks/use-home-media-wall.ts @@ -4,9 +4,19 @@ import useSWR from "swr"; import { getHomeMediaWall } from "@/lib/api"; import type { MediaWallResponse } from "@/lib/types"; +const DEFAULT_REFRESH_INTERVAL_MS = 10 * 60 * 1000; +const MIN_REFRESH_INTERVAL_MS = 60 * 1000; + +export function getMediaWallRefreshInterval(data?: MediaWallResponse) { + const nextRefresh = data?.next_refresh ? Date.parse(data.next_refresh) : Number.NaN; + return Number.isFinite(nextRefresh) + ? Math.max(MIN_REFRESH_INTERVAL_MS, nextRefresh - Date.now()) + : DEFAULT_REFRESH_INTERVAL_MS; +} + export function useHomeMediaWall() { return useSWR("/api/home/media-wall", getHomeMediaWall, { - refreshInterval: 600000, - revalidateOnFocus: false, + refreshInterval: getMediaWallRefreshInterval, + revalidateOnFocus: true, }); } diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 51d3d64..fb84673 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -273,6 +273,9 @@ export async function getRuntimeStatus(): Promise { media_wall_refresh_interval_seconds: 0, media_wall_startup_delay_seconds: 0, media_wall_source_stagger_seconds: 0, + media_wall_missing_source_stagger_max_seconds: 0, + media_wall_metadata_ttl_seconds: 0, + media_wall_max_metadata_fetches: 0, media_wall_douban_poster_fetches: 0, api_delay_seconds: 0, qbittorrent_configured: false, diff --git a/frontend/lib/api/generated.ts b/frontend/lib/api/generated.ts index 48fc054..bb517ff 100644 --- a/frontend/lib/api/generated.ts +++ b/frontend/lib/api/generated.ts @@ -1458,6 +1458,21 @@ export interface components { * @default 0 */ media_wall_source_stagger_seconds?: number; + /** + * Media Wall Missing Source Stagger Max Seconds + * @default 0 + */ + media_wall_missing_source_stagger_max_seconds?: number; + /** + * Media Wall Metadata Ttl Seconds + * @default 0 + */ + media_wall_metadata_ttl_seconds?: number; + /** + * Media Wall Max Metadata Fetches + * @default 0 + */ + media_wall_max_metadata_fetches?: number; /** * Media Wall Douban Poster Fetches * @default 0 diff --git a/frontend/lib/types.ts b/frontend/lib/types.ts index 4bb58ad..42139d9 100644 --- a/frontend/lib/types.ts +++ b/frontend/lib/types.ts @@ -272,6 +272,8 @@ export interface MediaWallDiagnosticsRail extends MediaWallDiagnosticsCount { export interface MediaWallDiagnostics { sources?: Record; rails?: Record; + missing_sources?: string[]; + stale_sources?: string[]; [key: string]: unknown; } diff --git a/frontend/tests/home-media-wall.test.tsx b/frontend/tests/home-media-wall.test.tsx index 44c9c8d..0158e18 100644 --- a/frontend/tests/home-media-wall.test.tsx +++ b/frontend/tests/home-media-wall.test.tsx @@ -2,7 +2,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mockAnimationFrame, mockMatchMedia, resetMatchMedia } from "./browser-shims"; -import { HomeMediaWall } from "@/components/home/media-wall"; +import { HomeMediaWall, HomeMediaWallLoading } from "@/components/home/media-wall"; import type { MediaWallResponse } from "@/lib/types"; const fixture: MediaWallResponse = { @@ -158,10 +158,12 @@ describe("HomeMediaWall", () => { render(); expect(screen.getByText("英美剧更新")).toBeTruthy(); - expect(screen.getByText("近期外语电影")).toBeTruthy(); - expect(screen.getByText("日韩剧更新")).toBeTruthy(); + expect(screen.getByText(/暂无匹配资源:.*近期外语电影.*日韩剧更新/)).toBeTruthy(); expect(screen.getByText("2160p / WEB-DL")).toBeTruthy(); + const poster = screen.getByRole("button", { name: /查看 Example Show 的详情/ }); + expect(within(poster).getByText("剧集").className).toContain("bg-background"); + await user.click( screen.getByRole("button", { name: /查看 Example Show 的详情/ }), ); @@ -243,6 +245,90 @@ describe("HomeMediaWall", () => { expect(screen.queryByRole("dialog")).toBeNull(); }); + it("keeps an open detail sheet synchronized with refreshed item data", async () => { + const { rerender } = render(); + + fireEvent.click( + screen.getByRole("button", { name: /查看 Example Show 的详情/ }), + ); + const dialog = await screen.findByRole("dialog"); + + rerender( + + rail.id === "western_series" + ? { + ...rail, + items: rail.items.map((item) => + item.id === "1001" + ? { ...item, title: "Updated Example Show", size_display: "51.00 GB" } + : item, + ), + } + : rail, + ), + }} + />, + ); + + expect(within(dialog).getByText("Updated Example Show")).toBeTruthy(); + expect(within(dialog).getByText("51.00 GB")).toBeTruthy(); + expect(within(dialog).queryByText("Example Show")).toBeNull(); + }); + + it.each([0, "0"])("falls back to IMDb when the Douban rating is %j", async (doubanRating) => { + render( + + rail.id === "western_series" + ? { + ...rail, + items: rail.items.map((item) => + item.id === "1001" + ? { ...item, douban_rating: doubanRating, imdb_rating: "8.2" } + : item, + ), + } + : rail, + ), + }} + />, + ); + + fireEvent.click( + screen.getByRole("button", { name: /查看 Example Show 的详情/ }), + ); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByText("评分 8.2")).toBeTruthy(); + expect(within(dialog).queryByText("评分 0")).toBeNull(); + }); + + it("retries the same poster URL when SWR supplies a new item object", () => { + const { rerender } = render(); + const poster = screen.getByAltText("Example Show 海报"); + + fireEvent.error(poster); + expect(screen.queryByAltText("Example Show 海报")).toBeNull(); + + rerender( + ({ + ...rail, + items: rail.items.map((item) => ({ ...item })), + })), + }} + />, + ); + + expect(screen.getByAltText("Example Show 海报")).toBeTruthy(); + }); + it("does not move poster artwork on hover", () => { render(); @@ -279,24 +365,48 @@ describe("HomeMediaWall", () => { expect(await screen.findByRole("dialog")).toBeTruthy(); }); + it("leaves vertical line-mode wheel events to page scrolling", () => { + render(); + const rail = screen.getByRole("region", { name: "英美剧更新 横向资源列表" }); + Object.defineProperty(rail, "scrollWidth", { configurable: true, value: 1000 }); + Object.defineProperty(rail, "clientWidth", { configurable: true, value: 300 }); + + expect(fireEvent.wheel(rail, { deltaY: 3, deltaMode: 1 })).toBe(true); + expect(rail.scrollLeft).toBe(0); + }); + it("renders fallback rails using the same poster/card behavior", () => { render(); expect(screen.getByText("最新高质量补位")).toBeTruthy(); - expect(screen.getByText("热门媒体补位")).toBeTruthy(); + expect(screen.getByText(/暂无匹配资源:.*热门媒体补位/)).toBeTruthy(); expect( screen.getByRole("button", { name: /查看 Relaxed Quality Pick 的详情/ }), ).toBeTruthy(); expect(screen.getByText("1080p / WEB-DL")).toBeTruthy(); }); - it("describes empty rails as a per-group miss, not missing cache", () => { - render(); + it("summarizes empty rails once while preserving the empty western status", () => { + const data = { + ...fallbackFixture, + rails: fallbackFixture.rails.map((rail) => + rail.id === "western_series" ? { ...rail, items: [] } : rail, + ), + }; + + render(); - expect(screen.getAllByText("当前筛选分组暂无匹配资源").length).toBeGreaterThan(0); + const emptySummary = screen.getByText(/暂无匹配资源:.*英美剧更新/); + expect(emptySummary).toBeTruthy(); + expect(screen.getAllByText(/暂无匹配资源:/)).toHaveLength(1); expect(screen.queryByText("暂无缓存")).toBeNull(); }); + it("renders loading placeholders for all seven rails", () => { + const { container } = render(); + expect(container.querySelectorAll("section")).toHaveLength(7); + }); + it("keeps stale-with-items messaging focused on cached content", () => { render( { expect(screen.getByText("来源 8 项 · 放宽填充 2 项 · 兜底 2 项")).toBeTruthy(); }); + it("names missing and stale media sources", () => { + render( + , + ); + + expect( + screen.getByText( + "来源 8 项 · 放宽填充 0 项 · 兜底 0 项 · 缺失 hot · 过期 movies", + ), + ).toBeTruthy(); + expect(screen.getByText(/部分来源缺失或过期/)).toBeTruthy(); + }); + it("ignores null and malformed diagnostic entries", () => { render( { - - - diff --git a/frontend/tests/home-page.test.tsx b/frontend/tests/home-page.test.tsx new file mode 100644 index 0000000..0d7803f --- /dev/null +++ b/frontend/tests/home-page.test.tsx @@ -0,0 +1,125 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import Home from "@/app/page"; + +const mocks = vi.hoisted(() => ({ useHomeMediaWall: vi.fn() })); + +vi.mock("@/hooks/use-home-media-wall", () => ({ + useHomeMediaWall: mocks.useHomeMediaWall, +})); + +vi.mock("@/components/common/page-scaffold", () => ({ + PageScaffold: ({ children }: { children: ReactNode }) =>
{children}
, +})); + +vi.mock("@/components/common/state-card", () => ({ + StateCard: ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+ ), +})); + +vi.mock("@/components/home/media-wall", () => ({ + EmptyMediaWall: () =>
empty-media-wall
, + HomeMediaWall: () =>
home-media-wall
, + HomeMediaWallLoading: () =>
loading-media-wall
, +})); + +const emptyData = { + stale: false, + refresh_status: "empty" as const, + rails: [ + { + id: "western_series" as const, + title: "英美剧更新", + description: "4K 美剧、英剧和英语剧集更新", + items: [], + }, + ], +}; + +describe("Home page media wall states", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("shows the request error instead of the ordinary empty state", () => { + mocks.useHomeMediaWall.mockReturnValue({ + data: emptyData, + error: new Error("media wall request failed"), + isLoading: false, + mutate: vi.fn(), + isValidating: false, + }); + + render(); + + expect(screen.getByText("加载媒体墙失败")).toBeTruthy(); + expect(screen.getByText("media wall request failed")).toBeTruthy(); + expect(screen.queryByText("empty-media-wall")).toBeNull(); + }); + + it("keeps the ordinary empty state when the request succeeds", () => { + mocks.useHomeMediaWall.mockReturnValue({ + data: emptyData, + error: undefined, + isLoading: false, + mutate: vi.fn(), + isValidating: false, + }); + + render(); + + expect(screen.getByText("empty-media-wall")).toBeTruthy(); + expect(screen.queryByText("加载媒体墙失败")).toBeNull(); + }); + + it("shows a structured refresh failure instead of the ordinary empty state", () => { + mocks.useHomeMediaWall.mockReturnValue({ + data: { + ...emptyData, + stale: true, + refresh_status: "error", + last_error: "M-Team source returned no items", + }, + error: undefined, + isLoading: false, + mutate: vi.fn(), + isValidating: false, + }); + + render(); + + expect(screen.getByText("媒体墙刷新失败")).toBeTruthy(); + expect(screen.getByText(/M-Team source returned no items.*后台会在下一轮重试/)).toBeTruthy(); + expect(screen.queryByText("empty-media-wall")).toBeNull(); + }); + + it("shows incomplete source diagnostics instead of the ordinary empty state", () => { + mocks.useHomeMediaWall.mockReturnValue({ + data: { + ...emptyData, + stale: true, + refresh_status: "ok", + diagnostics: { + missing_sources: ["hot"], + stale_sources: ["movies"], + }, + }, + error: undefined, + isLoading: false, + mutate: vi.fn(), + isValidating: false, + }); + + render(); + + expect(screen.getByText("媒体来源尚未就绪")).toBeTruthy(); + expect(screen.getByText(/hot、movies.*后台会继续分批刷新/)).toBeTruthy(); + expect(screen.queryByText("empty-media-wall")).toBeNull(); + }); +}); diff --git a/frontend/tests/use-home-media-wall.test.ts b/frontend/tests/use-home-media-wall.test.ts new file mode 100644 index 0000000..e6df62e --- /dev/null +++ b/frontend/tests/use-home-media-wall.test.ts @@ -0,0 +1,49 @@ +import { cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + getMediaWallRefreshInterval, + useHomeMediaWall, +} from "@/hooks/use-home-media-wall"; +import type { MediaWallResponse } from "@/lib/types"; + +const mocks = vi.hoisted(() => ({ useSWR: vi.fn() })); + +vi.mock("swr", () => ({ default: mocks.useSWR })); + +describe("useHomeMediaWall", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("polls at next_refresh with a one-minute retry floor", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-13T00:00:00Z")); + + expect( + getMediaWallRefreshInterval({ + next_refresh: "2026-07-13T02:00:00Z", + } as MediaWallResponse), + ).toBe(2 * 60 * 60 * 1000); + expect( + getMediaWallRefreshInterval({ + next_refresh: "2026-07-12T23:00:00Z", + } as MediaWallResponse), + ).toBe(60 * 1000); + expect(getMediaWallRefreshInterval()).toBe(10 * 60 * 1000); + }); + + it("revalidates on focus and delegates polling to the adaptive interval", () => { + renderHook(() => useHomeMediaWall()); + + expect(mocks.useSWR).toHaveBeenCalledWith( + "/api/home/media-wall", + expect.any(Function), + { + refreshInterval: getMediaWallRefreshInterval, + revalidateOnFocus: true, + }, + ); + }); +}); diff --git a/openapi.json b/openapi.json index cfd92f5..75a6b51 100644 --- a/openapi.json +++ b/openapi.json @@ -2499,6 +2499,21 @@ "title": "Media Wall Source Stagger Seconds", "default": 0 }, + "media_wall_missing_source_stagger_max_seconds": { + "type": "integer", + "title": "Media Wall Missing Source Stagger Max Seconds", + "default": 0 + }, + "media_wall_metadata_ttl_seconds": { + "type": "integer", + "title": "Media Wall Metadata Ttl Seconds", + "default": 0 + }, + "media_wall_max_metadata_fetches": { + "type": "integer", + "title": "Media Wall Max Metadata Fetches", + "default": 0 + }, "media_wall_douban_poster_fetches": { "type": "integer", "title": "Media Wall Douban Poster Fetches", diff --git a/tests/test_health_status.py b/tests/test_health_status.py index b5a188b..a16ecef 100644 --- a/tests/test_health_status.py +++ b/tests/test_health_status.py @@ -97,6 +97,9 @@ def test_status_reports_runtime_cache_dependencies_and_config(): assert body["dependencies"]["mteam"]["last_error"] is None assert body["config"]["refresh_interval_seconds"] >= 300 assert body["config"]["panel_collect_interval_seconds"] >= 30 + assert body["config"]["media_wall_missing_source_stagger_max_seconds"] == 5400 + assert body["config"]["media_wall_metadata_ttl_seconds"] >= 86400 + assert body["config"]["media_wall_max_metadata_fetches"] >= 0 assert "debug" in body["config"] assert "warnings" in body assert "free_refresh_backoff" in body["warnings"] @@ -119,6 +122,36 @@ def test_status_warns_when_cache_is_stale(): assert "free_cache_stale" in body["warnings"] +def test_status_warns_when_media_wall_is_stale(monkeypatch): + monkeypatch.setattr( + main.media_wall_service, + "get_snapshot", + lambda: {"stale": True}, + ) + + response = client.get("/api/status") + + assert response.status_code == 200 + assert "media_wall_stale" in response.json()["warnings"] + + +def test_status_warns_when_country_labels_are_unavailable(monkeypatch): + monkeypatch.setattr(main, "MTEAM_PROACTIVE_ENABLED", True) + monkeypatch.setattr(state, "COUNTRY_LABELS", {}) + + warnings = main.build_status_warnings( + {}, + {}, + { + "qbittorrent": {"ok": True}, + "mteam": {"ok": True}, + }, + {"stale": False}, + ) + + assert warnings == ["country_labels_unavailable"] + + def test_status_accepts_utc_z_cache_timestamp(): last_update = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") state.cached_data.update( @@ -183,4 +216,4 @@ def test_status_omits_mteam_warning_when_proactive_access_disabled(monkeypatch): "free_cache_stale", "panel_collector_stale", "qbittorrent_unhealthy", - } \ No newline at end of file + } diff --git a/tests/test_home_poster.py b/tests/test_home_poster.py index af87885..cc615d6 100644 --- a/tests/test_home_poster.py +++ b/tests/test_home_poster.py @@ -7,24 +7,45 @@ class FakeUpstreamResponse: - def __init__(self, status_code, content=b"", headers=None): + def __init__(self, status_code, content=b"", headers=None, chunks=None): self.status_code = status_code self.content = content self.headers = headers or {} + self.chunks = chunks + self.iterated = False + async def aiter_bytes(self): + self.iterated = True + for chunk in self.chunks if self.chunks is not None else [self.content]: + yield chunk -class FakeHttpClient: + +class FakeStream: def __init__(self, result=None, exc=None): self.result = result self.exc = exc - self.calls = [] - async def get(self, url, **kwargs): - self.calls.append((url, kwargs)) + async def __aenter__(self): if self.exc is not None: raise self.exc return self.result + async def __aexit__(self, exc_type, exc, traceback): + return False + + +class FakeHttpClient: + def __init__(self, result=None, exc=None): + self.result = result + self.exc = exc + self.calls = [] + self.methods = [] + + def stream(self, method, url, **kwargs): + self.methods.append(method) + self.calls.append((url, kwargs)) + return FakeStream(self.result, self.exc) + def make_client(monkeypatch, fake_http=None): if fake_http is not None: @@ -79,6 +100,7 @@ def test_home_poster_fetches_douban_with_referer_and_returns_image(monkeypatch): assert response.headers["cache-control"] == "public, max-age=86400" assert resolver_calls == ["img9.doubanio.com"] assert fake_http.calls[0][0] == url + assert fake_http.methods == ["GET"] request_kwargs = fake_http.calls[0][1] assert request_kwargs["follow_redirects"] is False assert request_kwargs["headers"]["Referer"] == "https://movie.douban.com/" @@ -90,6 +112,7 @@ def test_home_poster_fetches_douban_with_referer_and_returns_image(monkeypatch): [ {}, {"content-type": "text/html; charset=utf-8"}, + {"content-type": "image/svg+xml"}, ], ) def test_home_poster_returns_502_for_non_image_content_type(monkeypatch, headers): @@ -226,3 +249,66 @@ def test_home_poster_returns_502_for_upstream_exception(monkeypatch): ) assert response.status_code == 502 + + +def test_home_poster_rejects_declared_oversized_image_without_reading_body( + monkeypatch, +): + upstream = FakeUpstreamResponse( + 200, + b"not-read", + { + "content-type": "image/jpeg", + "content-length": str(home.POSTER_MAX_BYTES + 1), + }, + ) + fake_http = FakeHttpClient(upstream) + stub_resolved_ips(monkeypatch, ["1.2.3.4"]) + client = make_client(monkeypatch, fake_http) + + response = client.get( + "/api/home/poster", + params={"u": "https://image.tmdb.org/t/p/w500/too-large.jpg"}, + ) + + assert response.status_code == 502 + assert upstream.iterated is False + + +def test_home_poster_rejects_stream_that_exceeds_size_limit(monkeypatch): + monkeypatch.setattr(home, "POSTER_MAX_BYTES", 4) + upstream = FakeUpstreamResponse( + 200, + headers={"content-type": "image/jpeg"}, + chunks=[b"123", b"45"], + ) + fake_http = FakeHttpClient(upstream) + stub_resolved_ips(monkeypatch, ["1.2.3.4"]) + client = make_client(monkeypatch, fake_http) + + response = client.get( + "/api/home/poster", + params={"u": "https://image.tmdb.org/t/p/w500/too-large.jpg"}, + ) + + assert response.status_code == 502 + assert upstream.iterated is True + + +def test_home_media_wall_projects_internal_sources_before_proxying(monkeypatch): + class MustNotBeCopied: + def __deepcopy__(self, memo): + raise AssertionError("raw sources must be projected out before proxying") + + monkeypatch.setattr( + home.media_wall_service, + "get_snapshot", + lambda: {"rails": [], "sources": {"latest": MustNotBeCopied()}}, + ) + client = make_client(monkeypatch) + + response = client.get("/api/home/media-wall") + + assert response.status_code == 200 + assert response.headers["cache-control"] == "private, max-age=60" + assert "sources" not in response.json() diff --git a/tests/test_media_wall.py b/tests/test_media_wall.py index 3183463..d1c4f01 100644 --- a/tests/test_media_wall.py +++ b/tests/test_media_wall.py @@ -1,14 +1,18 @@ +import asyncio from copy import deepcopy from datetime import datetime, timedelta from urllib.parse import quote import pytest +from fastapi import Response from app.config import BEIJING_TZ from app.models import MediaWallResponse from app.services import mteam_api from app.services.media_wall import ( MediaWallService, + _dedupe_cards, + _merge_strict_and_relaxed, _search_payloads, _unwrap_gateway996_url, build_media_wall_rails, @@ -33,7 +37,7 @@ def torrent_item( leechers=30, times_completed=3, douban="https://www.douban.com/subject/1234567/", - imdb="tt1234567", + imdb="", small_descr="动作 科幻", ): return { @@ -120,6 +124,8 @@ def test_to_proxy_poster_url_rewrites_allowlisted_images_only(): ) tmdb_url = "https://image.tmdb.org/t/p/w500/poster.jpg" assert to_proxy_poster_url(tmdb_url) == "/api/home/poster?u=" + quote(tmdb_url, safe="") + amazon_url = "https://m.media-amazon.com/images/M/poster.jpg" + assert to_proxy_poster_url(amazon_url) == "/api/home/poster?u=" + quote(amazon_url, safe="") assert to_proxy_poster_url("https://example.com/x.jpg") == "https://example.com/x.jpg" assert to_proxy_poster_url(None) is None proxied = "/api/home/poster?u=https%3A%2F%2Fimage.tmdb.org%2Fx.jpg" @@ -1339,7 +1345,10 @@ def test_media_wall_staggered_scheduler_picks_one_missing_source_at_a_time(tmp_p "refresh_status": "ok", "rails": [], "sources": { - "latest": {"last_refreshed": now.isoformat(), "items": []}, + "latest": { + "last_refreshed": now.isoformat(), + "items": [torrent_item(id="latest-cached", imdb="")], + }, }, } @@ -1362,10 +1371,11 @@ def test_media_wall_staggered_scheduler_keeps_refreshed_sources_on_their_own_due "refresh_status": "ok", "rails": [], "sources": { - "latest": {"last_refreshed": now.isoformat(), "items": []}, - "movies": {"last_refreshed": (now + timedelta(seconds=stagger)).isoformat(), "items": []}, - "series": {"last_refreshed": (now + timedelta(seconds=stagger * 2)).isoformat(), "items": []}, - "hot": {"last_refreshed": (now + timedelta(seconds=stagger * 3)).isoformat(), "items": []}, + source: { + "last_refreshed": (now + timedelta(seconds=stagger * index)).isoformat(), + "items": [torrent_item(id=f"{source}-cached", douban=f"douban-{source}", imdb="")], + } + for index, source in enumerate(("latest", "movies", "series", "hot")) }, } @@ -1433,17 +1443,18 @@ async def test_media_wall_source_refresh_reports_next_staggered_source_time(tmp_ "refresh_status": "ok", "rails": [], "sources": { - "latest": {"last_refreshed": base.isoformat(), "items": []}, - "movies": {"last_refreshed": (base + timedelta(seconds=stagger)).isoformat(), "items": []}, - "series": {"last_refreshed": (base + timedelta(seconds=stagger * 2)).isoformat(), "items": []}, - "hot": {"last_refreshed": (base + timedelta(seconds=stagger * 3)).isoformat(), "items": []}, + source: { + "last_refreshed": (base + timedelta(seconds=stagger * index)).isoformat(), + "items": [torrent_item(id=f"{source}-cached", douban=f"douban-{source}", imdb="")], + } + for index, source in enumerate(("latest", "movies", "series", "hot")) }, } now = base + timedelta(seconds=21600, minutes=1) snapshot = await service.refresh_source_once("latest", now=now) - assert snapshot["next_refresh"] == (base + timedelta(seconds=21600 + stagger)).isoformat() + assert snapshot["next_refresh"] == (now + timedelta(seconds=stagger)).isoformat() def test_media_wall_service_reports_due_only_after_refresh_interval(tmp_path): @@ -2763,7 +2774,7 @@ async def refresh_once(self): fake_service = FakeService() monkeypatch.setattr(home, "media_wall_service", fake_service) - payload = await home.get_home_media_wall() + payload = await home.get_home_media_wall(Response()) assert payload["refresh_status"] == "empty" assert fake_service.get_calls == 1 @@ -2805,8 +2816,762 @@ async def test_home_media_wall_route_sanitizes_malformed_cached_snapshot(monkeyp } monkeypatch.setattr(home, "media_wall_service", service) - payload = await home.get_home_media_wall() + payload = await home.get_home_media_wall(Response()) assert [rail["id"] for rail in payload["rails"]] == EXPECTED_HOME_RAIL_IDS assert all(rail["items"] == [] for rail in payload["rails"]) MediaWallResponse.model_validate(payload) + + +def test_media_wall_text_region_overrides_country_id_and_id_8_is_not_chinese(): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + western = torrent_item( + id="western-text", + category=402, + name="Star City 2026 S01E01 2160p WEB-DL H.265", + douban="douban-western-text", + small_descr="美国 | 英语 | 剧情", + ) + western["countries"] = [1] + french = torrent_item( + id="french-id-8", + category=419, + name="Paris Story 2026 2160p WEB-DL H.265", + douban="douban-french", + small_descr="法国 | 法语 | 剧情", + ) + french["countries"] = [8] + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [western, french], + "movies": [french], + "series": [western], + "hot": [], + }, + metadata_by_key={ + "douban-western-text": metadata(title="Star City", year="2026", intro="剧情"), + "douban-french": metadata(title="Paris Story", year="2026", intro="法国 | 剧情"), + }, + now=now, + ) + + assert [item["id"] for item in rail_items(response, "western_series")] == ["western-text"] + assert [item["id"] for item in rail_items(response, "foreign_movies")] == ["french-id-8"] + + +def test_media_wall_sanitizer_migrates_cached_series_with_explicit_region(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "next_refresh": (now + timedelta(hours=6)).isoformat(), + "stale": False, + "refresh_status": "ok", + "sources": {}, + "rails": [ + { + "id": "chinese_series", + "items": [ + { + "id": "wrong-rail", + "media_key": "imdb:tt1000001", + "title": "Star City", + "torrent_name": "Star City 2026 S01E01 2160p WEB-DL H.265", + "media_type": "series", + "episode": "S01E01", + "quality_tags": ["2160p", "WEB-DL", "H.265"], + "description": "美国 | 英语 | 剧情", + }, + { + "id": "unknown-region", + "media_key": "imdb:tt1000002", + "title": "Mystery Show", + "torrent_name": "Mystery Show 2026 S01E01 2160p WEB-DL H.265", + "media_type": "series", + "episode": "S01E01", + "quality_tags": ["2160p", "WEB-DL", "H.265"], + "description": "剧情", + }, + ], + } + ], + } + + snapshot = service.get_snapshot(now) + + migrated = rail_items(snapshot, "western_series") + assert [item["id"] for item in migrated] == ["wrong-rail"] + assert migrated[0]["rail_reason"] == "英美 4K 剧集更新" + assert [item["id"] for item in rail_items(snapshot, "chinese_series")] == [ + "unknown-region" + ] + + +def test_media_wall_source_health_and_missing_source_stagger(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + item = torrent_item(id="cached", imdb="") + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + refresh_interval_seconds=21600, + source_stagger_seconds=12 * 60 * 60, + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "next_refresh": (now + timedelta(hours=6)).isoformat(), + "stale": False, + "refresh_status": "ok", + "rails": [], + "sources": { + "latest": {"last_refreshed": now.isoformat(), "items": [item]}, + "movies": {"last_refreshed": now.isoformat(), "items": []}, + "series": { + "last_refreshed": (now - timedelta(hours=7)).isoformat(), + "items": [item], + }, + }, + } + + snapshot = service.get_snapshot(now) + + assert snapshot["stale"] is True + assert snapshot["diagnostics"]["missing_sources"] == ["movies", "hot"] + assert snapshot["diagnostics"]["stale_sources"] == ["series"] + assert service._seconds_until_next_due(now) == 90 * 60 + + +@pytest.mark.asyncio +async def test_media_wall_metadata_budget_prioritizes_uncached_items(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + client = FakeMediaClient() + service = MediaWallService( + client=client, + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + max_metadata_fetches=1, + ) + service.max_douban_poster_fetches = 0 + service.metadata_cache = { + "douban:cached": { + "fetched_at": (now - timedelta(days=60)).isoformat(), + "data": metadata(title="Cached", year="2026"), + } + } + + await service._metadata_for_torrents( + { + "latest": [ + torrent_item(id="cached", douban="cached", imdb=""), + torrent_item(id="uncached", douban="uncached", imdb=""), + ] + }, + now, + ) + + assert client.metadata_calls == [("douban", "uncached", False)] + + +def test_media_wall_dedupes_shared_imdb_and_ignores_conflicting_douban_metadata(): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + correct = torrent_item( + id="correct", + category=419, + name="Im Not Afraid 2026 2160p BluRay REMUX H.265", + douban="douban-current", + imdb="tt36742917", + small_descr="美国 | 英语 | 剧情", + ) + wrong_secondary = torrent_item( + id="wrong-secondary", + category=419, + name="Im Not Afraid 2026 2160p WEB-DL H.265", + douban="1421570", + imdb="https://www.imdb.com/title/tt36742917/", + small_descr="美国 | 英语 | 剧情", + ) + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [wrong_secondary, correct], + "movies": [wrong_secondary, correct], + "series": [], + "hot": [], + }, + metadata_by_key={ + "1421570": metadata( + title="有你我不怕", + year="2003", + cover="https://img.example/wrong.webp", + intro="意大利 | 剧情", + ), + "imdb:tt36742917": metadata( + title="I'm Not Afraid", + year="2026", + intro="美国 | 英语 | 剧情", + ), + }, + now=now, + ) + + items = rail_items(response, "foreign_movies") + assert [item["id"] for item in items] == ["correct"] + assert items[0]["title"] == "I'm Not Afraid" + assert items[0]["year"] == "2026" + + +def test_media_wall_rejects_placeholder_poster_and_old_quality_latest_movie(): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + placeholder = ( + "https://img2.doubanio.com/f/frodo/753fa1e93c945e533e52dddab73b2b55a1083083/" + "pics/subject/movie_large.jpg" + ) + fallback = "https://image.tmdb.org/t/p/w500/fallback.jpg" + recent = torrent_item( + id="placeholder", + name="Nothing to Lose 2026 2160p WEB-DL H.265", + douban="douban-placeholder", + small_descr="美国 | 英语 | 剧情", + ) + recent["imageList"] = [fallback] + old_movie = torrent_item( + id="old-release", + name="Interstellar 2014 1080p WEB-DL H.264", + created=now.isoformat(), + douban="douban-old-release", + small_descr="美国 | 英语 | 剧情", + ) + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [recent, old_movie], + "movies": [recent, old_movie], + "series": [], + "hot": [], + }, + metadata_by_key={ + "douban-placeholder": metadata( + title="Nothing to Lose", + year="2026", + cover=placeholder, + intro="美国 | 英语 | 剧情", + ), + "douban-old-release": metadata( + title="Interstellar", year="2014", intro="美国 | 剧情" + ), + }, + now=now, + ) + + assert rail_items(response, "foreign_movies")[0]["poster_url"] == fallback + assert "old-release" not in [item["id"] for item in rail_items(response, "quality_latest")] + + +@pytest.mark.parametrize( + "payload", + [ + None, + {"data": None}, + {"data": []}, + {"data": [{}]}, + {"data": [{"message": "upstream error"}]}, + ], +) +@pytest.mark.asyncio +async def test_media_wall_source_refresh_rejects_empty_or_non_list_data_and_keeps_source( + tmp_path, payload +): + class PayloadClient(FakeMediaClient): + async def search_torrents(self, request_payload, label=""): + self.search_payloads.append((label, request_payload)) + return payload + + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + previous_item = torrent_item(id="previous", imdb="") + service = MediaWallService( + client=PayloadClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "refresh_status": "ok", + "stale": False, + "rails": [], + "sources": { + "latest": {"last_refreshed": now.isoformat(), "items": [previous_item]} + }, + } + + snapshot = await service.refresh_source_once("latest", now=now) + + assert snapshot["refresh_status"] == "error" + assert snapshot["sources"]["latest"]["items"] == [previous_item] + + +@pytest.mark.asyncio +async def test_media_wall_full_refresh_rejects_empty_source_and_keeps_snapshot(tmp_path): + class EmptyMoviesClient(FakeMediaClient): + async def search_torrents(self, payload, label=""): + if label == "media-wall:movies": + return {"data": []} + return await super().search_torrents(payload, label) + + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + previous = { + "last_refreshed": now.isoformat(), + "refresh_status": "ok", + "stale": False, + "rails": [], + "sources": { + "latest": { + "last_refreshed": now.isoformat(), + "items": [torrent_item(id="previous", imdb="")], + } + }, + } + service = MediaWallService( + client=EmptyMoviesClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + service.snapshot = previous + + snapshot = await service.refresh_once(now=now) + + assert snapshot["refresh_status"] == "error" + assert snapshot["sources"] == previous["sources"] + + +@pytest.mark.asyncio +async def test_media_wall_snapshot_write_failure_keeps_previous_memory_snapshot( + tmp_path, monkeypatch +): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + previous = { + "last_refreshed": (now - timedelta(hours=1)).isoformat(), + "refresh_status": "ok", + "stale": False, + "rails": [], + "sources": {}, + } + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + max_metadata_fetches=0, + ) + service.snapshot = previous + persist_calls = 0 + + def fail_persist(_snapshot=None): + nonlocal persist_calls + persist_calls += 1 + raise OSError("disk full") + + monkeypatch.setattr(service, "_persist_snapshot", fail_persist) + + result = await service.refresh_source_once("latest", now=now) + + assert persist_calls == 1 + assert service.snapshot is previous + assert result["refresh_status"] == "error" + assert result["last_error"] == "disk full" + assert result["next_refresh"] == ( + now + timedelta(seconds=service.refresh_failure_backoff_seconds) + ).isoformat() + visible = service.get_snapshot(now) + assert visible["refresh_status"] == "error" + assert visible["last_error"] == "disk full" + + +def test_media_wall_initial_persist_failure_is_visible_without_a_snapshot(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + + service._snapshot_after_persist_failure(now, "disk full") + + visible = service.get_snapshot(now) + assert visible["refresh_status"] == "error" + assert visible["last_error"] == "disk full" + + +def test_media_wall_uses_episode_release_year_without_discarding_series_metadata(): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + doctor_who = torrent_item( + id="doctor-who", + category=402, + name="Doctor Who S15E01 2026 2160p WEB-DL H.265", + douban="doctor-who", + small_descr="英国 | 英语 | 剧情", + ) + show_1923 = torrent_item( + id="show-1923", + category=402, + name="1923 S02E01 2160p WEB-DL H.265", + douban="show-1923", + small_descr="2025 | 美国 | 英语 | 剧情", + ) + show_1923_without_metadata = torrent_item( + id="show-1923-no-metadata", + category=402, + name="1923 S02E02 2160p WEB-DL H.265", + douban="", + imdb="", + small_descr="2025 | 美国 | 英语 | 剧情", + ) + movie_1917 = torrent_item( + id="movie-1917", + category=419, + name="1917 2160p UHD BluRay REMUX H.265", + douban="movie-1917", + small_descr="英国 | 英语 | 剧情", + ) + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [doctor_who, show_1923, show_1923_without_metadata, movie_1917], + "movies": [movie_1917], + "series": [doctor_who, show_1923, show_1923_without_metadata], + "hot": [], + }, + metadata_by_key={ + "doctor-who": metadata( + title="Doctor Who", + year="2005", + cover="https://img.example/doctor.webp", + intro="英国 | 英语 | 剧情", + ), + "show-1923": metadata( + title="1923", + year="2022", + cover="https://img.example/1923.webp", + intro="美国 | 英语 | 剧情", + ), + "movie-1917": metadata( + title="1917", + year="2019", + cover="https://img.example/1917.webp", + intro="英国 | 英语 | 剧情", + ), + }, + now=now, + ) + + western = {item["id"]: item for item in rail_items(response, "western_series")} + assert western["doctor-who"]["year"] == "2026" + assert western["doctor-who"]["poster_url"] == "https://img.example/doctor.webp" + assert western["show-1923"]["year"] == "2025" + assert western["show-1923"]["title"] == "1923" + assert western["show-1923-no-metadata"]["year"] == "2025" + classic = {item["id"]: item for item in rail_items(response, "classic_restorations")} + assert classic["movie-1917"]["year"] == "2019" + assert classic["movie-1917"]["poster_url"] == "https://img.example/1917.webp" + + +def test_media_wall_region_prefers_structured_country_over_synopsis_and_audio(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + western = torrent_item( + id="western-structured", + category=402, + name="Structured Show S01E01 2160p WEB-DL H.265", + douban="western-structured", + small_descr="剧情 | 第1季第1集", + ) + chinese = torrent_item( + id="chinese-structured", + category=402, + name="Mandarin Show S01E01 2160p WEB-DL H.265", + douban="chinese-structured", + small_descr="[英语] | 第1季第1集", + ) + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [western, chinese], + "movies": [], + "series": [western, chinese], + "hot": [], + }, + metadata_by_key={ + "western-structured": { + **metadata(title="Structured Show", year="2026", intro="故事发生在日本"), + "countries": ["美国"], + "languages": ["英语"], + }, + "chinese-structured": { + **metadata(title="Mandarin Show", year="2026", intro="一场美国之旅"), + "countries": ["中国大陆"], + "languages": ["汉语普通话"], + }, + }, + now=now, + ) + + assert [item["id"] for item in rail_items(response, "western_series")] == [ + "western-structured" + ] + assert [item["id"] for item in rail_items(response, "chinese_series")] == [ + "chinese-structured" + ] + + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "next_refresh": (now + timedelta(hours=6)).isoformat(), + "stale": False, + "refresh_status": "ok", + "sources": {}, + **response, + } + + cached = service.get_snapshot(now) + assert [item["id"] for item in rail_items(cached, "western_series")] == [ + "western-structured" + ] + assert [item["id"] for item in rail_items(cached, "chinese_series")] == [ + "chinese-structured" + ] + + +def test_media_wall_dedupe_does_not_claim_ids_from_rejected_bridge_card(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + + def identity_card(card_id, douban, imdb, **overrides): + return { + "id": card_id, + "media_key": f"douban:{douban}", + "douban": douban, + "imdb": imdb, + "title": card_id, + "torrent_name": f"{card_id} 2000 2160p BluRay REMUX H.265", + "media_type": "movie", + "year": "2000", + "quality_tags": ["2160p", "BluRay", "Remux", "H.265"], + "created_date": now.isoformat(), + **overrides, + } + + accepted = identity_card("accepted", "a", "tt1") + bridge = identity_card("bridge", "b", "tt1") + independent = identity_card("independent", "b", "tt2") + + assert [card["id"] for card in _dedupe_cards([accepted, bridge, independent])] == [ + "accepted", + "independent", + ] + merged, _counts = _merge_strict_and_relaxed([accepted], [bridge, independent], 8) + assert [card["id"] for card in merged] == ["accepted", "independent"] + + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + recent_accepted = identity_card( + "accepted", + "a", + "tt1", + torrent_name="accepted 2026 2160p WEB-DL H.265", + year="2026", + description="美国 | 英语", + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "next_refresh": (now + timedelta(hours=6)).isoformat(), + "stale": False, + "refresh_status": "ok", + "sources": {}, + "rails": [ + {"id": "foreign_movies", "items": [recent_accepted]}, + {"id": "classic_restorations", "items": [bridge, independent]}, + ], + } + + sanitized = service.get_snapshot(now) + + assert [item["id"] for item in rail_items(sanitized, "foreign_movies")] == [ + "accepted" + ] + assert [item["id"] for item in rail_items(sanitized, "classic_restorations")] == [ + "independent" + ] + + +def test_media_wall_drops_conflicting_secondary_id_before_quality_dedupe(): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + true_a = torrent_item( + id="true-a", + name="Current Film A 2026 1080p WEB-DL H.264", + douban="a-id", + imdb="tt1000001", + small_descr="美国 | 英语 | 剧情", + ) + high_quality_bridge = torrent_item( + id="bridge-a", + name="Current Film A 2026 2160p UHD BluRay REMUX DV H.265", + douban="b-id", + imdb="tt1000001", + small_descr="美国 | 英语 | 剧情", + ) + true_b = torrent_item( + id="true-b", + name="Classic Film B 2003 2160p UHD BluRay REMUX H.265", + douban="b-id", + imdb="tt2000002", + small_descr="意大利 | 剧情", + ) + + response = build_media_wall_rails( + torrents_by_source={ + "latest": [high_quality_bridge, true_a, true_b], + "movies": [high_quality_bridge, true_a, true_b], + "series": [], + "hot": [true_b], + }, + metadata_by_key={ + "a-id": metadata(title="Current Film A", year="2026", intro="美国 | 剧情"), + "b-id": metadata(title="Classic Film B", year="2003", intro="意大利 | 剧情"), + "imdb:tt1000001": metadata( + title="Current Film A", year="2026", intro="美国 | 剧情" + ), + "imdb:tt2000002": metadata( + title="Classic Film B", year="2003", intro="意大利 | 剧情" + ), + }, + now=now, + ) + + foreign = rail_items(response, "foreign_movies") + assert [item["id"] for item in foreign] == ["bridge-a"] + assert foreign[0]["douban"] is None + assert foreign[0]["media_key"] == "imdb:tt1000001" + assert [item["id"] for item in rail_items(response, "classic_restorations")] == [ + "true-b" + ] + + +@pytest.mark.asyncio +async def test_media_wall_source_refresh_never_advertises_faster_than_actual_stagger( + tmp_path, +): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + refresh_interval_seconds=21600, + max_metadata_fetches=0, + ) + service.snapshot = { + "last_refreshed": (now - timedelta(hours=7)).isoformat(), + "next_refresh": now.isoformat(), + "stale": True, + "refresh_status": "ok", + "rails": [], + "sources": { + source: { + "last_refreshed": (now - timedelta(hours=7)).isoformat(), + "items": [ + torrent_item( + id=f"{source}-cached", douban=f"douban-{source}", imdb="" + ) + ], + } + for source in ("latest", "movies", "series", "hot") + }, + } + + snapshot = await service.refresh_source_once("latest", now=now) + + assert snapshot["next_refresh"] == ( + now + timedelta(seconds=service.source_stagger_seconds) + ).isoformat() + + +@pytest.mark.asyncio +async def test_media_wall_background_loop_sleeps_from_returned_error_backoff( + tmp_path, monkeypatch +): + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + startup_delay_seconds=1, + refresh_failure_backoff_seconds=777, + ) + monkeypatch.setattr(service, "_next_due_source", lambda _now: "latest") + + async def fail_refresh(_source, now=None): + assert now is not None + return { + "refresh_status": "error", + "next_refresh": (now + timedelta(seconds=777)).isoformat(), + } + + sleep_calls = [] + + async def fake_sleep(seconds): + sleep_calls.append(seconds) + if len(sleep_calls) == 2: + raise asyncio.CancelledError + + monkeypatch.setattr(service, "refresh_source_once", fail_refresh) + monkeypatch.setattr(asyncio, "sleep", fake_sleep) + + with pytest.raises(asyncio.CancelledError): + await service.run_background_loop() + + assert sleep_calls[0] == 1 + assert 776 <= sleep_calls[1] <= 777 + + +@pytest.mark.asyncio +async def test_media_wall_metadata_budget_treats_conflicting_cache_as_uncached(tmp_path): + now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + client = FakeMediaClient() + service = MediaWallService( + client=client, + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + max_metadata_fetches=1, + ) + service.max_douban_poster_fetches = 0 + service.metadata_cache = { + "douban:1421570": { + "fetched_at": now.isoformat(), + "data": metadata(title="Wrong Film", year="2003"), + } + } + conflicted = torrent_item( + id="conflicted", + name="Current Film 2026 2160p BluRay", + douban="1421570", + imdb="tt36742917", + ) + ordinary_uncached = torrent_item( + id="ordinary", + name="Ordinary Film 2026 2160p BluRay", + douban="ordinary", + imdb="", + ) + + await service._metadata_for_torrents( + {"latest": [conflicted, ordinary_uncached]}, + now, + ) + + assert client.metadata_calls[0] == ("imdb", "tt36742917", False) diff --git a/tests/test_security.py b/tests/test_security.py index 6b9bf3f..192c821 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -101,6 +101,22 @@ def test_safe_frontend_file_serves_from_frontend_root(monkeypatch, tmp_path): assert "MT Engine" in response.text +def test_next_static_assets_get_immutable_cache_header(monkeypatch, tmp_path): + asset_path = tmp_path / "_next/static/chunks/app.abc123.js" + asset_path.parent.mkdir(parents=True) + asset_path.write_text("export {};", encoding="utf-8") + monkeypatch.setattr(main, "FRONTEND_DIR", tmp_path) + + response = client.get("/_next/static/chunks/app.abc123.js") + + assert response.status_code == 200 + assert response.headers["cache-control"] == ( + "public, max-age=31536000, immutable" + ) + assert "cache-control" not in client.get("/_next/static/chunks/missing.js").headers + assert "cache-control" not in client.get("/health").headers + + def test_encoded_traversal_does_not_fall_back_or_escape_frontend(monkeypatch, tmp_path): index_path = tmp_path / "index.html" index_path.write_text("

MT Engine

", encoding="utf-8") From 202f24f09957668dff7bf9bd5390fbe48b39831c Mon Sep 17 00:00:00 2001 From: kevinplus66 Date: Mon, 13 Jul 2026 04:30:02 +0800 Subject: [PATCH 2/4] Use M-Team country mapping for media wall --- CHANGELOG.md | 2 +- app/services/media_wall.py | 50 ++++++++++----------- tests/test_media_wall.py | 92 ++++++++++++++++++++++++++++++++------ 3 files changed, 103 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 892c37a..dd0d64b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### Home Media Wall Reliability -- Fixed regional rail conflicts so explicit country/language text overrides stale numeric hints, cached cards with a clear conflicting region are rerouted, and country ID `8` is no longer treated as Chinese. +- Fixed regional rail conflicts so explicit country/language text overrides numeric hints, cached cards with a clear conflicting region are rerouted, and numeric country IDs use M-Team's runtime mapping instead of hardcoded assumptions. - Treat missing, empty, or overdue sources as stale, preserve the previous source on invalid empty responses, and cap missing-source bootstrap spacing at 90 minutes even when the steady-state refresh interval is much longer. - Prioritize never-enriched media within the metadata budget, deduplicate across every normalized Douban/IMDb identity, prefer release-name years, reject known Douban placeholder posters, and keep old films out of `高质量新片新剧`. - Fixed zero-rating fallback, stale detail sheets, permanent poster failures, error/empty states, rail scrolling, empty-rail layout, source diagnostics, and media-wall polling on the frontend. diff --git a/app/services/media_wall.py b/app/services/media_wall.py index 8c993e2..ebe8abe 100644 --- a/app/services/media_wall.py +++ b/app/services/media_wall.py @@ -17,6 +17,7 @@ from typing import Any, Callable, Dict, List, Optional, Set, Tuple from urllib.parse import parse_qs, quote, urlparse +import app.state as state from app.config import ( BEIJING_TZ, MEDIA_WALL_DOUBAN_POSTER_FETCHES, @@ -250,9 +251,6 @@ def proxy_media_wall_posters(snapshot: Dict[str, Any]) -> Dict[str, Any]: RECENT_COMPLETED_SERIES_YEAR_WINDOW = 2 RECENT_FOREIGN_MOVIE_YEAR_WINDOW = 3 RELAXED_RAIL_VISIBLE_FLOOR = 6 -CHINESE_REGION_COUNTRY_IDS = {1, 2, 3, 108, 109, 110} -ASIAN_REGION_COUNTRY_IDS = {5, 6} -WESTERN_REGION_COUNTRY_IDS = {4, 7} COMPLETE_SEASON_RE = re.compile( r"\bComplete\b|全集|完结|完結|全\s*\d+\s*[集话話]?", re.IGNORECASE, @@ -937,12 +935,10 @@ def _metadata_language_region_hint(metadata: Dict[str, Any]) -> Optional[str]: def _torrent_country_region_hint(torrent: Dict[str, Any]) -> Optional[str]: - if _has_chinese_country_code(torrent): - return "chinese" - if _has_asian_country_code(torrent): - return "asian" - if _has_western_country_code(torrent): - return "western" + for country_id in _torrent_country_ids(torrent): + region = _country_region_hint(state.COUNTRY_LABELS.get(country_id, "")) + if region: + return region return None @@ -1319,6 +1315,14 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any """Keep cached Home content visible while normalizing older rail shapes.""" current_rail_ids = {rail_id for rail_id, _, _ in RAIL_DEFINITIONS} payload = dict(snapshot) + raw_sources = payload.get("sources") + sources = raw_sources if isinstance(raw_sources, dict) else {} + source_items_by_id = { + str(item.get("id")): item + for source_data in sources.values() + if isinstance(source_data, dict) + for item in _valid_source_items(source_data.get("items", [])) + } items_by_rail: Dict[str, List[Dict[str, Any]]] = { rail_id: [] for rail_id, _, _ in RAIL_DEFINITIONS } @@ -1341,11 +1345,15 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any ] if rail_id in current_rail_ids: for item in items: + source_item = source_items_by_id.get(str(item.get("id")), {}) + source_region = _torrent_country_region_hint(source_item) if not _is_cached_family_item_allowed(rail_id, item, now): continue + if rail_id == "foreign_movies" and source_region == "chinese": + continue target_rail = rail_id if rail_id in {"western_series", "asian_series", "chinese_series"}: - region = _cached_item_region_hint(item) + region = _cached_item_region_hint(item) or source_region if region: target_rail = { "western": "western_series", @@ -1663,12 +1671,12 @@ def _is_recent_upload( return created >= now - timedelta(days=max_age_days) -def _torrent_country_ids(torrent: Dict[str, Any]) -> Set[int]: +def _torrent_country_ids(torrent: Dict[str, Any]) -> List[int]: raw = torrent.get("countries") if raw is None: raw = torrent.get("country") if raw is None: - return set() + return [] if isinstance(raw, list): values = raw @@ -1679,26 +1687,16 @@ def _torrent_country_ids(torrent: Dict[str, Any]) -> Set[int]: else: values = re.split(r"[,;\s]+", str(raw)) - country_ids: Set[int] = set() + country_ids: List[int] = [] for value in values: current = value.get("id") if isinstance(value, dict) else value if str(current).strip().isdigit(): - country_ids.add(int(str(current).strip())) + country_id = int(str(current).strip()) + if country_id not in country_ids: + country_ids.append(country_id) return country_ids -def _has_chinese_country_code(torrent: Dict[str, Any]) -> bool: - return bool(_torrent_country_ids(torrent) & CHINESE_REGION_COUNTRY_IDS) - - -def _has_asian_country_code(torrent: Dict[str, Any]) -> bool: - return bool(_torrent_country_ids(torrent) & ASIAN_REGION_COUNTRY_IDS) - - -def _has_western_country_code(torrent: Dict[str, Any]) -> bool: - return bool(_torrent_country_ids(torrent) & WESTERN_REGION_COUNTRY_IDS) - - def _series_source_candidates(torrents_by_source: Dict[str, List[Dict[str, Any]]]) -> List[Dict[str, Any]]: return [ *_dict_items(torrents_by_source.get("latest", [])), diff --git a/tests/test_media_wall.py b/tests/test_media_wall.py index d1c4f01..e85594b 100644 --- a/tests/test_media_wall.py +++ b/tests/test_media_wall.py @@ -6,6 +6,7 @@ import pytest from fastapi import Response +import app.state as state from app.config import BEIJING_TZ from app.models import MediaWallResponse from app.services import mteam_api @@ -477,8 +478,9 @@ def test_family_media_wall_excludes_chinese_language_movies_from_foreign_movies( assert "hong-kong" not in [item["id"] for item in rail_items(response, "foreign_movies")] -def test_family_media_wall_excludes_chinese_country_ids_from_foreign_movies(): +def test_family_media_wall_excludes_chinese_country_ids_from_foreign_movies(monkeypatch): now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + monkeypatch.setattr(state, "COUNTRY_LABELS", {8: "中国", 109: "香港"}) hong_kong_movie = torrent_item( id="hong-kong-country", category=419, @@ -2630,16 +2632,21 @@ def test_boutique_series_rail_fills_sparse_strict_results_with_1080p_quality(): assert response["diagnostics"]["rails"]["western_series"] == {"items": 6, "strict": 1, "relaxed": 5, "fallback": 0} -def test_series_region_classification_uses_country_id_fallbacks(): +def test_series_region_classification_uses_runtime_country_labels(monkeypatch): now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + monkeypatch.setattr( + state, + "COUNTRY_LABELS", + {2: "United States of America", 8: "中国", 17: "Japan"}, + ) western = torrent_item( id="western-country", category=402, - name="Country Show S01E01 2160p WEB-DL H.265", + name="Star City S01 2026 2160p ATVP WEB-DL H.265", douban="douban-western-country", - small_descr="剧情 | 第1季第1集", + small_descr="星城 / Star City / 《为全人类》衍生剧 | 类别:剧情 科幻 战争", ) - western["countries"] = [4] + western["countries"] = ["2"] asian = torrent_item( id="asian-country", category=402, @@ -2647,7 +2654,7 @@ def test_series_region_classification_uses_country_id_fallbacks(): douban="douban-asian-country", small_descr="剧情 | 第1季第1集", ) - asian["countries"] = [5] + asian["countries"] = [17] chinese = torrent_item( id="chinese-country", category=402, @@ -2655,7 +2662,7 @@ def test_series_region_classification_uses_country_id_fallbacks(): douban="douban-chinese-country", small_descr="悬疑 | 第1季第1集", ) - chinese["countries"] = [1] + chinese["countries"] = [8] response = build_media_wall_rails( torrents_by_source={"latest": [western, asian, chinese], "movies": [], "series": [western, asian, chinese], "hot": []}, @@ -2673,8 +2680,9 @@ def test_series_region_classification_uses_country_id_fallbacks(): assert [item["id"] for item in rail_items(response, "chinese_series")] == ["chinese-country"] -def test_series_metadata_region_overrides_conflicting_torrent_country_ids(): +def test_series_metadata_region_overrides_conflicting_torrent_country_ids(monkeypatch): now = datetime(2026, 6, 5, 12, 0, tzinfo=BEIJING_TZ) + monkeypatch.setattr(state, "COUNTRY_LABELS", {8: "中国"}) western = torrent_item( id="western-metadata-override", category=402, @@ -2682,7 +2690,7 @@ def test_series_metadata_region_overrides_conflicting_torrent_country_ids(): douban="douban-western-override", small_descr="掩耳盗邻 / 你的朋友与邻居 第二季 | 第09集 | 4K HDR | 类型: 剧情/犯罪", ) - western["countries"] = ["2"] + western["countries"] = ["8"] response = build_media_wall_rails( torrents_by_source={"latest": [western], "movies": [], "series": [western], "hot": []}, @@ -2823,8 +2831,9 @@ async def test_home_media_wall_route_sanitizes_malformed_cached_snapshot(monkeyp MediaWallResponse.model_validate(payload) -def test_media_wall_text_region_overrides_country_id_and_id_8_is_not_chinese(): +def test_media_wall_text_region_overrides_numeric_country_hint(monkeypatch): now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) + monkeypatch.setattr(state, "COUNTRY_LABELS", {6: "France", 8: "中国"}) western = torrent_item( id="western-text", category=402, @@ -2832,15 +2841,15 @@ def test_media_wall_text_region_overrides_country_id_and_id_8_is_not_chinese(): douban="douban-western-text", small_descr="美国 | 英语 | 剧情", ) - western["countries"] = [1] + western["countries"] = [8] french = torrent_item( - id="french-id-8", + id="french-country", category=419, name="Paris Story 2026 2160p WEB-DL H.265", douban="douban-french", small_descr="法国 | 法语 | 剧情", ) - french["countries"] = [8] + french["countries"] = [6] response = build_media_wall_rails( torrents_by_source={ @@ -2857,7 +2866,9 @@ def test_media_wall_text_region_overrides_country_id_and_id_8_is_not_chinese(): ) assert [item["id"] for item in rail_items(response, "western_series")] == ["western-text"] - assert [item["id"] for item in rail_items(response, "foreign_movies")] == ["french-id-8"] + assert [item["id"] for item in rail_items(response, "foreign_movies")] == [ + "french-country" + ] def test_media_wall_sanitizer_migrates_cached_series_with_explicit_region(tmp_path): @@ -2912,6 +2923,59 @@ def test_media_wall_sanitizer_migrates_cached_series_with_explicit_region(tmp_pa ] +def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( + tmp_path, monkeypatch +): + now = datetime(2026, 7, 13, 12, 0, tzinfo=BEIJING_TZ) + monkeypatch.setattr(state, "COUNTRY_LABELS", {2: "United States of America"}) + source_item = torrent_item( + id="1207107", + category=402, + name="Star City S01 2026 2160p ATVP WEB-DL H.265", + douban="36858672", + small_descr="星城 / Star City / 《为全人类》衍生剧 | 类别:剧情 科幻 战争", + ) + source_item["countries"] = ["2"] + service = MediaWallService( + client=FakeMediaClient(), + snapshot_path=tmp_path / "snapshot.json", + metadata_path=tmp_path / "metadata.json", + ) + service.snapshot = { + "last_refreshed": now.isoformat(), + "next_refresh": (now + timedelta(days=3)).isoformat(), + "stale": False, + "refresh_status": "ok", + "sources": { + "series": {"last_refreshed": now.isoformat(), "items": [source_item]} + }, + "rails": [ + { + "id": "chinese_series", + "items": [ + { + "id": "1207107", + "media_key": "douban:36858672", + "title": "Star City S01", + "torrent_name": source_item["name"], + "media_type": "series", + "episode": "S01", + "quality_tags": ["2160p", "WEB-DL", "H.265"], + "description": source_item["smallDescr"], + } + ], + } + ], + } + + snapshot = service.get_snapshot(now) + + assert [item["id"] for item in rail_items(snapshot, "western_series")] == [ + "1207107" + ] + assert rail_items(snapshot, "chinese_series") == [] + + def test_media_wall_source_health_and_missing_source_stagger(tmp_path): now = datetime(2026, 5, 26, 12, 0, tzinfo=BEIJING_TZ) item = torrent_item(id="cached", imdb="") From a737af768f26c282f47c69b3efcd361bfefadc2d Mon Sep 17 00:00:00 2001 From: kevinplus66 Date: Mon, 13 Jul 2026 04:36:43 +0800 Subject: [PATCH 3/4] Remove stale country fallbacks --- app/routes/radar.py | 2 +- app/services/media_wall.py | 15 +++++++- frontend/components/radar/filter-selects.tsx | 3 +- frontend/lib/constants.ts | 10 ----- tests/test_media_wall.py | 39 ++++++++++++++++++-- 5 files changed, 51 insertions(+), 18 deletions(-) diff --git a/app/routes/radar.py b/app/routes/radar.py index 28a5a37..50b3f31 100644 --- a/app/routes/radar.py +++ b/app/routes/radar.py @@ -59,7 +59,7 @@ async def api_filter_options(): } else: # If no country data loaded, don't include countries field - # Frontend will use its fallback data + # Frontend hides country choices until the authoritative mapping is available. return FILTER_OPTIONS diff --git a/app/services/media_wall.py b/app/services/media_wall.py index ebe8abe..8fb00d5 100644 --- a/app/services/media_wall.py +++ b/app/services/media_wall.py @@ -935,13 +935,21 @@ def _metadata_language_region_hint(metadata: Dict[str, Any]) -> Optional[str]: def _torrent_country_region_hint(torrent: Dict[str, Any]) -> Optional[str]: - for country_id in _torrent_country_ids(torrent): - region = _country_region_hint(state.COUNTRY_LABELS.get(country_id, "")) + for country_label in _torrent_country_labels(torrent): + region = _country_region_hint(country_label) if region: return region return None +def _torrent_country_labels(torrent: Dict[str, Any]) -> List[str]: + return [ + state.COUNTRY_LABELS[country_id] + for country_id in _torrent_country_ids(torrent) + if country_id in state.COUNTRY_LABELS + ] + + def _torrent_region_hint(torrent: Dict[str, Any]) -> Optional[str]: text = _torrent_text(torrent) return ( @@ -1347,6 +1355,7 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any for item in items: source_item = source_items_by_id.get(str(item.get("id")), {}) source_region = _torrent_country_region_hint(source_item) + source_country_known = bool(_torrent_country_labels(source_item)) if not _is_cached_family_item_allowed(rail_id, item, now): continue if rail_id == "foreign_movies" and source_region == "chinese": @@ -1354,6 +1363,8 @@ def _sanitize_snapshot(snapshot: Dict[str, Any], now: datetime) -> Dict[str, Any target_rail = rail_id if rail_id in {"western_series", "asian_series", "chinese_series"}: region = _cached_item_region_hint(item) or source_region + if region is None and source_country_known: + continue if region: target_rail = { "western": "western_series", diff --git a/frontend/components/radar/filter-selects.tsx b/frontend/components/radar/filter-selects.tsx index 943375b..d64dfe1 100644 --- a/frontend/components/radar/filter-selects.tsx +++ b/frontend/components/radar/filter-selects.tsx @@ -105,8 +105,7 @@ export function FilterSelects({ }); }; - // 使用动态数据或回退到硬编码数据 - const countries = filterOptions?.countries || FILTER_OPTIONS.countries; + const countries = filterOptions?.countries ?? []; const standards = (filterOptions?.standards && filterOptions.standards.length > 0) ? filterOptions.standards : FALLBACK_STANDARDS; diff --git a/frontend/lib/constants.ts b/frontend/lib/constants.ts index b772096..0327961 100644 --- a/frontend/lib/constants.ts +++ b/frontend/lib/constants.ts @@ -187,16 +187,6 @@ export const NORMAL_CATEGORY_IDS = [ // ============ 筛选选项 ============ export const FILTER_OPTIONS = { - countries: [ - { id: 1, name_zh: "中国大陆", name_en: "China" }, - { id: 2, name_zh: "香港", name_en: "Hong Kong" }, - { id: 3, name_zh: "台湾", name_en: "Taiwan" }, - { id: 4, name_zh: "美国", name_en: "USA" }, - { id: 5, name_zh: "日本", name_en: "Japan" }, - { id: 6, name_zh: "韩国", name_en: "Korea" }, - { id: 7, name_zh: "英国", name_en: "UK" }, - { id: 8, name_zh: "法国", name_en: "France" }, - ] as FilterOption[], discounts: [ { id: "FREE", name_zh: "免费", name_en: "Free" }, { id: "_2X_FREE", name_zh: "2x免费", name_en: "2x Free" }, diff --git a/tests/test_media_wall.py b/tests/test_media_wall.py index e85594b..e34cc6d 100644 --- a/tests/test_media_wall.py +++ b/tests/test_media_wall.py @@ -2927,7 +2927,9 @@ def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( tmp_path, monkeypatch ): now = datetime(2026, 7, 13, 12, 0, tzinfo=BEIJING_TZ) - monkeypatch.setattr(state, "COUNTRY_LABELS", {2: "United States of America"}) + monkeypatch.setattr( + state, "COUNTRY_LABELS", {2: "United States of America", 6: "France"} + ) source_item = torrent_item( id="1207107", category=402, @@ -2936,6 +2938,14 @@ def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( small_descr="星城 / Star City / 《为全人类》衍生剧 | 类别:剧情 科幻 战争", ) source_item["countries"] = ["2"] + french_source = torrent_item( + id="french-series", + category=402, + name="Paris Show S01 2026 2160p WEB-DL H.265", + douban="french-series", + small_descr="剧情 | 第1季", + ) + french_source["countries"] = ["6"] service = MediaWallService( client=FakeMediaClient(), snapshot_path=tmp_path / "snapshot.json", @@ -2947,7 +2957,10 @@ def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( "stale": False, "refresh_status": "ok", "sources": { - "series": {"last_refreshed": now.isoformat(), "items": [source_item]} + "series": { + "last_refreshed": now.isoformat(), + "items": [source_item, french_source], + } }, "rails": [ { @@ -2964,7 +2977,22 @@ def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( "description": source_item["smallDescr"], } ], - } + }, + { + "id": "asian_series", + "items": [ + { + "id": "french-series", + "media_key": "douban:french-series", + "title": "Paris Show", + "torrent_name": french_source["name"], + "media_type": "series", + "episode": "S01", + "quality_tags": ["2160p", "WEB-DL", "H.265"], + "description": french_source["smallDescr"], + } + ], + }, ], } @@ -2974,6 +3002,11 @@ def test_media_wall_sanitizer_uses_raw_country_for_old_star_city_cache( "1207107" ] assert rail_items(snapshot, "chinese_series") == [] + assert "french-series" not in { + item["id"] + for rail_id in ("western_series", "asian_series", "chinese_series") + for item in rail_items(snapshot, rail_id) + } def test_media_wall_source_health_and_missing_source_stagger(tmp_path): From 58f28aa569e9cda19de60fef59f93a6287731f7c Mon Sep 17 00:00:00 2001 From: kevinplus66 Date: Mon, 13 Jul 2026 04:39:48 +0800 Subject: [PATCH 4/4] Hide unavailable country filter --- frontend/components/radar/filter-selects.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/radar/filter-selects.tsx b/frontend/components/radar/filter-selects.tsx index d64dfe1..d222b3e 100644 --- a/frontend/components/radar/filter-selects.tsx +++ b/frontend/components/radar/filter-selects.tsx @@ -145,7 +145,7 @@ export function FilterSelects({ )} {/* 国家/地区筛选 */} - {visibleFilters.includes("country") && ( + {visibleFilters.includes("country") && countries.length > 0 && (