fix: improve media image scraping

This commit is contained in:
jxxghp
2026-05-19 10:48:36 +08:00
parent 9e9c398177
commit 29dab5a312
6 changed files with 375 additions and 45 deletions
+65 -1
View File
@@ -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("流浪地球")
+94 -4
View File
@@ -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
)