feat(subscribe): merge mediaserver library entries into files info (#6131)

This commit is contained in:
kuke2733
2026-07-16 17:36:56 +08:00
committed by GitHub
parent a0ee99aacc
commit 229824a417
11 changed files with 388 additions and 4 deletions

View File

@@ -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]:

View File

@@ -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

View File

@@ -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]:
"""

View File

@@ -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的图片地址

View File

@@ -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

View File

@@ -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]:
"""

View File

@@ -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图片地址

View File

@@ -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)

View File

@@ -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,

View File

@@ -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):

View 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"