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..dd0d64b 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 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. +- 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/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 26c9267..8fb00d5 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, @@ -43,6 +44,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 +194,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 +215,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,13 +246,11 @@ 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} -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, @@ -292,9 +296,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 +314,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 +355,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 +383,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 +416,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 +437,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 +464,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 +479,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 +522,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 +549,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 +561,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 +572,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 +601,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 +683,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 +704,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 +743,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 +831,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 +876,103 @@ 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 + +def _metadata_country_region_hint(metadata: Dict[str, Any]) -> Optional[str]: + return _country_region_hint(_metadata_fields_text(metadata, ("country", "countries"))) + + +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]: + for country_label in _torrent_country_labels(torrent): + region = _country_region_hint(country_label) + if region: + return region return None -def _torrent_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" +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) - 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 +1027,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 +1100,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 +1111,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 +1266,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 } @@ -1212,6 +1323,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 } @@ -1233,9 +1352,33 @@ 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: + 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": + continue + 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", + "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 +1390,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 +1417,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 +1443,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 +1485,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 +1504,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 +1525,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 +1564,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 +1613,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 +1629,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, "")) @@ -1474,12 +1682,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 @@ -1490,26 +1698,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", [])), @@ -1530,16 +1728,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 +1762,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 +1770,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 +1802,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 +1837,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 +1888,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 +1896,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 +1917,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 +1948,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 +1996,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 +2019,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 +2050,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 +2063,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 +2126,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 +2172,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 +2261,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 +2308,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 +2457,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 +2490,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/components/radar/filter-selects.tsx b/frontend/components/radar/filter-selects.tsx index 943375b..d222b3e 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; @@ -146,7 +145,7 @@ export function FilterSelects({ )} {/* 国家/地区筛选 */} - {visibleFilters.includes("country") && ( + {visibleFilters.includes("country") && countries.length > 0 && (