mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 16:36:53 +08:00
fix: improve media image scraping
This commit is contained in:
+79
-15
@@ -363,6 +363,15 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
if season_image_name := season_image_name_map.get(metadata_type):
|
if season_image_name := season_image_name_map.get(metadata_type):
|
||||||
hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg"
|
hint_ext = Path(filename_hint).suffix if filename_hint else ".jpg"
|
||||||
final_filename = f"{season_image_name}{hint_ext}"
|
final_filename = f"{season_image_name}{hint_ext}"
|
||||||
|
elif item_type == ScrapingTarget.MOVIE and current_fileitem.type == "file":
|
||||||
|
# 电影文件的图片应与视频文件同级保存,避免把图片路径拼到文件名下面。
|
||||||
|
target_dir_item = parent_fileitem or self.storagechain.get_parent_item(
|
||||||
|
current_fileitem
|
||||||
|
)
|
||||||
|
if not target_dir_item:
|
||||||
|
logger.error(f"无法获取文件 {current_fileitem.path} 的父目录项。")
|
||||||
|
return current_fileitem, None
|
||||||
|
target_dir_path = Path(target_dir_item.path)
|
||||||
# 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致
|
# 如果是 EPISODE 类型的图片(如thumb),通常也是放在文件同级目录,文件名与视频文件一致
|
||||||
elif (
|
elif (
|
||||||
metadata_type in [ScrapingMetadata.THUMB]
|
metadata_type in [ScrapingMetadata.THUMB]
|
||||||
@@ -390,6 +399,52 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
target_full_path = target_dir_path / final_filename
|
target_full_path = target_dir_path / final_filename
|
||||||
return target_dir_item, target_full_path
|
return target_dir_item, target_full_path
|
||||||
|
|
||||||
|
def _get_target_fileitems_and_paths(
|
||||||
|
self,
|
||||||
|
current_fileitem: schemas.FileItem,
|
||||||
|
item_type: ScrapingTarget,
|
||||||
|
metadata_type: ScrapingMetadata,
|
||||||
|
filename_hint: Optional[str] = None,
|
||||||
|
parent_fileitem: Optional[schemas.FileItem] = None,
|
||||||
|
) -> List[Tuple[schemas.FileItem, Path]]:
|
||||||
|
"""
|
||||||
|
根据刮削上下文生成一个或多个保存目标。
|
||||||
|
季图片需要同时兼容根目录 seasonxx-poster 和季目录 poster 两种命名。
|
||||||
|
"""
|
||||||
|
target_item, target_path = self._get_target_fileitem_and_path(
|
||||||
|
current_fileitem=current_fileitem,
|
||||||
|
item_type=item_type,
|
||||||
|
metadata_type=metadata_type,
|
||||||
|
filename_hint=filename_hint,
|
||||||
|
parent_fileitem=parent_fileitem,
|
||||||
|
)
|
||||||
|
targets = [(target_item, target_path)] if target_path else []
|
||||||
|
|
||||||
|
if (
|
||||||
|
item_type != ScrapingTarget.SEASON
|
||||||
|
or not filename_hint
|
||||||
|
or not filename_hint.lower().startswith("season")
|
||||||
|
or metadata_type not in {
|
||||||
|
ScrapingMetadata.POSTER,
|
||||||
|
ScrapingMetadata.BANNER,
|
||||||
|
ScrapingMetadata.THUMB,
|
||||||
|
}
|
||||||
|
):
|
||||||
|
return targets
|
||||||
|
|
||||||
|
season_parent_item = parent_fileitem or self.storagechain.get_parent_item(
|
||||||
|
current_fileitem
|
||||||
|
)
|
||||||
|
if not season_parent_item:
|
||||||
|
logger.warn(f"无法获取季目录 {current_fileitem.path} 的父目录项,跳过根目录季图片")
|
||||||
|
return targets
|
||||||
|
|
||||||
|
season_root_path = Path(current_fileitem.path).with_name(filename_hint)
|
||||||
|
root_target = (season_parent_item, season_root_path)
|
||||||
|
if root_target not in targets:
|
||||||
|
targets.insert(0, root_target)
|
||||||
|
return targets
|
||||||
|
|
||||||
def metadata_nfo(
|
def metadata_nfo(
|
||||||
self,
|
self,
|
||||||
meta: MetaBase,
|
meta: MetaBase,
|
||||||
@@ -773,7 +828,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
self.scrape_metadata(
|
self.scrape_metadata(
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
init_folder=False,
|
init_folder=True,
|
||||||
parent=self.storagechain.get_parent_item(fileitem),
|
parent=self.storagechain.get_parent_item(fileitem),
|
||||||
overwrite=overwrite,
|
overwrite=overwrite,
|
||||||
)
|
)
|
||||||
@@ -985,8 +1040,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 获取目标 FileItem (`base_item`) 和 Path (`image_path`)
|
# 获取目标 FileItem 和 Path,季图片会同时写根目录和季目录。
|
||||||
base_item, image_path = self._get_target_fileitem_and_path(
|
image_targets = self._get_target_fileitems_and_paths(
|
||||||
current_fileitem=current_fileitem,
|
current_fileitem=current_fileitem,
|
||||||
item_type=item_type,
|
item_type=item_type,
|
||||||
metadata_type=metadata_type,
|
metadata_type=metadata_type,
|
||||||
@@ -994,19 +1049,20 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
parent_fileitem=parent_fileitem,
|
parent_fileitem=parent_fileitem,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not image_path:
|
for base_item, image_path in image_targets:
|
||||||
continue
|
if not image_path:
|
||||||
|
continue
|
||||||
|
|
||||||
# 文件存在检查
|
# 文件存在检查
|
||||||
file_exists = self.storagechain.get_file_item(
|
file_exists = self.storagechain.get_file_item(
|
||||||
storage=base_item.storage, path=image_path
|
storage=base_item.storage, path=image_path
|
||||||
)
|
|
||||||
|
|
||||||
# 刮削决策
|
|
||||||
if self._should_scrape(option, bool(file_exists), overwrite):
|
|
||||||
self._download_and_save_image(
|
|
||||||
fileitem=base_item, path=image_path, url=image_url
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 刮削决策
|
||||||
|
if self._should_scrape(option, bool(file_exists), overwrite):
|
||||||
|
self._download_and_save_image(
|
||||||
|
fileitem=base_item, path=image_path, url=image_url
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。"
|
f"未找到图片类型 {image_name} 对应的 ScrapingMetadata,跳过。"
|
||||||
@@ -1092,7 +1148,7 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
处理电影刮削
|
处理电影刮削
|
||||||
"""
|
"""
|
||||||
if fileitem.type == "file":
|
if fileitem.type == "file":
|
||||||
# 电影文件:仅处理 NFO
|
# 电影文件始终处理 NFO,直接初始化文件时再补同级目录图片。
|
||||||
self._scrape_nfo_generic(
|
self._scrape_nfo_generic(
|
||||||
current_fileitem=fileitem,
|
current_fileitem=fileitem,
|
||||||
meta=meta,
|
meta=meta,
|
||||||
@@ -1101,6 +1157,14 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
|||||||
parent_fileitem=parent,
|
parent_fileitem=parent,
|
||||||
overwrite=overwrite,
|
overwrite=overwrite,
|
||||||
)
|
)
|
||||||
|
if init_folder:
|
||||||
|
self._scrape_images_generic(
|
||||||
|
current_fileitem=fileitem,
|
||||||
|
mediainfo=mediainfo,
|
||||||
|
item_type=ScrapingTarget.MOVIE,
|
||||||
|
parent_fileitem=parent,
|
||||||
|
overwrite=overwrite,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# 电影目录:递归处理文件并初始化目录
|
# 电影目录:递归处理文件并初始化目录
|
||||||
self._handle_movie_directory(
|
self._handle_movie_directory(
|
||||||
|
|||||||
@@ -969,7 +969,24 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _process_tmdb_images(mediainfo: MediaInfo, images: dict) -> MediaInfo:
|
def _pick_best_tmdb_image(images: list) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 TMDB 图片候选中选出评分最高的文件路径。
|
||||||
|
"""
|
||||||
|
if not images:
|
||||||
|
return None
|
||||||
|
images = sorted(
|
||||||
|
images,
|
||||||
|
key=lambda x: (
|
||||||
|
x.get("vote_average") or 0,
|
||||||
|
x.get("vote_count") or 0,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return images[0].get("file_path")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _process_tmdb_images(cls, mediainfo: MediaInfo, images: dict) -> MediaInfo:
|
||||||
"""
|
"""
|
||||||
处理 TMDB 图片数据
|
处理 TMDB 图片数据
|
||||||
:param mediainfo: 媒体信息
|
:param mediainfo: 媒体信息
|
||||||
@@ -980,22 +997,16 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
images = images[0]
|
images = images[0]
|
||||||
# 背景图
|
# 背景图
|
||||||
if not mediainfo.backdrop_path:
|
if not mediainfo.backdrop_path:
|
||||||
backdrops = images.get("backdrops")
|
if image_path := cls._pick_best_tmdb_image(images.get("backdrops")):
|
||||||
if backdrops:
|
mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(image_path)
|
||||||
backdrops = sorted(backdrops, key=lambda x: x.get("vote_average"), reverse=True)
|
|
||||||
mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrops[0].get("file_path"))
|
|
||||||
# 标志
|
# 标志
|
||||||
if not mediainfo.logo_path:
|
if not mediainfo.logo_path:
|
||||||
logos = images.get("logos")
|
if image_path := cls._pick_best_tmdb_image(images.get("logos")):
|
||||||
if logos:
|
mediainfo.logo_path = settings.TMDB_IMAGE_URL(image_path)
|
||||||
logos = sorted(logos, key=lambda x: x.get("vote_average"), reverse=True)
|
|
||||||
mediainfo.logo_path = settings.TMDB_IMAGE_URL(logos[0].get("file_path"))
|
|
||||||
# 海报
|
# 海报
|
||||||
if not mediainfo.poster_path:
|
if not mediainfo.poster_path:
|
||||||
posters = images.get("posters")
|
if image_path := cls._pick_best_tmdb_image(images.get("posters")):
|
||||||
if posters:
|
mediainfo.poster_path = settings.TMDB_IMAGE_URL(image_path)
|
||||||
posters = sorted(posters, key=lambda x: x.get("vote_average"), reverse=True)
|
|
||||||
mediainfo.poster_path = settings.TMDB_IMAGE_URL(posters[0].get("file_path"))
|
|
||||||
return mediainfo
|
return mediainfo
|
||||||
|
|
||||||
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
|
||||||
@@ -1011,9 +1022,15 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
|
|
||||||
# 调用TMDB图片接口
|
# 调用TMDB图片接口
|
||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
images = self.tmdb.get_movie_images(mediainfo.tmdb_id)
|
images = self.tmdb.get_movie_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
images = self.tmdb.get_tv_images(mediainfo.tmdb_id)
|
images = self.tmdb.get_tv_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
if not images:
|
if not images:
|
||||||
return mediainfo
|
return mediainfo
|
||||||
|
|
||||||
@@ -1033,9 +1050,15 @@ class TheMovieDbModule(_ModuleBase):
|
|||||||
|
|
||||||
# 调用TMDB图片接口
|
# 调用TMDB图片接口
|
||||||
if mediainfo.type == MediaType.MOVIE:
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
images = await self.tmdb.async_get_movie_images(mediainfo.tmdb_id)
|
images = await self.tmdb.async_get_movie_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
images = await self.tmdb.async_get_tv_images(mediainfo.tmdb_id)
|
images = await self.tmdb.async_get_tv_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
if not images:
|
if not images:
|
||||||
return mediainfo
|
return mediainfo
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ class TmdbScraper:
|
|||||||
if poster_name and poster_url:
|
if poster_name and poster_url:
|
||||||
images[poster_name] = poster_url
|
images[poster_name] = poster_url
|
||||||
else:
|
else:
|
||||||
|
self.__ensure_main_images(mediainfo)
|
||||||
# 获取媒体信息中原有图片
|
# 获取媒体信息中原有图片
|
||||||
for attr_name, attr_value in vars(mediainfo).items():
|
for attr_name, attr_value in vars(mediainfo).items():
|
||||||
if (
|
if (
|
||||||
@@ -155,6 +156,52 @@ class TmdbScraper:
|
|||||||
images[image_name] = image_url
|
images[image_name] = image_url
|
||||||
return images
|
return images
|
||||||
|
|
||||||
|
def __ensure_main_images(self, mediainfo: MediaInfo) -> None:
|
||||||
|
"""
|
||||||
|
主媒体图片缺失时从 TMDB images 接口回填,避免当前语言没有图时只生成 NFO。
|
||||||
|
"""
|
||||||
|
if not mediainfo or not mediainfo.tmdb_id:
|
||||||
|
return
|
||||||
|
if mediainfo.poster_path and mediainfo.backdrop_path:
|
||||||
|
return
|
||||||
|
if mediainfo.type == MediaType.MOVIE:
|
||||||
|
image_info = self.default_tmdb.get_movie_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
image_info = self.default_tmdb.get_tv_images(
|
||||||
|
mediainfo.tmdb_id,
|
||||||
|
original_language=mediainfo.original_language,
|
||||||
|
)
|
||||||
|
if not image_info:
|
||||||
|
return
|
||||||
|
if not mediainfo.poster_path:
|
||||||
|
poster_path = self.__pick_best_image_path(image_info.get("posters"))
|
||||||
|
if poster_path:
|
||||||
|
mediainfo.poster_path = settings.TMDB_IMAGE_URL(poster_path)
|
||||||
|
if not mediainfo.backdrop_path:
|
||||||
|
backdrop_path = self.__pick_best_image_path(image_info.get("backdrops"))
|
||||||
|
if backdrop_path:
|
||||||
|
mediainfo.backdrop_path = settings.TMDB_IMAGE_URL(backdrop_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def __pick_best_image_path(images: list) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
从 TMDB 图片列表中选择评分和投票数最高的一张。
|
||||||
|
"""
|
||||||
|
if not images:
|
||||||
|
return None
|
||||||
|
images = sorted(
|
||||||
|
images,
|
||||||
|
key=lambda item: (
|
||||||
|
item.get("vote_average") or 0,
|
||||||
|
item.get("vote_count") or 0,
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return images[0].get("file_path")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]:
|
def get_season_poster(seasoninfo: dict, season: int) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from typing import Optional, List
|
|||||||
|
|
||||||
import zhconv
|
import zhconv
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
from app.utils.string import StringUtils
|
from app.utils.string import StringUtils
|
||||||
@@ -1232,7 +1233,24 @@ class TmdbApi:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_movie_images(self, tmdbid: int) -> dict:
|
@staticmethod
|
||||||
|
def _build_include_image_language(original_language: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
构造图片接口语言回退列表,避免当前语言没有图片时返回空列表。
|
||||||
|
"""
|
||||||
|
languages = []
|
||||||
|
for language in (
|
||||||
|
getattr(settings, "TMDB_LOCALE", None),
|
||||||
|
"en",
|
||||||
|
None,
|
||||||
|
original_language,
|
||||||
|
):
|
||||||
|
language = "null" if language is None else str(language).strip()
|
||||||
|
if language and language not in languages:
|
||||||
|
languages.append(language)
|
||||||
|
return ",".join(languages)
|
||||||
|
|
||||||
|
def get_movie_images(self, tmdbid: int, original_language: Optional[str] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
获取电影的图片
|
获取电影的图片
|
||||||
"""
|
"""
|
||||||
@@ -1240,12 +1258,17 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
||||||
return self.movie.images(movie_id=tmdbid) or {}
|
return self.movie.images(
|
||||||
|
movie_id=tmdbid,
|
||||||
|
include_image_language=self._build_include_image_language(
|
||||||
|
original_language
|
||||||
|
),
|
||||||
|
) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def get_tv_images(self, tmdbid: int) -> dict:
|
def get_tv_images(self, tmdbid: int, original_language: Optional[str] = None) -> dict:
|
||||||
"""
|
"""
|
||||||
获取电视剧的图片
|
获取电视剧的图片
|
||||||
"""
|
"""
|
||||||
@@ -1253,7 +1276,12 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
||||||
return self.tv.images(tv_id=tmdbid) or {}
|
return self.tv.images(
|
||||||
|
tv_id=tmdbid,
|
||||||
|
include_image_language=self._build_include_image_language(
|
||||||
|
original_language
|
||||||
|
),
|
||||||
|
) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return {}
|
return {}
|
||||||
@@ -1965,7 +1993,9 @@ class TmdbApi:
|
|||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return []
|
return []
|
||||||
|
|
||||||
async def async_get_movie_images(self, tmdbid: int) -> dict:
|
async def async_get_movie_images(
|
||||||
|
self, tmdbid: int, original_language: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
获取电影的图片(异步版本)
|
获取电影的图片(异步版本)
|
||||||
"""
|
"""
|
||||||
@@ -1973,12 +2003,19 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
logger.debug(f"正在获取电影图片:{tmdbid}...")
|
||||||
return await self.movie.async_images(movie_id=tmdbid) or {}
|
return await self.movie.async_images(
|
||||||
|
movie_id=tmdbid,
|
||||||
|
include_image_language=self._build_include_image_language(
|
||||||
|
original_language
|
||||||
|
),
|
||||||
|
) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def async_get_tv_images(self, tmdbid: int) -> dict:
|
async def async_get_tv_images(
|
||||||
|
self, tmdbid: int, original_language: Optional[str] = None
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
获取电视剧的图片(异步版本)
|
获取电视剧的图片(异步版本)
|
||||||
"""
|
"""
|
||||||
@@ -1986,7 +2023,12 @@ class TmdbApi:
|
|||||||
return {}
|
return {}
|
||||||
try:
|
try:
|
||||||
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
logger.debug(f"正在获取电视剧图片:{tmdbid}...")
|
||||||
return await self.tv.async_images(tv_id=tmdbid) or {}
|
return await self.tv.async_images(
|
||||||
|
tv_id=tmdbid,
|
||||||
|
include_image_language=self._build_include_image_language(
|
||||||
|
original_language
|
||||||
|
),
|
||||||
|
) or {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(str(e))
|
logger.error(str(e))
|
||||||
return {}
|
return {}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
from app.core.context import MediaInfo
|
from app.core.context import MediaInfo
|
||||||
from app.core.meta import MetaBase
|
from app.core.meta import MetaBase
|
||||||
from app.modules.douban import DoubanModule
|
from app.modules.douban import DoubanModule
|
||||||
from app.modules.themoviedb import TheMovieDbModule
|
from app.modules.themoviedb import TheMovieDbModule
|
||||||
|
from app.modules.themoviedb.scraper import TmdbScraper
|
||||||
|
from app.modules.themoviedb.tmdbapi import TmdbApi
|
||||||
from app.schemas.types import MediaType
|
from app.schemas.types import MediaType
|
||||||
|
|
||||||
|
|
||||||
@@ -96,6 +98,68 @@ class MediaRecognizeModulesTest(TestCase):
|
|||||||
module._async_search_by_name.assert_called()
|
module._async_search_by_name.assert_called()
|
||||||
module.tmdb.async_match_web.assert_not_called()
|
module.tmdb.async_match_web.assert_not_called()
|
||||||
|
|
||||||
|
def test_tmdb_image_language_fallback_includes_current_en_null_and_original(self):
|
||||||
|
"""TMDB 图片查询应带上语言回退,避免当前语言没有图片时直接返回空。"""
|
||||||
|
with patch("app.modules.themoviedb.tmdbapi.settings") as mock_settings:
|
||||||
|
mock_settings.TMDB_LOCALE = "zh"
|
||||||
|
|
||||||
|
result = TmdbApi._build_include_image_language("ja")
|
||||||
|
|
||||||
|
self.assertEqual(result, "zh,en,null,ja")
|
||||||
|
|
||||||
|
def test_tmdb_obtain_images_uses_language_fallback_and_picks_best(self):
|
||||||
|
"""obtain_images 应从图片接口回填缺失的海报和背景图。"""
|
||||||
|
module = TheMovieDbModule()
|
||||||
|
module.tmdb = Mock()
|
||||||
|
module.tmdb.get_movie_images.return_value = {
|
||||||
|
"posters": [
|
||||||
|
{"file_path": "/low-poster.jpg", "vote_average": 2, "vote_count": 10},
|
||||||
|
{"file_path": "/best-poster.jpg", "vote_average": 8, "vote_count": 1},
|
||||||
|
],
|
||||||
|
"backdrops": [
|
||||||
|
{"file_path": "/best-backdrop.jpg", "vote_average": 7, "vote_count": 2},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mediainfo = MediaInfo(
|
||||||
|
tmdb_id=100,
|
||||||
|
type=MediaType.MOVIE,
|
||||||
|
original_language="ja",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = module.obtain_images(mediainfo)
|
||||||
|
|
||||||
|
self.assertIs(result, mediainfo)
|
||||||
|
module.tmdb.get_movie_images.assert_called_once_with(100, original_language="ja")
|
||||||
|
self.assertTrue(mediainfo.poster_path.endswith("/best-poster.jpg"))
|
||||||
|
self.assertTrue(mediainfo.backdrop_path.endswith("/best-backdrop.jpg"))
|
||||||
|
|
||||||
|
def test_tmdb_scraper_metadata_img_fetches_missing_main_images(self):
|
||||||
|
"""主媒体图片缺失时,刮削图片列表应先从 TMDB images 接口补齐。"""
|
||||||
|
scraper = TmdbScraper()
|
||||||
|
scraper._meta_tmdb = Mock()
|
||||||
|
scraper._meta_tmdb.get_movie_images.return_value = {
|
||||||
|
"posters": [
|
||||||
|
{"file_path": "/fallback-poster.jpg", "vote_average": 5},
|
||||||
|
],
|
||||||
|
"backdrops": [
|
||||||
|
{"file_path": "/fallback-backdrop.jpg", "vote_average": 4},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mediainfo = MediaInfo(
|
||||||
|
tmdb_id=200,
|
||||||
|
type=MediaType.MOVIE,
|
||||||
|
original_language="en",
|
||||||
|
)
|
||||||
|
|
||||||
|
images = scraper.get_metadata_img(mediainfo)
|
||||||
|
|
||||||
|
scraper._meta_tmdb.get_movie_images.assert_called_once_with(
|
||||||
|
200,
|
||||||
|
original_language="en",
|
||||||
|
)
|
||||||
|
self.assertIn("poster.jpg", images)
|
||||||
|
self.assertIn("backdrop.jpg", images)
|
||||||
|
|
||||||
def test_douban_prepare_search_names_deduplicates_simplified_name(self):
|
def test_douban_prepare_search_names_deduplicates_simplified_name(self):
|
||||||
"""豆瓣候选名称应保留顺序,并去掉繁简转换后的重复项。"""
|
"""豆瓣候选名称应保留顺序,并去掉繁简转换后的重复项。"""
|
||||||
meta = MetaBase("流浪地球")
|
meta = MetaBase("流浪地球")
|
||||||
|
|||||||
@@ -15,11 +15,20 @@ from app.core.metainfo import MetaInfo
|
|||||||
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
|
from app.schemas.types import EventType, MediaType, ScrapingTarget, ScrapingMetadata, ScrapingPolicy
|
||||||
|
|
||||||
|
|
||||||
|
def reset_media_chain_singleton():
|
||||||
|
"""清理 MediaChain 单例,避免测试间复用被 mock 的实例。"""
|
||||||
|
MediaChain._instances.pop((MediaChain, (), frozenset()), None)
|
||||||
|
|
||||||
|
|
||||||
class TestMediaScrapingPaths(unittest.TestCase):
|
class TestMediaScrapingPaths(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
self.media_chain = MediaChain()
|
self.media_chain = MediaChain()
|
||||||
self.media_chain.storagechain = MagicMock()
|
self.media_chain.storagechain = MagicMock()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
|
|
||||||
def test_movie_file_nfo_path(self):
|
def test_movie_file_nfo_path(self):
|
||||||
fileitem = schemas.FileItem(path="/movies/avatar.mkv", name="avatar.mkv", type="file", storage="local")
|
fileitem = schemas.FileItem(path="/movies/avatar.mkv", name="avatar.mkv", type="file", storage="local")
|
||||||
parent_item = schemas.FileItem(path="/movies", name="movies", type="dir", storage="local")
|
parent_item = schemas.FileItem(path="/movies", name="movies", type="dir", storage="local")
|
||||||
@@ -75,6 +84,25 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
|||||||
self.assertEqual(target_item, fileitem)
|
self.assertEqual(target_item, fileitem)
|
||||||
self.assertEqual(target_path, Path("/tv/Show/Season 1/poster.jpg"))
|
self.assertEqual(target_path, Path("/tv/Show/Season 1/poster.jpg"))
|
||||||
|
|
||||||
|
def test_season_dir_poster_paths_include_root_and_season_dir(self):
|
||||||
|
"""季海报应同时写剧集根目录和季目录,兼容不同媒体库。"""
|
||||||
|
parent_item = schemas.FileItem(path="/tv/Show", name="Show", type="dir", storage="local")
|
||||||
|
fileitem = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
||||||
|
targets = self.media_chain._get_target_fileitems_and_paths(
|
||||||
|
current_fileitem=fileitem,
|
||||||
|
item_type=ScrapingTarget.SEASON,
|
||||||
|
metadata_type=ScrapingMetadata.POSTER,
|
||||||
|
filename_hint="season01-poster.jpg",
|
||||||
|
parent_fileitem=parent_item,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
targets,
|
||||||
|
[
|
||||||
|
(parent_item, Path("/tv/Show/season01-poster.jpg")),
|
||||||
|
(fileitem, Path("/tv/Show/Season 1/poster.jpg")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_season_dir_specials_poster_path(self):
|
def test_season_dir_specials_poster_path(self):
|
||||||
fileitem = schemas.FileItem(path="/tv/Show/Specials", name="Specials", type="dir", storage="local")
|
fileitem = schemas.FileItem(path="/tv/Show/Specials", name="Specials", type="dir", storage="local")
|
||||||
target_item, target_path = self.media_chain._get_target_fileitem_and_path(
|
target_item, target_path = self.media_chain._get_target_fileitem_and_path(
|
||||||
@@ -86,6 +114,20 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
|||||||
self.assertEqual(target_item, fileitem)
|
self.assertEqual(target_item, fileitem)
|
||||||
self.assertEqual(target_path, Path("/tv/Show/Specials/poster.jpg"))
|
self.assertEqual(target_path, Path("/tv/Show/Specials/poster.jpg"))
|
||||||
|
|
||||||
|
def test_movie_file_image_path_uses_parent_dir(self):
|
||||||
|
"""直接刮削电影文件时,图片应保存到父目录。"""
|
||||||
|
fileitem = schemas.FileItem(path="/movies/Avatar/Avatar.mkv", name="Avatar.mkv", type="file", storage="local")
|
||||||
|
parent_item = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local")
|
||||||
|
target_item, target_path = self.media_chain._get_target_fileitem_and_path(
|
||||||
|
current_fileitem=fileitem,
|
||||||
|
item_type=ScrapingTarget.MOVIE,
|
||||||
|
metadata_type=ScrapingMetadata.POSTER,
|
||||||
|
filename_hint="poster.jpg",
|
||||||
|
parent_fileitem=parent_item,
|
||||||
|
)
|
||||||
|
self.assertEqual(target_item, parent_item)
|
||||||
|
self.assertEqual(target_path, Path("/movies/Avatar/poster.jpg"))
|
||||||
|
|
||||||
def test_episode_file_nfo_path(self):
|
def test_episode_file_nfo_path(self):
|
||||||
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
||||||
parent_item = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
parent_item = schemas.FileItem(path="/tv/Show/Season 1", name="Season 1", type="dir", storage="local")
|
||||||
@@ -101,6 +143,7 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMediaScrapingNFO(unittest.TestCase):
|
class TestMediaScrapingNFO(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
self.media_chain = MediaChain()
|
self.media_chain = MediaChain()
|
||||||
self.media_chain.storagechain = MagicMock()
|
self.media_chain.storagechain = MagicMock()
|
||||||
self.media_chain.metadata_nfo = MagicMock(return_value="<nfo></nfo>")
|
self.media_chain.metadata_nfo = MagicMock(return_value="<nfo></nfo>")
|
||||||
@@ -111,6 +154,9 @@ class TestMediaScrapingNFO(unittest.TestCase):
|
|||||||
self.meta = MetaInfo("Avatar (2009)")
|
self.meta = MetaInfo("Avatar (2009)")
|
||||||
self.mediainfo = MediaInfo()
|
self.mediainfo = MediaInfo()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
|
|
||||||
def test_scrape_nfo_off(self):
|
def test_scrape_nfo_off(self):
|
||||||
self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.SKIP)
|
self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "nfo", ScrapingPolicy.SKIP)
|
||||||
self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)
|
self.media_chain._scrape_nfo_generic(self.fileitem, self.meta, self.mediainfo, ScrapingTarget.MOVIE)
|
||||||
@@ -147,6 +193,7 @@ class TestMediaScrapingNFO(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMediaScrapingImages(unittest.TestCase):
|
class TestMediaScrapingImages(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
self.media_chain = MediaChain()
|
self.media_chain = MediaChain()
|
||||||
self.original_download = self.media_chain._download_and_save_image
|
self.original_download = self.media_chain._download_and_save_image
|
||||||
self.media_chain.storagechain = MagicMock()
|
self.media_chain.storagechain = MagicMock()
|
||||||
@@ -156,6 +203,7 @@ class TestMediaScrapingImages(unittest.TestCase):
|
|||||||
|
|
||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.media_chain._download_and_save_image = self.original_download
|
self.media_chain._download_and_save_image = self.original_download
|
||||||
|
reset_media_chain_singleton()
|
||||||
|
|
||||||
def test_scrape_images_mapping(self):
|
def test_scrape_images_mapping(self):
|
||||||
fileitem = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local")
|
fileitem = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local")
|
||||||
@@ -191,9 +239,43 @@ class TestMediaScrapingImages(unittest.TestCase):
|
|||||||
self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.SEASON, season_number=1)
|
self.media_chain._scrape_images_generic(fileitem, mediainfo, ScrapingTarget.SEASON, season_number=1)
|
||||||
|
|
||||||
calls = self.media_chain._download_and_save_image.call_args_list
|
calls = self.media_chain._download_and_save_image.call_args_list
|
||||||
self.assertEqual(len(calls), 1)
|
self.assertEqual(len(calls), 2)
|
||||||
self.assertEqual(calls[0].kwargs["url"], "http://season01")
|
self.assertTrue(all(call.kwargs["url"] == "http://season01" for call in calls))
|
||||||
self.assertEqual(calls[0].kwargs["path"], Path("/tv/Show/Season 1/poster.jpg"))
|
self.assertEqual(
|
||||||
|
[call.kwargs["path"] for call in calls],
|
||||||
|
[
|
||||||
|
Path("/tv/Show/season01-poster.jpg"),
|
||||||
|
Path("/tv/Show/Season 1/poster.jpg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_scrape_movie_file_images_when_initialized_directly(self):
|
||||||
|
"""直接初始化刮削电影文件时,应生成同级 poster/backdrop。"""
|
||||||
|
fileitem = schemas.FileItem(path="/movies/Avatar/Avatar.mkv", name="Avatar.mkv", type="file", storage="local")
|
||||||
|
parent_item = schemas.FileItem(path="/movies/Avatar", name="Avatar", type="dir", storage="local")
|
||||||
|
mediainfo = MediaInfo()
|
||||||
|
self.media_chain.metadata_img.return_value = {
|
||||||
|
"poster.jpg": "http://poster",
|
||||||
|
"backdrop.jpg": "http://backdrop",
|
||||||
|
}
|
||||||
|
self.media_chain.scraping_policies.option.return_value = ScrapingOption("movie", "poster", ScrapingPolicy.OVERWRITE)
|
||||||
|
self.media_chain.storagechain.get_file_item.return_value = None
|
||||||
|
|
||||||
|
self.media_chain._scrape_images_generic(
|
||||||
|
fileitem,
|
||||||
|
mediainfo,
|
||||||
|
ScrapingTarget.MOVIE,
|
||||||
|
parent_fileitem=parent_item,
|
||||||
|
)
|
||||||
|
|
||||||
|
paths = [call.kwargs["path"] for call in self.media_chain._download_and_save_image.call_args_list]
|
||||||
|
self.assertEqual(
|
||||||
|
paths,
|
||||||
|
[
|
||||||
|
Path("/movies/Avatar/poster.jpg"),
|
||||||
|
Path("/movies/Avatar/backdrop.jpg"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_scrape_episode_thumb_image_path(self):
|
def test_scrape_episode_thumb_image_path(self):
|
||||||
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
fileitem = schemas.FileItem(path="/tv/Show/Season 1/S01E01.mp4", name="S01E01.mp4", type="file", storage="local")
|
||||||
@@ -295,11 +377,15 @@ class TestMediaScrapingImages(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMediaScrapingTVDirectory(unittest.TestCase):
|
class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
self.media_chain = MediaChain()
|
self.media_chain = MediaChain()
|
||||||
self.media_chain.storagechain = MagicMock()
|
self.media_chain.storagechain = MagicMock()
|
||||||
self.media_chain._scrape_nfo_generic = MagicMock()
|
self.media_chain._scrape_nfo_generic = MagicMock()
|
||||||
self.media_chain._scrape_images_generic = MagicMock()
|
self.media_chain._scrape_images_generic = MagicMock()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
|
|
||||||
@patch("app.chain.media.settings")
|
@patch("app.chain.media.settings")
|
||||||
def test_initialize_tv_directory_specials(self, mock_settings):
|
def test_initialize_tv_directory_specials(self, mock_settings):
|
||||||
# mock specials directory recognition
|
# mock specials directory recognition
|
||||||
@@ -366,9 +452,13 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
|||||||
|
|
||||||
class TestMediaScrapeEvents(unittest.TestCase):
|
class TestMediaScrapeEvents(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
self.media_chain = MediaChain()
|
self.media_chain = MediaChain()
|
||||||
self.media_chain.storagechain = MagicMock()
|
self.media_chain.storagechain = MagicMock()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
reset_media_chain_singleton()
|
||||||
|
|
||||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||||
def test_scrape_metadata_event_file(
|
def test_scrape_metadata_event_file(
|
||||||
self, mock_scrape_metadata
|
self, mock_scrape_metadata
|
||||||
@@ -394,7 +484,7 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
|||||||
mock_scrape_metadata.assert_called_once_with(
|
mock_scrape_metadata.assert_called_once_with(
|
||||||
fileitem=fileitem,
|
fileitem=fileitem,
|
||||||
mediainfo=mediainfo,
|
mediainfo=mediainfo,
|
||||||
init_folder=False,
|
init_folder=True,
|
||||||
parent=parent_item,
|
parent=parent_item,
|
||||||
overwrite=True
|
overwrite=True
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user