mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-10 16:04:37 +08:00
feat(subscribe): merge mediaserver library entries into files info (#6131)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Callable, List, Union, Optional, Generator, Any
|
||||
from typing import Callable, Dict, List, Union, Optional, Generator, Any
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.core.config import global_vars
|
||||
@@ -210,6 +210,24 @@ class MediaServerChain(ChainBase):
|
||||
"""
|
||||
return self.run_module("mediaserver_play_url", server=server, item_id=item_id)
|
||||
|
||||
def get_season_episode_ids(self, server: str, item_id: Union[str, int],
|
||||
season: int) -> Dict[int, str]:
|
||||
"""
|
||||
获取指定季的集号到媒体服务器条目 ID 映射
|
||||
|
||||
:param server: 媒体服务器名称
|
||||
:param item_id: 剧集在媒体服务器中的条目 ID
|
||||
:param season: 季号
|
||||
:return: 集号到条目 ID 的映射,无数据时返回空字典
|
||||
"""
|
||||
result = self.run_module(
|
||||
"mediaserver_season_episode_ids",
|
||||
server=server,
|
||||
item_id=item_id,
|
||||
season=season,
|
||||
)
|
||||
return result or {}
|
||||
|
||||
def get_image_cookies(
|
||||
self, server: Optional[str], image_url: str
|
||||
) -> Optional[str | dict]:
|
||||
|
||||
@@ -11,6 +11,7 @@ from app import schemas
|
||||
from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
@@ -34,6 +35,7 @@ from app.helper.interaction import (
|
||||
supports_markdown,
|
||||
update_or_post_message,
|
||||
)
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.helper.server import MoviePilotServerHelper
|
||||
from app.helper.torrent import TorrentHelper
|
||||
from app.log import logger
|
||||
@@ -3642,6 +3644,84 @@ class SubscribeChain(ChainBase):
|
||||
else:
|
||||
episodes[0].library.append(file_info)
|
||||
|
||||
# 合并所有媒体服务器已存在条目(逐台查询,不只取第一个命中)
|
||||
mediaserver_chain = MediaServerChain()
|
||||
server_names = list(MediaServerHelper().get_services().keys())
|
||||
|
||||
def _has_server_entry(library_list: List[schemas.SubscribeLibraryFileInfo],
|
||||
server_name: Optional[str],
|
||||
server_type: Optional[str]) -> bool:
|
||||
for info in library_list or []:
|
||||
if info.server and server_name and info.server == server_name:
|
||||
return True
|
||||
if info.server_type and server_type and info.server_type == server_type \
|
||||
and info.server == server_name \
|
||||
and (not info.file_path or str(info.file_path).startswith(("http://", "https://"))):
|
||||
return True
|
||||
return False
|
||||
|
||||
for server_name in server_names:
|
||||
exists_media = self.media_exists(mediainfo=mediainfo, server=server_name)
|
||||
# 仅合并真实媒体服务器结果,跳过本地 FileManager 兜底(已由 media_files 覆盖)
|
||||
if not exists_media or not (exists_media.server or exists_media.server_type):
|
||||
continue
|
||||
|
||||
resolved_server = exists_media.server or server_name
|
||||
server_storage = exists_media.server_type or resolved_server
|
||||
server_itemid = str(exists_media.itemid) if exists_media.itemid is not None else None
|
||||
series_detail_url = None
|
||||
if resolved_server and exists_media.itemid is not None:
|
||||
series_detail_url = mediaserver_chain.get_play_url(
|
||||
server=resolved_server,
|
||||
item_id=exists_media.itemid,
|
||||
)
|
||||
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
season_number = subscribe.season if subscribe.season is not None else 1
|
||||
exist_episodes = (exists_media.seasons or {}).get(season_number) or []
|
||||
episode_item_ids: Dict[int, str] = {}
|
||||
if resolved_server and exists_media.itemid is not None:
|
||||
episode_item_ids = mediaserver_chain.get_season_episode_ids(
|
||||
server=resolved_server,
|
||||
item_id=exists_media.itemid,
|
||||
season=season_number,
|
||||
)
|
||||
for episode_number in exist_episodes:
|
||||
episode_info = episodes.get(episode_number)
|
||||
if not episode_info:
|
||||
continue
|
||||
if _has_server_entry(episode_info.library, resolved_server, exists_media.server_type):
|
||||
continue
|
||||
episode_itemid = episode_item_ids.get(episode_number) or server_itemid
|
||||
detail_url = series_detail_url
|
||||
if resolved_server and episode_item_ids.get(episode_number):
|
||||
detail_url = mediaserver_chain.get_play_url(
|
||||
server=resolved_server,
|
||||
item_id=episode_itemid,
|
||||
) or series_detail_url
|
||||
episode_info.library.append(
|
||||
schemas.SubscribeLibraryFileInfo(
|
||||
storage=server_storage,
|
||||
file_path=detail_url,
|
||||
server=resolved_server,
|
||||
server_type=exists_media.server_type,
|
||||
itemid=str(episode_itemid) if episode_itemid is not None else None,
|
||||
)
|
||||
)
|
||||
else:
|
||||
episode_info = episodes.get(0)
|
||||
if episode_info and not _has_server_entry(
|
||||
episode_info.library, resolved_server, exists_media.server_type):
|
||||
episode_info.library.append(
|
||||
schemas.SubscribeLibraryFileInfo(
|
||||
storage=server_storage,
|
||||
file_path=series_detail_url,
|
||||
server=resolved_server,
|
||||
server_type=exists_media.server_type,
|
||||
itemid=server_itemid,
|
||||
)
|
||||
)
|
||||
|
||||
# 更新订阅信息
|
||||
subscribe_info.subscribe = Subscribe(**subscribe.to_dict())
|
||||
subscribe_info.episodes = episodes
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Generator, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
@@ -300,6 +300,21 @@ class EmbyModule(_ModuleBase, _MediaServerBase[Emby]):
|
||||
return None
|
||||
return server_obj.get_play_url(item_id)
|
||||
|
||||
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
|
||||
season: int) -> Optional[Dict[int, str]]:
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
|
||||
:param server: Emby 媒体服务器名称
|
||||
:param item_id: 剧集在 Emby 中的条目 ID
|
||||
:param season: 季号
|
||||
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
|
||||
"""
|
||||
server_obj: Emby = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
|
||||
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
|
||||
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
|
||||
@@ -474,6 +474,37 @@ class Emby:
|
||||
return None, None
|
||||
return None, {}
|
||||
|
||||
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
|
||||
"""
|
||||
获取指定季的集号到媒体服务器条目 ID 映射
|
||||
:param item_id: 剧集在 Emby 中的 ID
|
||||
:param season: 季号
|
||||
:return: {集号: episode_item_id}
|
||||
"""
|
||||
if not item_id or not self._host or not self._apikey:
|
||||
return {}
|
||||
try:
|
||||
url = f"{self._host}emby/Shows/{item_id}/Episodes"
|
||||
params = {
|
||||
"Season": season,
|
||||
"IsMissing": "false",
|
||||
"api_key": self._apikey
|
||||
}
|
||||
res_json = RequestUtils().get_res(url, params)
|
||||
if not res_json:
|
||||
return {}
|
||||
episode_ids: Dict[int, str] = {}
|
||||
for res_item in res_json.json().get("Items") or []:
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
episode_id = res_item.get("Id")
|
||||
if episode_index is None or not episode_id:
|
||||
continue
|
||||
episode_ids[int(episode_index)] = str(episode_id)
|
||||
return episode_ids
|
||||
except Exception as e:
|
||||
logger.error(f"获取 Emby 季集条目 ID 出错:{str(e)}")
|
||||
return {}
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
根据ItemId从Emby查询TMDB的图片地址
|
||||
|
||||
@@ -606,8 +606,12 @@ class FileManagerModule(_ModuleBase):
|
||||
"""
|
||||
判断媒体文件是否存在于文件系统(网盘或本地文件),只支持标准媒体库结构
|
||||
:param mediainfo: 识别的媒体信息
|
||||
:param server: 指定媒体服务器名称时跳过本地文件系统检查
|
||||
:return: 如不存在返回None,存在时返回信息,包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
|
||||
"""
|
||||
if kwargs.get("server"):
|
||||
return None
|
||||
|
||||
if not settings.LOCAL_EXISTS_SEARCH:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Generator, List, Optional, Tuple, Union
|
||||
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
|
||||
|
||||
from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
@@ -299,6 +299,21 @@ class JellyfinModule(_ModuleBase, _MediaServerBase[Jellyfin]):
|
||||
return None
|
||||
return server_obj.get_play_url(item_id)
|
||||
|
||||
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
|
||||
season: int) -> Optional[Dict[int, str]]:
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
|
||||
:param server: Jellyfin 媒体服务器名称
|
||||
:param item_id: 剧集在 Jellyfin 中的条目 ID
|
||||
:param season: 季号
|
||||
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
|
||||
"""
|
||||
server_obj: Jellyfin = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
|
||||
def mediaserver_latest(self, server: Optional[str] = None, count: Optional[int] = 20,
|
||||
username: Optional[str] = None) -> List[schemas.MediaServerPlayItem]:
|
||||
"""
|
||||
|
||||
@@ -523,6 +523,38 @@ class Jellyfin:
|
||||
return None, None
|
||||
return None, {}
|
||||
|
||||
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
|
||||
"""
|
||||
获取指定季的集号到媒体服务器条目 ID 映射
|
||||
:param item_id: 剧集在 Jellyfin 中的 ID
|
||||
:param season: 季号
|
||||
:return: {集号: episode_item_id}
|
||||
"""
|
||||
if not item_id or not self._host or not self._apikey or not self.user:
|
||||
return {}
|
||||
try:
|
||||
url = f"{self._host}Shows/{item_id}/Episodes"
|
||||
params = {
|
||||
"season": season,
|
||||
"userId": self.user,
|
||||
"isMissing": "false",
|
||||
"api_key": self._apikey
|
||||
}
|
||||
res_json = RequestUtils().get_res(url, params)
|
||||
if not res_json:
|
||||
return {}
|
||||
episode_ids: Dict[int, str] = {}
|
||||
for res_item in res_json.json().get("Items") or []:
|
||||
episode_index = res_item.get("IndexNumber")
|
||||
episode_id = res_item.get("Id")
|
||||
if episode_index is None or not episode_id:
|
||||
continue
|
||||
episode_ids[int(episode_index)] = str(episode_id)
|
||||
return episode_ids
|
||||
except Exception as e:
|
||||
logger.error(f"获取 Jellyfin 季集条目 ID 出错:{str(e)}")
|
||||
return {}
|
||||
|
||||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||||
"""
|
||||
根据ItemId从Jellyfin查询TMDB图片地址
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator
|
||||
from typing import Optional, Tuple, Union, Any, List, Generator, Dict
|
||||
|
||||
from app import schemas
|
||||
from app.core.context import MediaInfo
|
||||
@@ -349,3 +349,18 @@ class PlexModule(_ModuleBase, _MediaServerBase[Plex]):
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_play_url(item_id)
|
||||
|
||||
def mediaserver_season_episode_ids(self, server: str, item_id: Union[str, int],
|
||||
season: int) -> Optional[Dict[int, str]]:
|
||||
"""
|
||||
获取指定季的集号到条目 ID 映射
|
||||
|
||||
:param server: Plex 媒体服务器名称
|
||||
:param item_id: 剧集在 Plex 中的条目 ID / key
|
||||
:param season: 季号
|
||||
:return: 集号到条目 ID 的映射,服务器不可用或无数据时返回 None
|
||||
"""
|
||||
server_obj: Plex = self.get_instance(server)
|
||||
if not server_obj:
|
||||
return None
|
||||
return server_obj.get_season_episode_ids(str(item_id), season)
|
||||
|
||||
@@ -293,6 +293,31 @@ class Plex:
|
||||
season_episodes[episode.seasonNumber].append(episode.index)
|
||||
return videos.key, season_episodes
|
||||
|
||||
def get_season_episode_ids(self, item_id: str, season: int) -> Dict[int, str]:
|
||||
"""
|
||||
获取指定季的集号到媒体服务器条目 ID 映射
|
||||
:param item_id: 剧集在 Plex 中的 ID / key
|
||||
:param season: 季号
|
||||
:return: {集号: episode_item_key}
|
||||
"""
|
||||
if not self._plex or not item_id:
|
||||
return {}
|
||||
try:
|
||||
videos = self.__fetch_item(item_id)
|
||||
if not videos:
|
||||
return {}
|
||||
episode_ids: Dict[int, str] = {}
|
||||
for episode in videos.episodes():
|
||||
if episode.seasonNumber != int(season):
|
||||
continue
|
||||
if episode.index is None or not episode.key:
|
||||
continue
|
||||
episode_ids[int(episode.index)] = str(episode.key)
|
||||
return episode_ids
|
||||
except Exception as e:
|
||||
logger.error(f"获取 Plex 季集条目 ID 出错:{str(e)}")
|
||||
return {}
|
||||
|
||||
def __search_show(self,
|
||||
title: Optional[str] = None,
|
||||
original_title: Optional[str] = None,
|
||||
|
||||
@@ -217,6 +217,12 @@ class SubscribeLibraryFileInfo(BaseModel):
|
||||
storage: Optional[str] = "local"
|
||||
# 文件路径
|
||||
file_path: Optional[str] = None
|
||||
# 媒体服务器名称
|
||||
server: Optional[str] = None
|
||||
# 媒体服务器类型:emby、jellyfin、plex 等
|
||||
server_type: Optional[str] = None
|
||||
# 媒体服务器条目 ID
|
||||
itemid: Optional[str] = None
|
||||
|
||||
|
||||
class SubscribeEpisodeInfo(BaseModel):
|
||||
|
||||
143
tests/test_subscribe_files_info.py
Normal file
143
tests/test_subscribe_files_info.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""订阅文件统计相关测试"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.modules.filemanager import FileManagerModule
|
||||
from app.schemas.mediaserver import ExistMediaInfo
|
||||
from app.schemas.types import MediaType
|
||||
|
||||
|
||||
def _build_subscribe(**overrides):
|
||||
data = {
|
||||
"id": 1,
|
||||
"name": "Test Show",
|
||||
"year": "2026",
|
||||
"type": MediaType.TV.value,
|
||||
"season": 1,
|
||||
"tmdbid": None,
|
||||
"doubanid": None,
|
||||
"imdbid": None,
|
||||
"tvdbid": None,
|
||||
"bangumiid": None,
|
||||
"episode_group": None,
|
||||
"start_episode": 1,
|
||||
"total_episode": 2,
|
||||
}
|
||||
data.update(overrides)
|
||||
subscribe = SimpleNamespace(**data)
|
||||
subscribe.to_dict = lambda: dict(data)
|
||||
return subscribe
|
||||
|
||||
|
||||
def _build_mediainfo():
|
||||
return SimpleNamespace(
|
||||
type=MediaType.TV,
|
||||
title="Test Show",
|
||||
title_year="Test Show (2026)",
|
||||
year="2026",
|
||||
tmdb_id=None,
|
||||
douban_id=None,
|
||||
)
|
||||
|
||||
|
||||
def test_filemanager_media_exists_skips_local_when_server_specified():
|
||||
module = FileManagerModule()
|
||||
mediainfo = _build_mediainfo()
|
||||
|
||||
with patch.object(module, "media_files", return_value=[SimpleNamespace(path="/media/test.mkv")]) as media_files:
|
||||
result = module.media_exists(mediainfo, server="Emby1")
|
||||
|
||||
assert result is None
|
||||
media_files.assert_not_called()
|
||||
|
||||
|
||||
def test_subscribe_files_info_merges_multiple_mediaservers():
|
||||
subscribe = _build_subscribe(season=1, total_episode=2)
|
||||
mediainfo = _build_mediainfo()
|
||||
|
||||
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
|
||||
if server == "Emby1":
|
||||
return ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons={1: [1]},
|
||||
server_type="emby",
|
||||
server="Emby1",
|
||||
itemid="emby-series",
|
||||
)
|
||||
if server == "Jellyfin1":
|
||||
return ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons={1: [1]},
|
||||
server_type="jellyfin",
|
||||
server="Jellyfin1",
|
||||
itemid="jf-series",
|
||||
)
|
||||
return None
|
||||
|
||||
helper = MagicMock()
|
||||
helper.get_services.return_value = {"Emby1": object(), "Jellyfin1": object()}
|
||||
|
||||
mediaserver_chain = MagicMock()
|
||||
mediaserver_chain.get_play_url.side_effect = lambda server, item_id: f"https://{server}/item/{item_id}"
|
||||
mediaserver_chain.get_season_episode_ids.side_effect = lambda server, item_id, season: {1: f"{item_id}-ep1"}
|
||||
|
||||
chain = SubscribeChain()
|
||||
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
|
||||
patch.object(chain, "recognize_media", return_value=mediainfo), \
|
||||
patch.object(chain, "media_files", return_value=None), \
|
||||
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
|
||||
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
|
||||
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
|
||||
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
|
||||
download_oper.return_value.get_by_mediaid.return_value = []
|
||||
result = chain.subscribe_files_info(subscribe)
|
||||
|
||||
library = result.episodes[1].library
|
||||
servers = {item.server for item in library}
|
||||
assert servers == {"Emby1", "Jellyfin1"}
|
||||
assert all(str(item.file_path).startswith("https://") for item in library)
|
||||
|
||||
|
||||
def test_subscribe_files_info_uses_season_zero_for_tv():
|
||||
subscribe = _build_subscribe(season=0, total_episode=1, start_episode=1)
|
||||
mediainfo = _build_mediainfo()
|
||||
captured_seasons = []
|
||||
|
||||
def _media_exists_side_effect(*, mediainfo, server=None, **kwargs):
|
||||
if server == "Emby1":
|
||||
return ExistMediaInfo(
|
||||
type=MediaType.TV,
|
||||
seasons={0: [1]},
|
||||
server_type="emby",
|
||||
server="Emby1",
|
||||
itemid="emby-special",
|
||||
)
|
||||
return None
|
||||
|
||||
def _get_season_episode_ids(server, item_id, season):
|
||||
captured_seasons.append(season)
|
||||
return {1: f"{item_id}-ep1"}
|
||||
|
||||
helper = MagicMock()
|
||||
helper.get_services.return_value = {"Emby1": object()}
|
||||
|
||||
mediaserver_chain = MagicMock()
|
||||
mediaserver_chain.get_play_url.return_value = "https://emby/item/1"
|
||||
mediaserver_chain.get_season_episode_ids.side_effect = _get_season_episode_ids
|
||||
|
||||
chain = SubscribeChain()
|
||||
with patch("app.chain.subscribe.DownloadHistoryOper") as download_oper, \
|
||||
patch.object(chain, "recognize_media", return_value=mediainfo), \
|
||||
patch.object(chain, "media_files", return_value=None), \
|
||||
patch.object(chain, "media_exists", side_effect=_media_exists_side_effect), \
|
||||
patch("app.chain.subscribe.MediaServerHelper", return_value=helper), \
|
||||
patch("app.chain.subscribe.MediaServerChain", return_value=mediaserver_chain), \
|
||||
patch("app.chain.subscribe.Subscribe", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)):
|
||||
download_oper.return_value.get_by_mediaid.return_value = []
|
||||
result = chain.subscribe_files_info(subscribe)
|
||||
|
||||
assert captured_seasons == [0]
|
||||
assert len(result.episodes[1].library) == 1
|
||||
assert result.episodes[1].library[0].server == "Emby1"
|
||||
Reference in New Issue
Block a user