Merge pull request #6892 from MiCat-S/fix/media-scrape-manage-permission

fix(media): 刮削接口要求管理权限,与手动整理和文件管理保持一致
This commit is contained in:
jxxghp
2026-10-05 16:43:11 +08:00
committed by GitHub
4 changed files with 63 additions and 7 deletions

View File

@@ -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,
_,
)

View File

@@ -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` 时清理命中的成功历史和非移动模式旧目标后重新整理 |

View File

@@ -21,6 +21,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 message as message_endpoint
from app.api.endpoints import plugin as plugin_endpoint
from app.api.endpoints import rule as rule_endpoint
@@ -182,6 +183,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,

View File

@@ -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,