mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 05:44:44 +08:00
fix(mediaserver): 修正 MediaVault 图片凭据、查询截断、剧集类型与刷新短路
代码审查在 v2 移植版上发现四处缺陷,同样存在于本分支已合入的模块: - 图片地址把管理面板的长效 API Key 拼进查询参数。该地址会随接口响应交给浏览器 直接加载,等于把管理凭据下发到客户端,可被重放到 MediaVault 的全部管理接口。 MediaVault 的媒体库图片为免鉴权资源,去掉该参数即可 - get_movies 与按标题定位剧集只读关键字查询的第一页,命中数超过单页上限时排在 后面的目标会被误判为未入库;改为翻页直到取完 - 最新入库把 Series 条目标成电影,只有 Episode 被识别为电视剧;改为两者都映射为 电视剧,「季:集 - 分集名」副标题仍只给分集 - refresh_library_by_items 把生成器交给 all(),首个媒体库排队失败就会短路,同批 命中的其余媒体库收不到扫描任务;改为先全部发出请求再汇总 补 6 条回归测试;同时收紧测试替身:假 Api 改为复用真实的 image_url 实现,避免 自行拼装掩盖「图片地址不得携带凭据」这一约束。
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user