fix: preserve parent year in media recognition

This commit is contained in:
jxxghp
2026-08-06 12:45:48 +08:00
parent 7fe7be6d71
commit 4abae809c2
4 changed files with 88 additions and 6 deletions
+25 -3
View File
@@ -9,6 +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.meta import MetaBase
from app.core.metainfo import MetaInfo, MetaInfoPath 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
@@ -22,6 +23,29 @@ router = APIRouter()
MediaSource = str MediaSource = str
def _build_recognize_metainfo(
title: str,
subtitle: Optional[str] = None,
custom_words: Optional[str] = None,
) -> MetaBase:
"""构造标题识别元数据,并兼容第三方客户端传入媒体文件路径。"""
custom_word_list = custom_words.split("\n") if custom_words else None
normalized_title = title.replace("\\", "/")
title_path = Path(normalized_title)
if (
("/" in title or "\\" in title)
and "://" not in title
and title_path.suffix.lower() in settings.RMT_MEDIAEXT
):
metainfo = MetaInfoPath(
title_path,
custom_words=custom_word_list,
)
metainfo.title = title
return metainfo
return MetaInfo(title, subtitle, custom_words=custom_word_list)
def _build_media_seasons( def _build_media_seasons(
mediainfo: Any, season: Optional[int] = None, mediainfo: Any, season: Optional[int] = None,
) -> List[schemas.MediaSeason]: ) -> List[schemas.MediaSeason]:
@@ -84,9 +108,7 @@ async def recognize(
:param _: :param _:
""" """
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效 # 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
metainfo = MetaInfo( metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
title, subtitle, custom_words=custom_words.split("\n") if custom_words else None
)
mediainfo = await MediaChain().async_recognize_by_meta( mediainfo = await MediaChain().async_recognize_by_meta(
metainfo, metainfo,
source=source, source=source,
+1 -1
View File
@@ -125,7 +125,7 @@ FastAPI 异常响应保留 `detail` 字段,并在错误详情为文本时返
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
| :--- | :--- | :--- | | :--- | :--- | :--- |
| GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title``type``page``count`,可选 `source``media` 支持 `themoviedb``douban``bangumi``anilist``collection` 支持 `themoviedb``person` 支持 `themoviedb``douban` | | GET | `/api/v1/media/search` | 按标题搜索媒体、合集或人物,参数:`title``type``page``count`,可选 `source``media` 支持 `themoviedb``douban``bangumi``anilist``collection` 支持 `themoviedb``person` 支持 `themoviedb``douban` |
| GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source` | | GET | `/api/v1/media/recognize` | 识别标题,参数:`title``subtitle``custom_words`,可选 `source`;当 `title` 为含目录的媒体文件路径时,会合并父目录中的名称、年份等信息 |
| 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/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source``media_id``type_name`(电影/电视剧)可指定本次刮削媒体 |
+2 -2
View File
@@ -92,8 +92,8 @@ All endpoints are under the base URL `{MP_HOST}`. Path parameters are shown as `
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` | | GET | `/api/v1/media/search` | Search media, collections, or people by title. Params: `title` (required), `type`, `page`, `count`, optional `source`. Supported sources: `media` = `themoviedb`, `douban`, `bangumi`, `anilist`; `collection` = `themoviedb`; `person` = `themoviedb`, `douban` |
| GET | `/api/v1/media/recognize` | Recognize media from torrent title. Params: `title` (required), `subtitle`, `custom_words`, optional `source` | | GET | `/api/v1/media/recognize` | Recognize media from a torrent title or a media file path. Params: `title` (required), `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata such as title and year |
| 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 from a torrent title or media file path (API_TOKEN auth, use `--token-param`). Params: `title`, `subtitle`, `custom_words`, optional `source`; media file paths also use parent-directory metadata |
| 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. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) | | POST | `/api/v1/media/scrape/{storage}` | Scrape media metadata. Body: FileItem JSON. Optional params: `media_source`, `media_id`, `type_name` (`电影`/`电视剧`) |
+60
View File
@@ -0,0 +1,60 @@
import asyncio
from unittest.mock import AsyncMock, Mock, patch
import pytest
from app.api.endpoints.media import recognize
from app.core.context import MediaInfo
from app.schemas.types import MediaType
@pytest.mark.parametrize(
"title",
[
"武神主宰 (2020)/武神主宰.S01E680.mp4",
r"D:\武神主宰 (2020)\武神主宰.S01E680.mp4",
],
)
def test_recognize_uses_parent_metadata_for_media_file_path(title: str) -> None:
"""标题参数为媒体文件路径时应合并父目录中的名称和年份。"""
chain = Mock()
chain.async_recognize_by_meta = AsyncMock(
return_value=MediaInfo(
title="武神主宰",
year="2020",
type=MediaType.TV,
)
)
with patch("app.api.endpoints.media.MediaChain", return_value=chain):
asyncio.run(recognize(title=title, _=Mock()))
metainfo = chain.async_recognize_by_meta.await_args.args[0]
assert metainfo.name == "武神主宰"
assert metainfo.year == "2020"
assert metainfo.begin_season == 1
assert metainfo.begin_episode == 680
assert metainfo.title == title
@pytest.mark.parametrize(
"title",
[
"Fate/stay night",
"https://example.com/武神主宰.S01E680.mp4",
],
)
def test_recognize_does_not_treat_non_path_title_as_file_path(title: str) -> None:
"""普通片名和网络地址不应误走文件路径解析。"""
chain = Mock()
chain.async_recognize_by_meta = AsyncMock(
return_value=MediaInfo(title="Fate/stay night", type=MediaType.TV)
)
with (
patch("app.api.endpoints.media.MediaChain", return_value=chain),
patch("app.api.endpoints.media.MetaInfoPath") as meta_info_path,
):
asyncio.run(recognize(title=title, _=Mock()))
meta_info_path.assert_not_called()