diff --git a/app/modules/mediavault/api.py b/app/modules/mediavault/api.py index cf14537fe..99d11f94d 100644 --- a/app/modules/mediavault/api.py +++ b/app/modules/mediavault/api.py @@ -1,6 +1,5 @@ from dataclasses import dataclass from typing import Any, Dict, List, Optional, Union -from urllib.parse import urlencode from app.adapters.network.http import RequestUtils from app.foundation.url import UrlUtils @@ -45,12 +44,15 @@ class Api: self._request_utils.close() def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str: - """拼装带鉴权的图片直链;item_id 传媒体库 ID 时得到媒体库封面。""" + """拼装图片直链;item_id 传媒体库 ID 时得到媒体库封面。 + + MediaVault 的图片是免鉴权资源,这里**不能**带上 API Key:该地址会随接口 + 响应交给浏览器加载,带 Key 等于把管理凭据下发到客户端。 + """ if not self.configured or not item_id: return "" base = (UrlUtils.standardize_base_url(host).rstrip("/") if host else self._host) - query = urlencode({"api_key": self._apikey}) - return f"{base}{self.LIBRARY_PATH}/items/{item_id}/image/{image_type}?{query}" + return f"{base}{self.LIBRARY_PATH}/items/{item_id}/image/{image_type}" def request( self, diff --git a/app/modules/mediavault/mediavault.py b/app/modules/mediavault/mediavault.py index 564dc0dac..290740e95 100644 --- a/app/modules/mediavault/mediavault.py +++ b/app/modules/mediavault/mediavault.py @@ -222,6 +222,32 @@ class MediaVault: return None return result.data + def __search_rows(self, keyword: str, kinds: str) -> Optional[Generator[Dict[str, Any], Any, None]]: + """按关键字翻页产出条目原始记录。 + + MediaVault 的关键字查询是模糊匹配,命中数可能超过单页上限;只读第一页会让 + 存在性判断把排在后面的目标误判成「未入库」,因此这里翻页直到取完。 + 连接失败返回 None,与「查得到但没有」区分开。 + """ + first = self.__query_items(keyword=keyword, kinds=kinds, page=1, page_size=self.PAGE_LIMIT) + if first is None: + return None + + def rows() -> Generator[Dict[str, Any], Any, None]: + result: Optional[Dict[str, Any]] = first + page = 1 + while result is not None: + batch = result.get("items") or [] + yield from batch + if len(batch) < self.PAGE_LIMIT: + return + page += 1 + result = self.__query_items( + keyword=keyword, kinds=kinds, page=page, page_size=self.PAGE_LIMIT + ) + + return rows() + # ── 条目 ──────────────────────────────────────────────────── def get_iteminfo(self, itemid: str) -> Optional[_SchemaMediaServerItem]: @@ -243,11 +269,11 @@ class MediaVault: """按标题和年份检查电影是否存在。""" if not title or not self.is_configured(): return None - result = self.__query_items(keyword=title, kinds="Movie", page_size=self.PAGE_LIMIT) - if result is None: + rows = self.__search_rows(keyword=title, kinds="Movie") + if rows is None: return None movies = [] - for row in result.get("items") or []: + for row in rows: item = self.__format_item_info(row) if not item or item.title != title: continue @@ -310,10 +336,10 @@ class MediaVault: media_id: Optional[str], ) -> Optional[str]: """按标题定位剧集条目 ID;连接失败返回 None,未找到返回空串。""" - result = self.__query_items(keyword=title, kinds="Series", page_size=self.PAGE_LIMIT) - if result is None: + rows = self.__search_rows(keyword=title, kinds="Series") + if rows is None: return None - for row in result.get("items") or []: + for row in rows: item = self.__format_item_info(row) if not item or item.title != title: continue @@ -475,6 +501,8 @@ class MediaVault: if not row_id: continue is_episode = row.get("kind") == "Episode" + # Series 与 Episode 都是电视剧;只有分集才拼「季:集 - 分集名」副标题 + is_tv = is_episode or row.get("kind") == "Series" title: Optional[str] = row.get("title") subtitle: Optional[str] = None if is_episode: @@ -494,7 +522,7 @@ class MediaVault: item_id=row_id, title=title, subtitle=subtitle, - type=MediaType.TV.value if is_episode else MediaType.MOVIE.value, + type=MediaType.TV.value if is_tv else MediaType.MOVIE.value, image=self._api.image_url(str(image_id or row_id), "primary"), link=self.get_play_url(row_id), percent=percent, @@ -562,7 +590,10 @@ class MediaVault: logger.info(f"MediaVault 中未找到 {item.title} 对应的媒体库,将扫描全部媒体库") if unmatched: return self.refresh_root_library() - return all(self.__queue_scan(library_id) for library_id in matched) + # 先全部发出排队请求再汇总:交给 all() 的生成器会在首个失败处短路, + # 导致同批命中的其余媒体库收不到扫描任务 + results = [self.__queue_scan(library_id) for library_id in sorted(matched)] + return all(results) @staticmethod def __match_library_by_path( diff --git a/tests/test_mediavault_module.py b/tests/test_mediavault_module.py index 7f0c86f05..6c43c6863 100644 --- a/tests/test_mediavault_module.py +++ b/tests/test_mediavault_module.py @@ -5,7 +5,7 @@ from typing import Optional import pytest -from app.modules.mediavault.api import Result +from app.modules.mediavault.api import Api, Result from app.modules.mediavault.mediavault import MediaVault from app.schemas.mediaserver import RefreshMediaItem from app.schemas.types import MediaSource, MediaType @@ -28,7 +28,8 @@ class _FakeApi: self.closed = True def image_url(self, item_id: str, image_type: str, host: Optional[str] = None) -> str: - return f"{host or self._host}/api/v1/media-library/items/{item_id}/image/{image_type}?api_key=k" + # 复用真实实现,避免假 Api 自行拼装掩盖「URL 不得携带凭据」这一约束 + return Api(host=host or self._host, apikey="k").image_url(item_id, image_type) def request(self, api, method=None, params=None, data=None, base_path=None, suppress_log=False): self.calls.append({"api": api, "method": method, "params": params or {}, "data": data, @@ -144,7 +145,7 @@ def test_get_librarys_maps_type_and_builds_image_url(): ] assert libraries[0].path == ["/mnt/movies"] assert libraries[1].path == "/mnt/tv" - assert libraries[0].image.endswith("/items/lib-1/image/primary?api_key=k") + assert libraries[0].image.endswith("/items/lib-1/image/primary") assert libraries[0].server_type == "mediavault" @@ -441,3 +442,92 @@ def test_disconnect_closes_session(): client.disconnect() assert client._api.closed is True assert client.is_authenticated() is False + + +# ── PR-Agent 审查修复的回归 ────────────────────────────────────────── + + +def test_get_movies_pages_past_the_first_page(): + """关键字命中超过单页上限时,仍要能找到排在后面的目标电影。""" + rows = [_item_row(i, title=f"其它{i}") for i in range(MediaVault.PAGE_LIMIT)] + rows.append(_item_row(999, title="沙丘", year=2021, tmdb_id=438631)) + client = _client({"/items": _paged_items(rows)}) + + matched = client.get_movies(title="沙丘", year="2021") + + assert [m.item_id for m in matched] == ["id-999"] + assert [call["params"]["page"] for call in client._api.calls] == [1, 2] + + +def test_find_series_pages_past_the_first_page(): + """按标题定位剧集时同样要翻页,否则整部剧会被误判成未入库。""" + rows = [_item_row(i, kind="Series", title=f"其它{i}") for i in range(MediaVault.PAGE_LIMIT)] + rows.append(_item_row(999, kind="Series", title="剧A", year=2020)) + routes = { + "/items": _paged_items(rows), + "/items/id-999/episodes": Result(True, {"seasons": {"1": [1, 2]}}), + } + + item_id, seasons = _client(routes).get_tv_episodes(title="剧A", year="2020") + + assert item_id == "id-999" + assert seasons == {1: [1, 2]} + + +def test_latest_marks_series_as_tv_not_movie(): + """最新入库里的剧集条目类型必须是电视剧。""" + rows = [_item_row(1, kind="Series", title="剧A"), _item_row(2, kind="Movie", title="片B")] + client = _client({"/items": _paged_items(rows)}) + + latest = client.get_latest(num=2) + + assert [(item.title, item.type) for item in latest] == [ + ("剧A", MediaType.TV.value), + ("片B", MediaType.MOVIE.value), + ] + # 剧集不是分集,不应拼出「季:集」副标题 + assert latest[0].subtitle == "2020" + + +def test_refresh_queues_every_matched_library_even_if_one_fails(): + """同批命中多个媒体库时,前面的扫描失败不能让后面的媒体库被跳过。""" + routes = { + "/libraries": Result(True, {"items": [ + {"id": "lib-1", "root_paths": ["/mnt/a"]}, + {"id": "lib-2", "root_paths": ["/mnt/b"]}, + ]}), + "/libraries/lib-1/scan-task": Result(False, None, "busy", 409), + "/libraries/lib-2/scan-task": Result(True, {}), + } + client = _client(routes) + + ok = client.refresh_library_by_items([ + RefreshMediaItem(title="A", target_path=Path("/mnt/a/A (2020)")), + RefreshMediaItem(title="B", target_path=Path("/mnt/b/B (2021)")), + ]) + + assert ok is False + scanned = [call["api"] for call in client._api.calls if call["api"].endswith("scan-task")] + assert scanned == ["/libraries/lib-1/scan-task", "/libraries/lib-2/scan-task"] + + +def test_image_url_never_carries_credentials(): + """图片地址会交给浏览器直接加载,绝不能带上管理 API Key。""" + url = Api(host="http://mv.local", apikey="super-secret-admin-key").image_url("id-1", "primary") + + assert url == "http://mv.local/api/v1/media-library/items/id-1/image/primary" + assert "super-secret-admin-key" not in url + assert "api_key" not in url + + +def test_play_item_and_backdrop_images_carry_no_credentials(): + """展示类接口产出的图片地址同样不得携带凭据。""" + rows = [_item_row(1, has_backdrop=True), _item_row(2, kind="Episode", series_id="s-1")] + client = _client({"/items": _paged_items(rows)}) + + urls = [item.image for item in client.get_latest(num=2)] + urls += client.get_latest_backdrops(num=1) + urls += [lib.image for lib in (client.get_librarys() or [])] + + assert urls + assert all("api_key" not in url for url in urls)