From 873c4c7087eb41641535d57811431da89eacf40e Mon Sep 17 00:00:00 2001 From: LIN student <86354789+MiCat-S@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:04:10 +0800 Subject: [PATCH] =?UTF-8?q?fix(media):=20=E5=88=AE=E5=89=8A=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E8=A6=81=E6=B1=82=E7=AE=A1=E7=90=86=E6=9D=83=E9=99=90?= =?UTF-8?q?=EF=BC=8C=E4=B8=8E=E6=89=8B=E5=8A=A8=E6=95=B4=E7=90=86=E5=92=8C?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E7=AE=A1=E7=90=86=E4=BF=9D=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E8=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/v1/media/scrape/{storage} 只依赖 verify_token,任何登录用户都可 调用。该接口以 overwrite=True 调用刮削链,会覆盖目标目录已有的海报、背景图 和 NFO,音频文件还会改写标签;存储与路径完全由请求提供。 同类写操作 /transfer/manual、/transfer/manual/target-path、/storage/* 以及 整理记录的写操作都要求 manage 权限;前端只在文件管理页(需 manage 权限) 调用该接口,Agent 策略也已把 media.scrape 定为管理员操作。 现改为依赖 get_current_active_manage_user。_scrape_impl 内部没有使用令牌 载荷,去掉其中未使用的 Depends 参数。API_TOKEN/apikey 调用解析为 SUPERUSER 对应的超级管理员,行为不变。 Co-Authored-By: Claude Opus 5.5 --- app/api/endpoints/media.py | 9 ++--- docs/mcp-api.md | 2 +- tests/test_api_authorization.py | 2 + tests/test_media_scrape_endpoint.py | 57 +++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 7 deletions(-) diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 1ff653e36..fc256483c 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -7,7 +7,7 @@ from pydantic import BeforeValidator from app.adapters.web.security.access import verify_apitoken, verify_token from app.api.context import get_classification_runtime -from app.api.dependencies.auth import get_current_active_user +from app.api.dependencies.auth import get_current_active_manage_user, get_current_active_user from app.api.response import ( CompatibleCountParam, CompatiblePageParam, @@ -420,7 +420,6 @@ def _scrape_impl( type_name: Optional[MediaType] = None, music_type: Optional[str] = None, episode_group: Optional[str] = None, - _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 刮削媒体信息,可按请求指定媒体数据源及其原生ID @@ -432,7 +431,6 @@ def _scrape_impl( :param type_name: 媒体类型 :param music_type: 音乐实体类型,支持 recording 和 album :param episode_group: TMDB 电视剧剧集组 - :param _: Token校验 """ if not fileitem or not fileitem.path: return _SchemaResponse(success=False, message="刮削路径无效") @@ -531,9 +529,9 @@ def scrape( Optional[str], Query(description="TMDB 剧集组编号,用于按指定季集顺序识别电视剧"), ] = None, - _: _SchemaTokenPayload = Depends(verify_token), + _: object = Depends(get_current_active_manage_user), ) -> Any: - """按请求级媒体身份、剧集组和音乐实体设置刮削单项媒体。""" + """按请求级媒体身份、剧集组和音乐实体设置刮削单项媒体;会覆盖目标目录的图片和 NFO,与文件管理一样要求管理权限。""" return _scrape_impl( fileitem, storage, @@ -542,7 +540,6 @@ def scrape( type_name, music_type, episode_group, - _, ) diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 7629a63b8..b90fde8d3 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -422,7 +422,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返 | GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `media_source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 | | GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `media_source` | | GET | `/api/v1/media/{media_id}` | 按原生 ID 查询影视或音乐详情;必填参数:`media_source`、`type_name`,其中 `media_source` 与路径中的 `media_id` 组成统一媒体身份,`type_name` 支持电影、电视剧和音乐 | -| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)和 TMDB `episode_group`。剧集组用于指定电视剧的季集顺序;音乐会按策略处理音频标签、封面和歌词 | +| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)和 TMDB `episode_group`。剧集组用于指定电视剧的季集顺序;音乐会按策略处理音频标签、封面和歌词。会覆盖目标位置已有的图片、NFO 和音频标签,与文件管理、手动整理一样需要管理权限 | | POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 | | POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 | | POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;`logids` 会先还原为同一个显式文件批次,多首音乐因而共享专辑识别上下文;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;使用 `music_type=album` 和 MusicBrainz 发行组 `media_id` 时,可另传 `musicbrainz_release_id`(Release UUID)明确选定发行版;后端核验该版属于该发行组,失败不改用默认版;`GET /api/v1/music/album/{album_id}` 接受同名可选查询参数用于预先核对该版曲目;预览与执行须保持相同 ID 和文件范围;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | diff --git a/tests/test_api_authorization.py b/tests/test_api_authorization.py index 7f481cd8d..7af4186db 100644 --- a/tests/test_api_authorization.py +++ b/tests/test_api_authorization.py @@ -20,6 +20,7 @@ from app.api.endpoints import dashboard as dashboard_endpoint from app.api.endpoints import github as github_endpoint from app.api.endpoints import history as history_endpoint from app.api.endpoints import login as login_endpoint +from app.api.endpoints import media as media_endpoint from app.api.endpoints import plugin as plugin_endpoint from app.api.endpoints import rule as rule_endpoint from app.api.endpoints import site as site_endpoint @@ -174,6 +175,7 @@ def test_manage_page_endpoints_accept_manage_permission(): transfer_endpoint.match_manual_transfer_target_path, transfer_endpoint.manual_transfer, transfer_endpoint.recommend_episode_format, + media_endpoint.scrape, ] async_endpoints = [ site_endpoint.read_sites, diff --git a/tests/test_media_scrape_endpoint.py b/tests/test_media_scrape_endpoint.py index 859950590..ad48444f1 100644 --- a/tests/test_media_scrape_endpoint.py +++ b/tests/test_media_scrape_endpoint.py @@ -1,7 +1,11 @@ +from types import SimpleNamespace from unittest.mock import AsyncMock, Mock, patch import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from app.api.dependencies.auth import get_current_active_user from app.api.endpoints import media as media_endpoint from app.api.endpoints.media import recognize_file, scrape from app.domain.context import Context, MediaInfo @@ -123,6 +127,59 @@ def test_scrape_rejects_zero_media_id_before_recognition() -> None: scraping_chain.assert_not_called() +def _post_scrape_as(monkeypatch, current_user): + """以指定登录用户经真实路由提交刮削请求,返回响应和被替换的媒体链、刮削链。""" + media_chain = Mock() + # mkv 非音频文件,需显式关闭 Mock 的 is_audio_path 避免误入音乐分支 + media_chain.is_audio_path.return_value = False + media_chain.return_value.recognize_by_path.return_value = Context( + meta_info=MetaBase("Test Show S01E01"), + media_info=MediaInfo(title="测试剧集", type=MediaType.TV), + ) + scraping_chain = Mock() + monkeypatch.setattr(media_endpoint, "MediaChain", media_chain) + monkeypatch.setattr(media_endpoint, "ScrapingChain", scraping_chain) + + app = FastAPI() + app.include_router(media_endpoint.router, prefix="/api/v1/media") + # 只替换登录用户解析,管理权限判断仍走真实依赖 + app.dependency_overrides[get_current_active_user] = lambda: current_user + fileitem = FileItem(storage="alist", path="/tv/Test Show S01E01.mkv", type="file") + response = TestClient(app).post( + "/api/v1/media/scrape/alist", + json=fileitem.model_dump(mode="json"), + ) + return response, media_chain, scraping_chain + + +def test_scrape_http_rejects_user_without_manage_permission(monkeypatch) -> None: + """手动刮削会覆盖目录内的图片和 NFO,没有管理权限的用户不得进入识别和刮削链。""" + user = SimpleNamespace(is_active=True, is_superuser=False, permissions={}) + + response, media_chain, scraping_chain = _post_scrape_as(monkeypatch, user) + + assert response.status_code == 403 + media_chain.assert_not_called() + scraping_chain.assert_not_called() + + +@pytest.mark.parametrize( + "user", + [ + SimpleNamespace(is_active=True, is_superuser=False, permissions={"manage": True}), + SimpleNamespace(is_active=True, is_superuser=True, permissions={}), + ], + ids=["manage", "superuser"], +) +def test_scrape_http_accepts_manage_user_and_superuser(monkeypatch, user) -> None: + """具备管理权限的用户和超级管理员可与文件管理、手动整理一样执行手动刮削。""" + response, _, scraping_chain = _post_scrape_as(monkeypatch, user) + + assert response.status_code == 200 + assert response.json()["success"] is True + scraping_chain.return_value.scrape_metadata.assert_called_once() + + @pytest.mark.parametrize("media_source", list(MediaSource)) def test_source_media_id_validator_rejects_zero_for_every_source( media_source: MediaSource,