mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
fix: improve media image scraping
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import asyncio
|
||||
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.meta import MetaBase
|
||||
from app.modules.douban import DoubanModule
|
||||
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
|
||||
|
||||
|
||||
@@ -96,6 +98,68 @@ class MediaRecognizeModulesTest(TestCase):
|
||||
module._async_search_by_name.assert_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):
|
||||
"""豆瓣候选名称应保留顺序,并去掉繁简转换后的重复项。"""
|
||||
meta = MetaBase("流浪地球")
|
||||
|
||||
@@ -15,11 +15,20 @@ from app.core.metainfo import MetaInfo
|
||||
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):
|
||||
def setUp(self):
|
||||
reset_media_chain_singleton()
|
||||
self.media_chain = MediaChain()
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
|
||||
def tearDown(self):
|
||||
reset_media_chain_singleton()
|
||||
|
||||
def test_movie_file_nfo_path(self):
|
||||
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")
|
||||
@@ -75,6 +84,25 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
||||
self.assertEqual(target_item, fileitem)
|
||||
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):
|
||||
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(
|
||||
@@ -86,6 +114,20 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
||||
self.assertEqual(target_item, fileitem)
|
||||
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):
|
||||
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")
|
||||
@@ -101,6 +143,7 @@ class TestMediaScrapingPaths(unittest.TestCase):
|
||||
|
||||
class TestMediaScrapingNFO(unittest.TestCase):
|
||||
def setUp(self):
|
||||
reset_media_chain_singleton()
|
||||
self.media_chain = MediaChain()
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
self.media_chain.metadata_nfo = MagicMock(return_value="<nfo></nfo>")
|
||||
@@ -111,6 +154,9 @@ class TestMediaScrapingNFO(unittest.TestCase):
|
||||
self.meta = MetaInfo("Avatar (2009)")
|
||||
self.mediainfo = MediaInfo()
|
||||
|
||||
def tearDown(self):
|
||||
reset_media_chain_singleton()
|
||||
|
||||
def test_scrape_nfo_off(self):
|
||||
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)
|
||||
@@ -147,6 +193,7 @@ class TestMediaScrapingNFO(unittest.TestCase):
|
||||
|
||||
class TestMediaScrapingImages(unittest.TestCase):
|
||||
def setUp(self):
|
||||
reset_media_chain_singleton()
|
||||
self.media_chain = MediaChain()
|
||||
self.original_download = self.media_chain._download_and_save_image
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
@@ -156,6 +203,7 @@ class TestMediaScrapingImages(unittest.TestCase):
|
||||
|
||||
def tearDown(self):
|
||||
self.media_chain._download_and_save_image = self.original_download
|
||||
reset_media_chain_singleton()
|
||||
|
||||
def test_scrape_images_mapping(self):
|
||||
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)
|
||||
|
||||
calls = self.media_chain._download_and_save_image.call_args_list
|
||||
self.assertEqual(len(calls), 1)
|
||||
self.assertEqual(calls[0].kwargs["url"], "http://season01")
|
||||
self.assertEqual(calls[0].kwargs["path"], Path("/tv/Show/Season 1/poster.jpg"))
|
||||
self.assertEqual(len(calls), 2)
|
||||
self.assertTrue(all(call.kwargs["url"] == "http://season01" for call in calls))
|
||||
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):
|
||||
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):
|
||||
def setUp(self):
|
||||
reset_media_chain_singleton()
|
||||
self.media_chain = MediaChain()
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
self.media_chain._scrape_nfo_generic = MagicMock()
|
||||
self.media_chain._scrape_images_generic = MagicMock()
|
||||
|
||||
def tearDown(self):
|
||||
reset_media_chain_singleton()
|
||||
|
||||
@patch("app.chain.media.settings")
|
||||
def test_initialize_tv_directory_specials(self, mock_settings):
|
||||
# mock specials directory recognition
|
||||
@@ -366,9 +452,13 @@ class TestMediaScrapingTVDirectory(unittest.TestCase):
|
||||
|
||||
class TestMediaScrapeEvents(unittest.TestCase):
|
||||
def setUp(self):
|
||||
reset_media_chain_singleton()
|
||||
self.media_chain = MediaChain()
|
||||
self.media_chain.storagechain = MagicMock()
|
||||
|
||||
def tearDown(self):
|
||||
reset_media_chain_singleton()
|
||||
|
||||
@patch("app.chain.media.MediaChain.scrape_metadata")
|
||||
def test_scrape_metadata_event_file(
|
||||
self, mock_scrape_metadata
|
||||
@@ -394,7 +484,7 @@ class TestMediaScrapeEvents(unittest.TestCase):
|
||||
mock_scrape_metadata.assert_called_once_with(
|
||||
fileitem=fileitem,
|
||||
mediainfo=mediainfo,
|
||||
init_folder=False,
|
||||
init_folder=True,
|
||||
parent=parent_item,
|
||||
overwrite=True
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user