mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
feat: support explicit media ids for file scraping
This commit is contained in:
@@ -9,7 +9,7 @@ from app.chain.tmdb import TmdbChain
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.context import Context
|
from app.core.context import Context
|
||||||
from app.core.event import eventmanager
|
from app.core.event import eventmanager
|
||||||
from app.core.metainfo import MetaInfo
|
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||||
from app.core.security import verify_token, verify_apitoken
|
from app.core.security import verify_token, verify_apitoken
|
||||||
from app.db.models import User
|
from app.db.models import User
|
||||||
from app.db.user_oper import get_current_active_user, get_current_active_superuser
|
from app.db.user_oper import get_current_active_user, get_current_active_superuser
|
||||||
@@ -154,26 +154,64 @@ async def search(
|
|||||||
def scrape(
|
def scrape(
|
||||||
fileitem: schemas.FileItem,
|
fileitem: schemas.FileItem,
|
||||||
storage: Optional[str] = "local",
|
storage: Optional[str] = "local",
|
||||||
|
media_source: Optional[MediaSource] = None,
|
||||||
|
media_id: Optional[str] = None,
|
||||||
|
type_name: Optional[MediaType] = None,
|
||||||
_: schemas.TokenPayload = Depends(verify_token),
|
_: schemas.TokenPayload = Depends(verify_token),
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""
|
"""
|
||||||
刮削媒体信息
|
刮削媒体信息,可按请求指定媒体数据源及其原生ID
|
||||||
|
|
||||||
|
:param fileitem: 待刮削文件项
|
||||||
|
:param storage: 文件所在存储
|
||||||
|
:param media_source: 请求级媒体数据源
|
||||||
|
:param media_id: 数据源原生ID
|
||||||
|
:param type_name: 媒体类型
|
||||||
|
:param _: Token校验
|
||||||
"""
|
"""
|
||||||
if not fileitem or not fileitem.path:
|
if not fileitem or not fileitem.path:
|
||||||
return schemas.Response(success=False, message="刮削路径无效")
|
return schemas.Response(success=False, message="刮削路径无效")
|
||||||
|
normalized_media_id = media_id.strip() if media_id else None
|
||||||
|
if normalized_media_id and not media_source:
|
||||||
|
return schemas.Response(
|
||||||
|
success=False, message="指定媒体ID时必须同时指定媒体数据源"
|
||||||
|
)
|
||||||
|
if normalized_media_id and not normalized_media_id.isdigit():
|
||||||
|
return schemas.Response(success=False, message="媒体ID格式无效")
|
||||||
|
|
||||||
chain = MediaChain()
|
chain = MediaChain()
|
||||||
# 识别媒体信息
|
if normalized_media_id:
|
||||||
context = chain.recognize_by_path(fileitem.path, obtain_images=True)
|
meta_info = MetaInfoPath(Path(fileitem.path))
|
||||||
if not context or not context.media_info:
|
media_info = chain.recognize_media(
|
||||||
|
meta=meta_info,
|
||||||
|
mtype=type_name,
|
||||||
|
source=media_source,
|
||||||
|
mediaid=normalized_media_id,
|
||||||
|
)
|
||||||
|
if media_info:
|
||||||
|
media_info.scrape_source = media_source
|
||||||
|
chain.obtain_images(mediainfo=media_info)
|
||||||
|
else:
|
||||||
|
context = chain.recognize_by_path(
|
||||||
|
fileitem.path,
|
||||||
|
source=media_source,
|
||||||
|
obtain_images=True,
|
||||||
|
)
|
||||||
|
meta_info = context.meta_info if context else None
|
||||||
|
media_info = context.media_info if context else None
|
||||||
|
|
||||||
|
if not media_info:
|
||||||
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
return schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||||||
|
if media_source:
|
||||||
|
media_info.scrape_source = media_source
|
||||||
if storage == "local":
|
if storage == "local":
|
||||||
if not Path(fileitem.path).exists():
|
if not Path(fileitem.path).exists():
|
||||||
return schemas.Response(success=False, message="刮削路径不存在")
|
return schemas.Response(success=False, message="刮削路径不存在")
|
||||||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||||||
chain.scrape_metadata(
|
chain.scrape_metadata(
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
meta=context.meta_info,
|
meta=meta_info,
|
||||||
mediainfo=context.media_info,
|
mediainfo=media_info,
|
||||||
overwrite=True,
|
overwrite=True,
|
||||||
)
|
)
|
||||||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
|
|||||||
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source` |
|
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title`、`subtitle`、`custom_words`,可选 `source` |
|
||||||
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
|
| GET | `/api/v1/media/recognize_file` | 识别文件路径,参数:`path`,可选 `source` |
|
||||||
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 前缀 |
|
| GET | `/api/v1/media/{mediaid}` | 查询媒体详情,`mediaid` 支持 `tmdb:`、`douban:`、`bangumi:`、`anilist:` 前缀 |
|
||||||
|
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧)可指定本次刮削媒体 |
|
||||||
| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID |
|
| POST | `/api/v1/transfer/manual/target-path` | 匹配手动整理目标路径;请求体可用 `media_source` + `media_id` 指定数据源原生ID |
|
||||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid` |
|
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源,同时兼容 `tmdbid`、`doubanid` |
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
|
|||||||
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` |
|
| GET | `/api/v1/media/recognize2` | Recognize media (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source` |
|
||||||
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
|
| GET | `/api/v1/media/recognize_file` | Recognize media from file path. Params: `path` (required), optional `source` |
|
||||||
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
|
| GET | `/api/v1/media/recognize_file2` | Recognize file (API_TOKEN auth). Params: `path`, optional `source` |
|
||||||
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON |
|
| POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) |
|
||||||
| GET | `/api/v1/media/category/config` | Get category strategy config |
|
| GET | `/api/v1/media/category/config` | Get category strategy config |
|
||||||
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
|
| POST | `/api/v1/media/category/config` | Save category strategy config. Body: CategoryConfig |
|
||||||
| GET | `/api/v1/media/category` | Get auto-categorization config |
|
| GET | `/api/v1/media/category` | Get auto-categorization config |
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from app.api.endpoints.media import scrape
|
||||||
|
from app.core.context import Context, MediaInfo
|
||||||
|
from app.core.meta import MetaBase
|
||||||
|
from app.schemas import FileItem, MediaType
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_uses_explicit_media_source_and_id() -> None:
|
||||||
|
"""手动刮削应使用请求指定的数据源原生ID,并传给后续刮削流程。"""
|
||||||
|
fileitem = FileItem(storage="alist", path="/movies/Test Movie (2026).mkv", type="file")
|
||||||
|
media_info = MediaInfo(title="测试电影", type=MediaType.MOVIE)
|
||||||
|
chain = Mock()
|
||||||
|
chain.recognize_media.return_value = media_info
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
|
||||||
|
result = scrape(
|
||||||
|
fileitem=fileitem,
|
||||||
|
storage="alist",
|
||||||
|
media_source="douban",
|
||||||
|
media_id="123456",
|
||||||
|
type_name=MediaType.MOVIE,
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
chain.recognize_by_path.assert_not_called()
|
||||||
|
recognize_kwargs = chain.recognize_media.call_args.kwargs
|
||||||
|
assert recognize_kwargs["source"] == "douban"
|
||||||
|
assert recognize_kwargs["mediaid"] == "123456"
|
||||||
|
assert recognize_kwargs["mtype"] == MediaType.MOVIE
|
||||||
|
chain.obtain_images.assert_called_once_with(mediainfo=media_info)
|
||||||
|
assert media_info.scrape_source == "douban"
|
||||||
|
scrape_kwargs = chain.scrape_metadata.call_args.kwargs
|
||||||
|
assert scrape_kwargs["fileitem"] is fileitem
|
||||||
|
assert scrape_kwargs["mediainfo"] is media_info
|
||||||
|
assert scrape_kwargs["overwrite"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_keeps_automatic_recognition_compatible() -> None:
|
||||||
|
"""未指定媒体ID时应继续按路径识别,并允许仅限定请求级数据源。"""
|
||||||
|
fileitem = FileItem(storage="alist", path="/tv/Test Show S01E01.mkv", type="file")
|
||||||
|
meta_info = MetaBase("Test Show S01E01")
|
||||||
|
media_info = MediaInfo(title="测试剧集", type=MediaType.TV)
|
||||||
|
chain = Mock()
|
||||||
|
chain.recognize_by_path.return_value = Context(meta_info=meta_info, media_info=media_info)
|
||||||
|
|
||||||
|
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
|
||||||
|
result = scrape(
|
||||||
|
fileitem=fileitem,
|
||||||
|
storage="alist",
|
||||||
|
media_source="bangumi",
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is True
|
||||||
|
chain.recognize_by_path.assert_called_once_with(
|
||||||
|
fileitem.path,
|
||||||
|
source="bangumi",
|
||||||
|
obtain_images=True,
|
||||||
|
)
|
||||||
|
chain.recognize_media.assert_not_called()
|
||||||
|
assert media_info.scrape_source == "bangumi"
|
||||||
|
chain.scrape_metadata.assert_called_once_with(
|
||||||
|
fileitem=fileitem,
|
||||||
|
meta=meta_info,
|
||||||
|
mediainfo=media_info,
|
||||||
|
overwrite=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_scrape_rejects_media_id_without_source() -> None:
|
||||||
|
"""原生媒体ID缺少所属数据源时应直接返回明确错误。"""
|
||||||
|
result = scrape(
|
||||||
|
fileitem=FileItem(storage="alist", path="/movies/Test.mkv", type="file"),
|
||||||
|
storage="alist",
|
||||||
|
media_id="123456",
|
||||||
|
_=Mock(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.success is False
|
||||||
|
assert result.message == "指定媒体ID时必须同时指定媒体数据源"
|
||||||
Reference in New Issue
Block a user