Files
MoviePilot/app/modules/emby/emby.py

1436 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Emby 媒体服务器客户端,负责内容查询、图片、刷新及 Webhook 转换。
"""
import json
import re
import traceback
from pathlib import Path
from typing import Any, Dict, Generator, List, Optional, Tuple, Union
from requests import Response
from app.adapters.network.http import RequestUtils
from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item
from app.foundation.url import UrlUtils
from app.modules._base.mediaserver import emby_episode_numbers, emby_query_params
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.dashboard import Statistic as _SchemaStatistic
from app.schemas.mediaserver import MediaServerField, MediaServerItem, MediaServerQueryOptions
from app.schemas.mediaserver import MediaServerItem as _SchemaMediaServerItem
from app.schemas.mediaserver import MediaServerLibrary as _SchemaMediaServerLibrary
from app.schemas.mediaserver import MediaServerPlayItem as _SchemaMediaServerPlayItem
from app.schemas.mediaserver import RefreshMediaItem as _SchemaRefreshMediaItem
from app.schemas.mediaserver import WebhookEventInfo as _SchemaWebhookEventInfo
from app.schemas.types import MediaSource, MediaType
def _optional_text(value: object) -> Optional[str]:
"""
读取服务端可空文本字段,拒绝非文本值而不隐式转换标识或路径。
"""
if value is None or isinstance(value, str):
return value
raise TypeError("Emby 文本字段格式错误")
def _user_id(value: object) -> Optional[Union[str, int]]:
"""
读取兼容字符串或整数的服务端用户标识,保留空值。
"""
if value is None or isinstance(value, (str, int)):
return value
raise TypeError("Emby 用户标识格式错误")
class Emby:
"""
Emby媒体服务器客户端。
"""
_host: Optional[str] = None
_playhost: Optional[str] = None
_apikey: Optional[str] = None
_sync_libraries: List[str] = []
user: Optional[Union[str, int]] = None
_username: Optional[str] = None
def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None,
username: Optional[str] = None, sync_libraries: Optional[list[str]] = None, **kwargs: object) -> None:
"""
保存服务配置,并查询用户、服务器身份及必要的媒体库路径。
:param host: 服务根地址,缺少时不初始化连接
:param apikey: 服务 API 密钥,缺少时不初始化连接
:param play_host: 外网访问地址,用于播放跳转和远程图片链接
:param username: 优先使用的查询用户名;省略时先匹配 MoviePilot 管理员用户名
:param sync_libraries: 允许同步的媒体库标识列表;空列表不限制,具体筛选由各查询接口处理
:param kwargs: 兼容通用服务配置的额外参数,当前不读取
"""
if not host or not apikey:
logger.error("Emby服务器配置不完整!")
return
self._host = host
if self._host:
self._host = UrlUtils.standardize_base_url(self._host)
self._playhost = play_host
if self._playhost:
self._playhost = UrlUtils.standardize_base_url(self._playhost)
self._apikey = apikey
self._username = username
self.user = self.get_user(username or get_runtime_setting('SUPERUSER'))
self.folders = self.get_emby_folders()
self.serverid = self.get_server_id()
self._sync_libraries = sync_libraries or []
def is_inactive(self) -> bool:
"""
判断完整服务配置是否尚未找到可用的查询用户。
:return: 地址及密钥均已配置但 user 为空时返回 True;配置缺失时返回 False
"""
if not self._host or not self._apikey:
return False
return True if not self.user else False
def reconnect(self) -> None:
"""
重新查询用户并更新媒体库路径缓存。
"""
self.user = self.get_user()
self.folders = self.get_emby_folders()
def get_emby_folders(self) -> List[dict[str, Any]]:
"""
读取服务端可选媒体库及其目录信息。
:return: 服务端返回的目录记录列表;配置不完整或查询失败时返回空列表
"""
if not self._host or not self._apikey:
return []
url = f"{self._host}emby/Library/SelectableMediaFolders"
params = {
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
if res:
folders: object = res.json()
if not isinstance(folders, list) or not all(isinstance(folder, dict) for folder in folders):
raise TypeError("Emby 媒体库目录响应格式错误")
return folders
else:
logger.error("Library/SelectableMediaFolders 未获取到返回数据")
return []
except Exception as e:
logger.error("连接Library/SelectableMediaFolders 出错:" + str(e))
return []
def get_emby_virtual_folders(self) -> List[dict[str, Any]]:
"""
读取虚拟媒体库及其全部目录,优先使用共享路径。
:return: 包含 Id、Name 和路径列表 Path 的字典列表;查询失败时返回空列表
"""
if not self._host or not self._apikey:
return []
url = f"{self._host}emby/Library/VirtualFolders/Query"
params = {
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
if res:
library_items = res.json().get("Items")
librarys = []
for library_item in library_items:
library_id = library_item.get('ItemId')
library_name = library_item.get('Name')
pathInfos = library_item.get('LibraryOptions', {}).get('PathInfos')
library_paths = []
for path in pathInfos:
if path.get('NetworkPath'):
library_paths.append(path.get('NetworkPath'))
else:
library_paths.append(path.get('Path'))
if library_name and library_paths:
librarys.append({
'Id': library_id,
'Name': library_name,
'Path': library_paths
})
return librarys
else:
logger.error("Library/VirtualFolders/Query 未获取到返回数据")
return []
except Exception as e:
logger.error("连接Library/VirtualFolders/Query 出错:" + str(e))
return []
def __get_emby_librarys(self, username: Optional[str] = None) -> Optional[List[dict[str, Any]]]:
"""
获取Emby媒体库列表。
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}emby/Users/{user}/Views"
params = {"api_key": self._apikey}
try:
res = RequestUtils().get_res(url, params)
if res:
items = res.json().get("Items")
return items if isinstance(items, list) else None
else:
logger.error("User/Views 未获取到返回数据")
return None
except Exception as e:
logger.error("连接User/Views 出错:" + str(e))
return None
def get_librarys(
self,
username: Optional[str] = None,
hidden: Optional[bool] = False,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Optional[List[_SchemaMediaServerLibrary]]:
"""
读取用户可见媒体库,并转换为统一媒体库模型。
:param username: 用于限定可见内容的用户名,省略时使用当前实例的查询用户
:param hidden: 是否隐藏未选入同步范围的媒体库;默认返回全部可见库
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 媒体库列表;配置不完整、用户不可用或查询失败时返回 None
"""
if not self._host or not self._apikey:
return None
source_libraries = self.__get_emby_librarys(username)
if source_libraries is None:
return None
options = query or MediaServerQueryOptions()
libraries = []
for library in source_libraries:
if (
hidden
and self._sync_libraries
and "all" not in self._sync_libraries
and library.get("Id") not in self._sync_libraries
):
continue
if library.get("CollectionType") == "movies":
library_type = MediaType.MOVIE.value
elif library.get("CollectionType") in ("music", "musicvideos", "audio"):
library_type = MediaType.MUSIC.value
elif library.get("CollectionType") == "tvshows":
library_type = MediaType.TV.value
else:
library_type = MediaType.UNKNOWN.value
image = (
self.__get_local_image_by_id(
library.get("Id"),
image_tag=(library.get("ImageTags") or {}).get("Primary"),
)
if options.includes(MediaServerField.IMAGES)
else None
)
server_id = library.get("ServerId") or self.serverid
server_query = f"serverId={server_id}&" if server_id else ""
libraries.append(
_SchemaMediaServerLibrary(
server="emby",
id=library.get("Id"),
item_id=library.get("Id"),
server_id=server_id,
name=library.get("Name"),
path=library.get("Path") if options.includes(MediaServerField.PATH) else None,
type=library_type,
item_count=self.get_items_count(
library.get("Id"),
"MusicAlbum" if library_type == MediaType.MUSIC.value else "Movie,Series",
)
if options.includes(MediaServerField.COUNTS)
else None,
image=image,
link=(
f"{self._playhost or self._host}web/index.html"
f"#!/videos?{server_query}parentId={library.get('Id')}"
)
if options.includes(MediaServerField.LINK)
else None,
server_type="emby",
)
)
return libraries
def get_user(self, user_name: Optional[str] = None) -> Optional[Union[str, int]]:
"""
获取用于查询用户范围数据的用户ID。
优先匹配指定用户名,其次匹配媒体服务器配置用户名,最后回退管理员。
:param user_name: 优先匹配的用户名
:return: 匹配到的用户ID,未找到可用用户时返回None
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}Users"
params = {
"api_key": self._apikey
}
try:
res = RequestUtils().get_res(url, params)
if res:
users = res.json()
candidate_usernames = []
for candidate_username in (user_name, self._username):
if candidate_username and candidate_username not in candidate_usernames:
candidate_usernames.append(candidate_username)
for candidate_username in candidate_usernames:
for emby_user in users:
if emby_user.get("Name") == candidate_username:
return _user_id(emby_user.get("Id"))
for emby_user in users:
if emby_user.get("Policy", {}).get("IsAdministrator"):
return _user_id(emby_user.get("Id"))
else:
logger.error("Users 未获取到返回数据")
except Exception as e:
logger.error("连接Users出错:" + str(e))
return None
def authenticate(self, username: str, password: str) -> Optional[str]:
"""
向媒体服务器验证指定用户的密码。
:param username: 待认证的用户名
:param password: 待认证的密码
:return: 成功时返回访问令牌;配置不完整或认证失败时返回 None
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}emby/Users/AuthenticateByName"
try:
res = RequestUtils(headers={
'X-Emby-Authorization': f'MediaBrowser Client="MoviePilot", '
f'Device="requests", '
f'DeviceId="1", '
f'Version="1.0.0", '
f'Token="{self._apikey}"',
'Content-Type': 'application/json',
"Accept": "application/json"
}).post_res(
url=url,
data=json.dumps({
"Username": username,
"Pw": password
})
)
if res:
auth_token = res.json().get("AccessToken")
if auth_token:
logger.info(f"用户 {username} Emby认证成功")
return _optional_text(auth_token)
else:
logger.error("Users/AuthenticateByName 未获取到返回数据")
except Exception as e:
logger.error("连接Users/AuthenticateByName出错:" + str(e))
return None
def get_server_id(self) -> Optional[str]:
"""
查询媒体服务器的稳定标识。
:return: 服务器标识;配置不完整或查询失败时返回 None
"""
if not self._host or not self._apikey:
return None
params = {
'api_key': self._apikey
}
for path in ("System/Info", "emby/System/Info"):
url = f"{self._host}{path}"
try:
res = RequestUtils().get_res(url, params)
if res:
result = res.json() or {}
server_id = result.get("Id") or result.get("ServerId")
if server_id:
return _optional_text(server_id)
else:
logger.error(f"{path} 未获取到返回数据")
except Exception as e:
logger.error(f"连接{path}出错:" + str(e))
return None
def get_user_count(self) -> Optional[int]:
"""
查询媒体服务器的用户数量。
:return: 服务端用户数量;配置不完整或请求失败时返回 0
"""
if not self._host or not self._apikey:
return 0
url = f"{self._host}emby/Users/Query"
params = {
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
if res:
count: object = res.json().get("TotalRecordCount")
if count is None or isinstance(count, int):
return count
raise TypeError("Emby 用户数量格式错误")
else:
logger.error("Users/Query 未获取到返回数据")
return 0
except Exception as e:
logger.error("连接Users/Query出错:" + str(e))
return 0
def get_medias_count(self) -> _SchemaStatistic:
"""
统计电影、剧集、分集和音乐数量。
:return: 统一统计对象;查询失败时返回空统计,音乐优先采用专辑数,再回退到歌曲数
"""
if not self._host or not self._apikey:
return _SchemaStatistic()
url = f"{self._host}emby/Items/Counts"
params = {
'api_key': self._apikey
}
try:
res = RequestUtils().get_res(url, params)
if res:
result = res.json()
return _SchemaStatistic(
movie_count=result.get("MovieCount") or 0,
tv_count=result.get("SeriesCount") or 0,
episode_count=result.get("EpisodeCount") or 0,
music_count=result.get("MusicAlbumCount") or result.get("AlbumCount")
or result.get("SongCount") or 0,
)
else:
logger.error("Items/Counts 未获取到返回数据")
return _SchemaStatistic()
except Exception as e:
logger.error("连接Items/Counts出错:" + str(e))
return _SchemaStatistic()
def __get_emby_series_id_by_name(
self, name: Optional[str], year: Optional[Union[str, int]], *, query: Optional[MediaServerQueryOptions] = None
) -> Optional[str]:
"""
根据名称查询Emby中剧集的SeriesId。
:param name: 标题
:param year: 年份
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: None 表示连不通,""表示未找到,找到返回ID
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}emby/Items"
params = {
"IncludeItemTypes": "Series",
"Fields": "ProductionYear",
"StartIndex": 0,
"Recursive": "true",
"SearchTerm": name,
"Limit": 10,
"IncludeSearchTypes": "false",
"api_key": self._apikey,
}
try:
params.update(emby_query_params(query))
res = RequestUtils().get_res(url, params)
if res:
res_items = res.json().get("Items")
if res_items:
for res_item in res_items:
if res_item.get("Name") == name and (
not year or str(res_item.get("ProductionYear")) == str(year)
):
return _optional_text(res_item.get("Id"))
except Exception as e:
logger.error("连接Items出错:" + str(e))
return None
return ""
def get_movies(
self,
title: str,
year: Optional[Union[str, int]] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Optional[List[_SchemaMediaServerItem]]:
"""
搜索电影并核对标题、年份和来源身份。
只检查服务端搜索返回的前 10 个候选。
:param title: 必须精确匹配的电影标题
:param year: 可选年份,空值表示不限年份
:param media_source: 可选媒体来源,用于排除来源身份冲突
:param media_id: 来源内的媒体标识,与 media_source 配合使用
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 匹配的统一电影条目;候选均被过滤时可能为空列表,服务端无候选或查询失败时返回 None
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}emby/Items"
params = {
"IncludeItemTypes": "Movie",
"Fields": "ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,UserDataLastPlayedDate,ParentId",
"StartIndex": 0,
"Recursive": "true",
"SearchTerm": title,
"Limit": 10,
"IncludeSearchTypes": "false",
"api_key": self._apikey,
}
try:
params.update(
emby_query_params(query.with_fields(MediaServerField.IDENTITY) if query is not None else None)
)
res = RequestUtils().get_res(url, params)
if res:
res_items = res.json().get("Items")
if res_items:
ret_movies = []
for item in res_items:
if not item:
continue
mediaserver_item = self.__format_item_info(item)
if mediaserver_item:
if (
MediaServerIdentityHelper.is_compatible(mediaserver_item, media_source, media_id)
and mediaserver_item.title == title
and (not year or str(mediaserver_item.year) == str(year))
):
ret_movies.append(mediaserver_item)
return ret_movies
except Exception as e:
logger.error("连接Items出错:" + str(e))
return None
return None
def get_music(
self,
title: Optional[str] = None,
artist: Optional[str] = None,
album: Optional[str] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> List[_SchemaMediaServerItem]:
"""
按歌曲、艺术家和专辑关键词搜索音乐候选。
此方法负责候选搜索与统一模型转换,不执行歌曲或专辑名称的精确匹配。
:param title: 可选歌曲标题
:param artist: 可选艺术家名称
:param album: 可选专辑名称
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 最多 20 个专辑或歌曲候选;查询词为空、配置不完整或请求失败时返回空列表
"""
if not self._host or not self._apikey or not self.user:
return []
search_term = " ".join(filter(None, [title, artist, album])).strip()
if not search_term:
return []
url = f"{self._host}emby/Users/{self.user}/Items"
params = {
"IncludeItemTypes": "MusicAlbum,Audio",
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,ParentId",
"SearchTerm": search_term,
"Recursive": "true",
"Limit": 20,
"api_key": self._apikey,
}
try:
params.update(emby_query_params(query))
res = RequestUtils().get_res(url, params)
items = res.json().get("Items", []) if res and res.status_code == 200 else []
return [item for item in (self.__format_item_info(item) for item in items) if item]
except Exception as e:
logger.debug(f"查询Emby音乐出错:{e}")
return []
def get_tv_episodes(
self,
item_id: Optional[str] = None,
title: Optional[str] = None,
year: Optional[Union[str, int]] = None,
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
season: Optional[int] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Tuple[Optional[str], Optional[Dict[int, List[int]]]]:
"""
查询电视剧身份及各季已经存在的集号。
已有缓存标识失效时按标题及年份重新查找;仅统计服务端实际存在且具有季号、集号的条目。
合并文件按 IndexNumber 至 IndexNumberEnd 的闭区间计入全部集数。
:param item_id: Emby 条目标识
:param title: 缺少条目标识或缓存标识失效时用于回退搜索的剧名
:param year: 回退搜索时限定的年份,空值表示不限
:param media_source: 可选媒体来源,用于排除身份冲突
:param media_id: 对应来源的媒体标识
:param season: 目标季号;None 表示查询全部季
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 电视剧条目标识与“季号到集号列表”映射;配置缺失或异常返回两项 None,
未找到、身份不兼容或响应无可用数据时返回 None 和空映射
"""
if not self._host or not self._apikey:
return None, None
cached_item_id = item_id
if not item_id:
item_id = self.__get_emby_series_id_by_name(title, year, **MediaServerQueryOptions.as_kwargs(query))
if item_id is None:
return None, None
if not item_id:
return None, {}
# 校验媒体服务器返回的主身份是否与目标冲突
item_info = self.get_iteminfo(
item_id,
**({"query": query.with_fields(MediaServerField.IDENTITY)} if query is not None else {}),
)
if not item_info and cached_item_id and title:
# 媒体删除后重新入库会导致缓存ID失效,回退到标题搜索避免误判整部剧缺失。
logger.warning(f"Emby缓存的电视剧媒体ID {cached_item_id} 已失效,尝试按标题重新搜索:{title}")
item_id = self.__get_emby_series_id_by_name(title, year, **MediaServerQueryOptions.as_kwargs(query))
if item_id is None:
return None, None
if not item_id:
return None, {}
item_info = self.get_iteminfo(
item_id,
**({"query": query.with_fields(MediaServerField.IDENTITY)} if query is not None else {}),
)
if not item_info:
return None, {}
if not MediaServerIdentityHelper.is_compatible(item_info, media_source, media_id):
return None, {}
if season is None:
season = None
try:
url = f"{self._host}emby/Shows/{item_id}/Episodes"
params = {"Season": season, "IsMissing": "false", "api_key": self._apikey}
params.update(
emby_query_params(
MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None
)
)
res_json = RequestUtils().get_res(url, params)
if res_json:
tv_item = res_json.json()
res_items = tv_item.get("Items")
season_episodes: dict[int, list[int]] = {}
for res_item in res_items:
season_index = res_item.get("ParentIndexNumber")
if season_index is None:
continue
if season is not None and season != season_index:
continue
episode_index = res_item.get("IndexNumber")
if episode_index is None:
continue
if season_index not in season_episodes:
season_episodes[season_index] = []
season_episodes[season_index].extend(emby_episode_numbers(res_item))
return item_id, season_episodes
except Exception as e:
logger.error("连接Shows/Id/Episodes出错:" + str(e))
return None, None
return None, {}
def get_season_episode_ids(
self, item_id: str, season: int, *, query: Optional[MediaServerQueryOptions] = None
) -> Dict[int, str]:
"""
读取指定季的分集条目标识。
合并文件覆盖的各个集号指向同一个条目标识。
:param item_id: Emby 条目标识
:param season: 目标季号
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 集号到条目标识的映射;输入不完整、无分集或查询失败时返回空映射
"""
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}
params.update(
emby_query_params(
MediaServerQueryOptions(fields=frozenset()) if query is not None and query.fields is not None else None
)
)
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
for number in emby_episode_numbers(res_item):
episode_ids[number] = 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]:
"""
优先查询指定类型的 TMDB 图片,必要时回退媒体服务器图片。
没有匹配图片时尝试外网媒体服务器地址。远程图片查询发生异常时直接返回 None。
:param item_id: Emby 条目标识
:param image_type: 服务端图片类型,如 Primary 或 Backdrop,需与响应中的 Type 一致
:return: 匹配的图片 URL;回退失败时返回 None
"""
if not self._host or not self._apikey:
return None
url = f"{self._host}emby/Items/{item_id}/RemoteImages"
params = {
"api_key": self._apikey
}
try:
res = RequestUtils(timeout=10).get_res(url, params)
if res:
images = res.json().get("Images")
if images:
for image in images:
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
return _optional_text(image.get("Url"))
logger.info("Items/RemoteImages 未获取到返回数据,采用本地图片")
return self.generate_external_image_link(item_id, image_type)
except Exception as e:
logger.error("连接Items/Id/RemoteImages出错:" + str(e))
return None
def generate_external_image_link(self, item_id: str, image_type: str) -> Optional[str]:
"""
探测外网播放地址下的条目图片。
:param item_id: Emby 条目标识
:param image_type: 服务端图片类型,如 Backdrop 或 Primary
:return: 可访问响应的最终 URL;外网地址缺失、图片不存在或请求失败时返回 None
"""
if not self._playhost:
logger.error("Emby外网播放地址未能获取或为空")
return None
url = f"{self._playhost}Items/{item_id}/Images/{image_type}"
try:
res = RequestUtils().get_res(url)
if res and res.status_code != 404:
logger.info(f"影片图片链接:{res.url}")
return _optional_text(res.url)
else:
logger.info("Items/Id/Images 未获取到返回数据或无该影片{}图片".format(image_type))
return None
except Exception as e:
logger.error("连接Items/Id/Images出错:" + str(e))
return None
def __refresh_emby_library_by_id(self, item_id: str) -> bool:
"""
通知Emby刷新一个项目的媒体库。
"""
if not self._host or not self._apikey:
return False
url = f"{self._host}emby/Items/{item_id}/Refresh"
params = {
"Recursive": "true",
"api_key": self._apikey
}
try:
res = RequestUtils().post_res(url, params=params)
if res:
return True
else:
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
except Exception as e:
logger.error("连接Items/Id/Refresh出错:" + str(e))
return False
return False
def refresh_root_library(self) -> bool:
"""
请求 Emby 刷新整个媒体库。
:return: 刷新请求被接受时返回 True,配置不完整或请求失败时返回 False;不等待扫描完成
"""
if not self._host or not self._apikey:
return False
url = f"{self._host}emby/Library/Refresh"
params = {
"api_key": self._apikey
}
try:
res = RequestUtils().post_res(url, params=params)
if res:
return True
else:
logger.info("刷新媒体库失败,无法连接Emby!")
except Exception as e:
logger.error("连接Library/Refresh出错:" + str(e))
return False
return False
def refresh_library_by_items(self, items: List[_SchemaRefreshMediaItem]) -> Optional[bool]:
"""
按媒体身份和目标路径合并刷新范围,并逐项请求刷新。
已有电视剧仅刷新对应剧集,已有电影不重复刷新。不能定位媒体库时刷新整个根库。
:param items: 包含媒体类型、标题、年份及目标路径的刷新请求列表
:return: 输入为空时返回 False;有任一刷新请求失败时返回 False,无需刷新或全部请求成功时返回 True
"""
if not items:
return False
# 收集要刷新的媒体库信息
logger.info("开始刷新Emby媒体库...")
library_ids = []
for item in items:
library_id = self.__get_emby_library_id_by_item(item)
if library_id and library_id not in library_ids:
library_ids.append(library_id)
if "/" in library_ids:
return self.refresh_root_library()
success = True
for library_id in library_ids:
if library_id != "/":
refreshed = self.__refresh_emby_library_by_id(library_id)
success = bool(refreshed) and success
logger.info("Emby媒体库刷新完成")
return success
def __get_emby_library_id_by_item(self, item: _SchemaRefreshMediaItem) -> Optional[str]:
"""
根据媒体身份、路径及分类确定需要刷新的位置。
:param item: 待刷新的媒体信息及目标路径
:return: 电视剧或媒体库标识;无法定位时返回 / 表示根库,缺少必要信息或电影已存在时返回 None
"""
if not item.type or not item.target_path:
return None
if item.type == MediaType.TV and item.title and item.year:
item_id = self.__get_emby_series_id_by_name(item.title, item.year)
if item_id:
# 存在电视剧,则直接刷新这个电视剧就行
return item_id
elif item.type == MediaType.MOVIE and item.title and item.year:
if self.get_movies(item.title, item.year):
# 已存在,不用刷新
return None
# 查找需要刷新的媒体库ID
item_path = Path(item.target_path)
for folder in self.folders:
for subfolder in folder.get("SubFolders") or []:
try:
subfolder_path = Path(subfolder.get("Path"))
if item_path.is_relative_to(subfolder_path):
return folder.get("Id")
except Exception as err:
logger.debug(f"匹配子目录出错:{err} - {traceback.format_exc()}")
# 如果找不到,只要路径中有分类目录名就命中
if item.category:
for folder in self.folders:
for subfolder in folder.get("SubFolders") or []:
if subfolder.get("Path") and re.search(
r"[/\\]%s" % re.escape(item.category), subfolder.get("Path")
):
return folder.get("Id")
return "/"
@staticmethod
def __format_item_info(item: dict[str, Any]) -> Optional[_SchemaMediaServerItem]:
"""
通过统一 Emby 系转换契约生成条目,并保留 Emby 的服务器 ID。
"""
return format_emby_family_item(
item,
server="emby",
include_server_id=True,
)
def get_iteminfo(
self, itemid: str, *, query: Optional[MediaServerQueryOptions] = None
) -> Optional[_SchemaMediaServerItem]:
"""
查询单个条目并转换为统一媒体模型。
:param itemid: Emby 条目标识
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 条目详情;标识为空、配置不完整、查询失败或类型无法转换时返回 None
"""
if not itemid:
return None
if not self._host or not self._apikey:
return None
url = f"{self._host}emby/Users/{self.user}/Items/{itemid}"
params: dict[str, Any] = {"api_key": self._apikey}
projected = query is not None and query.fields is not None
if projected:
url = url.rsplit("/", 1)[0]
params.update({"Ids": itemid, "Limit": 1, **emby_query_params(query)})
try:
res = RequestUtils().get_res(url, params)
if res and res.status_code == 200:
payload = res.json()
if projected:
payload = next(iter(payload.get("Items") or []), None)
if payload is None:
return None
iteminfo = self.__format_item_info(payload)
return iteminfo
except Exception as e:
logger.error(f"连接/Users/{self.user}/Items/{itemid}出错:" + str(e))
return None
def _get_series_identity(
self,
series_id: Optional[str],
) -> Tuple[Optional[MediaSource], Optional[str]]:
"""
查询 Emby Series 条目的规范身份,查询失败时返回空身份。
"""
if not series_id or not self._host or not self._apikey or not self.user:
return None, None
url = f"{self._host}emby/Users/{self.user}/Items/{series_id}"
params = {
"api_key": self._apikey,
"Fields": "ProviderIds",
}
try:
res = RequestUtils().get_res(url, params)
if res and res.status_code == 200:
return MediaServerIdentityHelper.from_provider_ids(res.json().get("ProviderIds"))
except Exception as e:
logger.error(f"获取 Emby 剧集身份出错:{str(e)}")
return None, None
def get_items_count(self, parent: Optional[Union[str, int]], include_item_types: str = "Movie,Series") -> Optional[int]:
"""
递归统计指定媒体库内的可同步条目。
:param parent: 媒体库标识
:param include_item_types: 逗号分隔的服务端条目类型,默认 Movie,Series
:return: 条目总数;输入或配置不完整、响应缺少计数或查询失败时返回 None
"""
if not parent or not self._host or not self._apikey:
return None
url = f"{self._host}emby/Users/{self.user}/Items"
params = {
"ParentId": parent,
"Recursive": "true",
"IncludeItemTypes": include_item_types,
"Limit": 0,
"api_key": self._apikey,
}
try:
res = RequestUtils().get_res(url, params)
if not res or res.status_code != 200:
return None
total_count = res.json().get("TotalRecordCount")
return int(total_count) if total_count is not None else None
except Exception as e:
logger.error(f"查询媒体库 {parent} 的媒体总数出错:{str(e)}")
return None
def get_items(
self,
parent: Union[str, int],
start_index: Optional[int] = 0,
limit: Optional[int] = -1,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Generator[Optional[MediaServerItem], None, None]:
"""
读取媒体库条目并递归展开目录。
读取失败时向上抛出异常,防止同步调用方将失败误判为媒体库清空。
Folder 展开为成员,只返回电影、电视剧和音乐专辑。
:param parent: 媒体库标识,不接受空值
:param start_index: 分页起始索引;不限制数量时不发送此参数
:param limit: 本层请求数量;None 或 -1 不限制,递归子目录始终不分页
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:yields: 转换后的统一媒体条目
:raises ValueError: 媒体库标识为空
:raises ConnectionError: 配置不完整或读取媒体库失败
"""
if not parent:
raise ValueError("Emby 媒体库 ID 不能为空")
if not self._host or not self._apikey:
raise ConnectionError("Emby 服务器未连接")
url = f"{self._host}emby/Users/{self.user}/Items"
params: dict[str, Union[str, int, None]] = {
"ParentId": parent,
"api_key": self._apikey,
"Fields": "Album,AlbumArtist,AlbumArtists,Artists,ArtistItems,ChildCount,"
"ProviderIds,OriginalTitle,ProductionYear,Path,UserDataPlayCount,"
"UserDataLastPlayedDate,ParentId",
}
if limit is not None and limit != -1:
params.update({"StartIndex": start_index, "Limit": limit})
try:
params.update(emby_query_params(query))
res = RequestUtils().get_res(url, params)
if not res or res.status_code != 200:
raise ConnectionError(
f"读取 Emby 媒体库 {parent} 失败,状态码:{res.status_code if res is not None else '无响应'}"
)
items = res.json().get("Items") or []
for item in items:
if not item:
continue
if "Folder" in item.get("Type"):
for items in self.get_items(
parent=item.get("Id"), **MediaServerQueryOptions.as_kwargs(query)
):
yield items
elif item.get("Type") in ["Movie", "Series", "MusicAlbum"]:
yield self.__format_item_info(item)
except Exception as e:
logger.error(f"同步读取 Emby 媒体库 {parent} 出错:{str(e)}")
raise
def get_webhook_message(self, form: Any, args: dict[str, object]) -> Optional[_SchemaWebhookEventInfo]:
"""
解析 Emby Webhook,并补全统一事件中的媒体身份与图片。
Episode 和 Season 的 ProviderIds 可能指向单集或季,电视剧身份从 SeriesId 对应条目读取。
处理事件可能查询媒体服务器获取剧集身份及背景图片。
:param form: 表单数据;优先解析 data 字段中的 JSON
:param args: 表单无 data 时作为事件字段来源的查询参数
:return: 统一 Webhook 事件;输入为空、JSON 无法解析或缺少 Event 时返回 None
"""
if not form and not args:
return None
try:
if form and form.get("data"):
result = form.get("data")
else:
result = json.dumps(dict(args))
message = json.loads(result)
except Exception as e:
logger.debug("解析emby webhook报文出错:" + str(e))
return None
eventType = message.get('Event')
if not eventType:
return None
logger.debug(f"接收到emby webhook:{message}")
eventItem = _SchemaWebhookEventInfo(event=eventType, channel="emby")
if message.get('Item'):
eventItem.media_type = message.get('Item', {}).get('Type')
if message.get('Item', {}).get('Type') == 'Episode' \
or message.get('Item', {}).get('Type') == 'Series' \
or message.get('Item', {}).get('Type') == 'Season':
eventItem.item_type = "TV"
if message.get('Item', {}).get('SeriesName') \
and message.get('Item', {}).get('ParentIndexNumber') \
and message.get('Item', {}).get('IndexNumber'):
eventItem.item_name = "%s %s%s %s" % (
message.get('Item', {}).get('SeriesName'),
"S" + str(message.get('Item', {}).get('ParentIndexNumber')),
"E" + str(message.get('Item', {}).get('IndexNumber')),
message.get('Item', {}).get('Name'))
elif message.get('Item', {}).get('SeriesName'):
eventItem.item_name = "%s %s" % (
message.get('Item', {}).get('SeriesName'),
message.get('Item', {}).get('Name'))
else:
eventItem.item_name = message.get('Item', {}).get('Name')
eventItem.item_id = message.get('Item', {}).get('SeriesId')
eventItem.season_id = message.get('Item', {}).get('ParentIndexNumber')
eventItem.episode_id = message.get('Item', {}).get('IndexNumber')
elif message.get('Item', {}).get('Type') == 'Audio':
eventItem.item_type = "AUD"
album = message.get('Item', {}).get('Album')
file_name = message.get('Item', {}).get('FileName')
eventItem.item_name = album
eventItem.overview = file_name
eventItem.item_id = message.get('Item', {}).get('AlbumId')
else:
eventItem.item_type = "MOV"
eventItem.item_name = "%s %s" % (
message.get('Item', {}).get('Name'), "(" + str(message.get('Item', {}).get('ProductionYear')) + ")")
eventItem.item_id = message.get('Item', {}).get('Id')
eventItem.item_path = message.get('Item', {}).get('Path')
if message.get('Item', {}).get('Type') in {"Episode", "Season"}:
eventItem.media_source, eventItem.media_id = self._get_series_identity(eventItem.item_id)
else:
eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids(
message.get('Item', {}).get('ProviderIds')
)
if message.get('Item', {}).get('Overview') and len(message.get('Item', {}).get('Overview')) > 100:
eventItem.overview = str(message.get('Item', {}).get('Overview'))[:100] + "..."
else:
eventItem.overview = message.get('Item', {}).get('Overview')
eventItem.percentage = message.get('TranscodingInfo', {}).get('CompletionPercentage')
if not eventItem.percentage:
if message.get('PlaybackInfo', {}).get('PositionTicks') and message.get('Item', {}).get('RunTimeTicks'):
eventItem.percentage = message.get('PlaybackInfo', {}).get('PositionTicks') / \
message.get('Item', {}).get('RunTimeTicks') * 100
if message.get('Session'):
eventItem.ip = message.get('Session').get('RemoteEndPoint')
eventItem.device_name = message.get('Session').get('DeviceName')
eventItem.client = message.get('Session').get('Client')
if message.get("User"):
eventItem.user_name = message.get("User").get('Name')
if message.get("item_isvirtual"):
eventItem.item_isvirtual = message.get("item_isvirtual")
eventItem.item_type = message.get("item_type")
eventItem.item_name = message.get("item_name")
eventItem.item_path = message.get("item_path")
eventItem.media_source, eventItem.media_id = MediaServerIdentityHelper.from_provider_ids({
"tmdb_id": message.get("tmdb_id"),
})
eventItem.season_id = message.get("season_id")
eventItem.episode_id = message.get("episode_id")
if eventItem.item_id:
eventItem.image_url = self.get_remote_image_by_id(item_id=eventItem.item_id,
image_type="Backdrop")
eventItem.json_object = message
return eventItem
def get_data(self, url: str) -> Optional[Response]:
"""
替换服务配置占位符后发送自定义 GET 请求。
:param url: 请求 URL,其中 [HOST]、[APIKEY]、[USER] 替换为当前服务配置
:return: 原始 HTTP 响应,由调用方读取和关闭;配置不完整或请求异常时返回 None
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", _optional_text(self.user or '') or '')
try:
return RequestUtils(content_type="application/json").get_res(url=url)
except Exception as e:
logger.error("连接Emby出错:" + str(e))
return None
def post_data(self, url: str, data: Optional[str] = None, headers: Optional[dict[str, str]] = None) -> Optional[Response]:
"""
替换服务配置占位符后发送自定义 POST 请求。
:param url: 请求 URL,其中 [HOST]、[APIKEY]、[USER] 替换为当前服务配置
:param data: 发送的请求数据,原样传给请求工具
:param headers: 可选请求头
:return: 原始 HTTP 响应,由调用方读取和关闭;配置不完整或请求异常时返回 None
"""
if not self._host or not self._apikey:
return None
url = url.replace("[HOST]", self._host or '') \
.replace("[APIKEY]", self._apikey or '') \
.replace("[USER]", _optional_text(self.user or '') or '')
try:
return RequestUtils(
headers=headers,
).post_res(url=url, data=data)
except Exception as e:
logger.error("连接Emby出错:" + str(e))
return None
def get_play_url(self, item_id: str, server_id: Optional[str] = None) -> str:
"""
构造媒体服务器网页中的条目播放链接。
:param item_id: Emby 条目标识
:param server_id: 链接使用的服务器标识,省略时采用当前实例标识
:return: 优先使用外网播放地址的网页链接;只构造链接,不验证条目存在
"""
server_id = server_id or self.serverid
server_query = f"&serverId={server_id}" if server_id else ""
return f"{self._playhost or self._host}web/index.html#!" \
f"/item?id={item_id}&context=home{server_query}"
def get_backdrop_url(self, item_id: str, image_tag: str, remote: Optional[bool] = False) -> str:
"""
构造带 API 密钥和版本标签的背景图片地址。
:param item_id: Emby 条目标识
:param image_tag: 图片版本标签,用于区分封面更新前后的缓存
:param remote: 是否优先使用外网播放地址;False 使用服务地址
:return: 图片 URL;配置、条目标识或图片标签缺失时返回空字符串
"""
if not self._host or not self._apikey:
return ""
if not image_tag or not item_id:
return ""
if remote:
host_url = self._playhost or self._host
else:
host_url = self._host
return f"{host_url}Items/{item_id}/" \
f"Images/Backdrop?tag={image_tag}&api_key={self._apikey}"
def __get_local_image_by_id(self, item_id: Optional[Union[str, int]], image_tag: Optional[str] = None) -> str:
"""
构造服务内网的主封面图片地址。
:param item_id: Emby 条目标识
:param image_tag: 可选图片版本标签,用于更新缓存键
:return: 主封面地址;服务配置不完整时返回空字符串
"""
if not self._host or not self._apikey:
return ""
image_url = f"{self._host}Items/{item_id}/Images/Primary"
return f"{image_url}?tag={image_tag}" if image_tag else image_url
def get_resume(
self,
num: Optional[int] = 12,
username: Optional[str] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Optional[List[_SchemaMediaServerPlayItem]]:
"""
查询用户的继续观看列表并按可同步媒体库路径筛选。
:param num: 最多保留的条目数量,默认 12
:param username: 用于限定可见内容的用户名,省略时使用当前实例的查询用户
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 电影或电视剧播放条目列表;配置不完整、用户不可用或请求失败时返回 None
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Resume"
params = {
"Limit": 100,
"MediaTypes": "Video",
"Fields": "ProductionYear,Path,ImageTags",
"api_key": self._apikey,
}
options = query or MediaServerQueryOptions()
try:
params.update(emby_query_params(query.with_fields(MediaServerField.PATH) if query is not None else None))
res = RequestUtils().get_res(url, params)
if res:
result = res.json().get("Items") or []
ret_resume: list[_SchemaMediaServerPlayItem] = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_resume) == num:
break
if item.get("Type") not in ["Movie", "Episode"]:
continue
item_path = item.get("Path")
if (
item_path
and library_folders
and not any(str(item_path).startswith(folder) for folder in library_folders)
):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
server_id = item.get("ServerId") or self.serverid
link = (
self.get_play_url(item.get("Id"), server_id=server_id)
if options.includes(MediaServerField.LINK)
else None
)
if item_type == MediaType.MOVIE.value:
title = item.get("Name")
subtitle = str(item.get("ProductionYear")) if item.get("ProductionYear") else None
else:
title = f"{item.get('SeriesName')}"
subtitle = f"S{item.get('ParentIndexNumber')}:{item.get('IndexNumber')} - {item.get('Name')}"
image = self._get_resume_image(item, query)
ret_resume.append(
_SchemaMediaServerPlayItem(
id=item.get("Id"),
item_id=item.get("Id"),
server_id=server_id,
title=title,
subtitle=subtitle,
type=item_type,
image=image,
link=link,
percent=item.get("UserData", {}).get("PlayedPercentage"),
server_type="emby",
)
)
return ret_resume
else:
logger.error("Users/Items/Resume 未获取到返回数据")
except Exception as e:
logger.error("连接Users/Items/Resume出错:" + str(e))
return None
def _get_resume_image(
self,
item: dict[str, Any],
query: Optional[MediaServerQueryOptions],
) -> Optional[str]:
"""
仅在请求图片时运行原有继续观看封面选择与探测逻辑。
:param item: 继续观看接口返回的原始条目
:param query: 可选内容投影;未指定时保留原有图片行为
:return: 可用图片链接;调用方不需要图片时返回 None 且不探测图片
"""
if query is not None and not query.includes(MediaServerField.IMAGES):
return None
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
if item_type == MediaType.MOVIE.value:
if item.get("BackdropImageTags"):
image = self.get_backdrop_url(item_id=item["Id"], image_tag=(item.get("BackdropImageTags") or [])[0])
else:
image = self.__get_local_image_by_id(
item.get("Id"),
image_tag=(item.get("ImageTags") or {}).get("Primary"),
)
else:
image = self.get_backdrop_url(
item_id=item.get("SeriesId", ""), image_tag=item.get("SeriesPrimaryImageTag", "")
)
if not image:
image = self.__get_local_image_by_id(
item.get("SeriesId"),
image_tag=item.get("SeriesPrimaryImageTag"),
)
return image
def get_latest(
self,
num: Optional[int] = 20,
username: Optional[str] = None,
*,
query: Optional[MediaServerQueryOptions] = None,
) -> Optional[List[_SchemaMediaServerPlayItem]]:
"""
查询用户最近入库的媒体条目。
:param num: 最多保留的条目数量,默认 20;先查询 100 条再按库路径筛选
:param username: 用于限定可见内容的用户名,省略时使用当前实例的查询用户
:param query: 可选查询内容投影,省略时保留原有请求和返回内容
:return: 转换后的最新媒体列表;配置不完整、用户不可用或请求失败时返回 None
"""
if not self._host or not self._apikey:
return None
if username:
user = self.get_user(username)
else:
user = self.user
if not user:
return None
url = f"{self._host}Users/{user}/Items/Latest"
params = {
"Limit": 100,
"MediaTypes": "Video",
"Fields": "ProductionYear,Path,BackdropImageTags,ImageTags",
"api_key": self._apikey,
}
options = query or MediaServerQueryOptions()
try:
params.update(emby_query_params(query.with_fields(MediaServerField.PATH) if query is not None else None))
res = RequestUtils().get_res(url, params)
if res:
result = res.json() or []
ret_latest: list[_SchemaMediaServerPlayItem] = []
# 用户媒体库文件夹列表(排除黑名单)
library_folders = self.get_user_library_folders()
for item in result:
if len(ret_latest) == num:
break
if item.get("Type") not in ["Movie", "Series"]:
continue
item_path = item.get("Path")
if (
item_path
and library_folders
and not any(str(item_path).startswith(folder) for folder in library_folders)
):
continue
item_type = MediaType.MOVIE.value if item.get("Type") == "Movie" else MediaType.TV.value
server_id = item.get("ServerId") or self.serverid
link = (
self.get_play_url(item.get("Id"), server_id=server_id)
if options.includes(MediaServerField.LINK)
else None
)
image = (
self.__get_local_image_by_id(
item_id=item.get("Id"),
image_tag=(item.get("ImageTags") or {}).get("Primary"),
)
if options.includes(MediaServerField.IMAGES)
else None
)
ret_latest.append(
_SchemaMediaServerPlayItem(
id=item.get("Id"),
item_id=item.get("Id"),
server_id=server_id,
title=item.get("Name"),
subtitle=str(item.get("ProductionYear")) if item.get("ProductionYear") else None,
type=item_type,
image=image,
link=link,
BackdropImageTags=item.get("BackdropImageTags")
if options.includes(MediaServerField.IMAGES)
else None,
server_type="emby",
)
)
return ret_latest
else:
logger.error("Users/Items/Latest 未获取到返回数据")
except Exception as e:
logger.error("连接Users/Items/Latest出错:" + str(e))
return None
def get_user_library_folders(self) -> list[str]:
"""
收集允许同步的媒体库目录路径。
:return: 按同步库配置过滤后的路径列表;没有可用媒体库时返回空列表
"""
if not self._host or not self._apikey:
return []
library_folders = []
for library in self.get_emby_virtual_folders() or []:
if self._sync_libraries and library.get("Id") not in self._sync_libraries:
continue
paths = library.get("Path")
if paths is None:
raise TypeError("Emby 媒体库路径列表为空")
library_folders += [folder for folder in paths]
return library_folders