496 lines
20 KiB
Python
496 lines
20 KiB
Python
import json
|
||
import re
|
||
from pathlib import Path
|
||
from typing import List, Optional, Union, Dict
|
||
|
||
from app.core.config import settings
|
||
from app.log import logger
|
||
from app.schemas import RefreshMediaItem
|
||
from app.utils.http import RequestUtils
|
||
from app.utils.singleton import Singleton
|
||
from app.utils.string import StringUtils
|
||
from app.schemas.types import MediaType
|
||
|
||
|
||
class Emby(metaclass=Singleton):
|
||
|
||
def __init__(self):
|
||
self._host = settings.EMBY_HOST
|
||
if self._host:
|
||
if not self._host.endswith("/"):
|
||
self._host += "/"
|
||
if not self._host.startswith("http"):
|
||
self._host = "http://" + self._host
|
||
self._apikey = settings.EMBY_API_KEY
|
||
self._user = self.get_user()
|
||
self._folders = self.get_emby_folders()
|
||
|
||
def get_emby_folders(self) -> List[dict]:
|
||
"""
|
||
获取Emby媒体库路径列表
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return []
|
||
req_url = "%semby/Library/SelectableMediaFolders?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
return res.json()
|
||
else:
|
||
logger.error(f"Library/SelectableMediaFolders 未获取到返回数据")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"连接Library/SelectableMediaFolders 出错:" + str(e))
|
||
return []
|
||
|
||
def get_emby_librarys(self) -> List[dict]:
|
||
"""
|
||
获取Emby媒体库列表
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return []
|
||
req_url = f"{self._host}emby/Users/{self._user}/Views?api_key={self._apikey}"
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
return res.json().get("Items")
|
||
else:
|
||
logger.error(f"User/Views 未获取到返回数据")
|
||
return []
|
||
except Exception as e:
|
||
logger.error(f"连接User/Views 出错:" + str(e))
|
||
return []
|
||
|
||
def get_user(self, user_name: str = None) -> Optional[Union[str, int]]:
|
||
"""
|
||
获得管理员用户
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
users = res.json()
|
||
# 先查询是否有与当前用户名称匹配的
|
||
if user_name:
|
||
for user in users:
|
||
if user.get("Name") == user_name:
|
||
return user.get("Id")
|
||
# 查询管理员
|
||
for user in users:
|
||
if user.get("Policy", {}).get("IsAdministrator"):
|
||
return user.get("Id")
|
||
else:
|
||
logger.error(f"Users 未获取到返回数据")
|
||
except Exception as e:
|
||
logger.error(f"连接Users出错:" + str(e))
|
||
return None
|
||
|
||
def get_server_id(self) -> Optional[str]:
|
||
"""
|
||
获得服务器信息
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
req_url = "%sSystem/Info?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
return res.json().get("Id")
|
||
else:
|
||
logger.error(f"System/Info 未获取到返回数据")
|
||
except Exception as e:
|
||
|
||
logger.error(f"连接System/Info出错:" + str(e))
|
||
return None
|
||
|
||
def get_user_count(self) -> int:
|
||
"""
|
||
获得用户数量
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return 0
|
||
req_url = "%semby/Users/Query?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
return res.json().get("TotalRecordCount")
|
||
else:
|
||
logger.error(f"Users/Query 未获取到返回数据")
|
||
return 0
|
||
except Exception as e:
|
||
logger.error(f"连接Users/Query出错:" + str(e))
|
||
return 0
|
||
|
||
def get_activity_log(self, num: int = 30) -> List[dict]:
|
||
"""
|
||
获取Emby活动记录
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return []
|
||
req_url = "%semby/System/ActivityLog/Entries?api_key=%s&" % (self._host, self._apikey)
|
||
ret_array = []
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
ret_json = res.json()
|
||
items = ret_json.get('Items')
|
||
for item in items:
|
||
if item.get("Type") == "AuthenticationSucceeded":
|
||
event_type = "LG"
|
||
event_date = StringUtils.get_time(item.get("Date"))
|
||
event_str = "%s, %s" % (item.get("Name"), item.get("ShortOverview"))
|
||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||
ret_array.append(activity)
|
||
if item.get("Type") in ["VideoPlayback", "VideoPlaybackStopped"]:
|
||
event_type = "PL"
|
||
event_date = StringUtils.get_time(item.get("Date"))
|
||
event_str = item.get("Name")
|
||
activity = {"type": event_type, "event": event_str, "date": event_date}
|
||
ret_array.append(activity)
|
||
else:
|
||
logger.error(f"System/ActivityLog/Entries 未获取到返回数据")
|
||
return []
|
||
except Exception as e:
|
||
|
||
logger.error(f"连接System/ActivityLog/Entries出错:" + str(e))
|
||
return []
|
||
return ret_array[:num]
|
||
|
||
def get_medias_count(self) -> dict:
|
||
"""
|
||
获得电影、电视剧、动漫媒体数量
|
||
:return: MovieCount SeriesCount SongCount
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return {}
|
||
req_url = "%semby/Items/Counts?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
return res.json()
|
||
else:
|
||
logger.error(f"Items/Counts 未获取到返回数据")
|
||
return {}
|
||
except Exception as e:
|
||
logger.error(f"连接Items/Counts出错:" + str(e))
|
||
return {}
|
||
|
||
def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]:
|
||
"""
|
||
根据名称查询Emby中剧集的SeriesId
|
||
:param name: 标题
|
||
:param year: 年份
|
||
:return: None 表示连不通,""表示未找到,找到返回ID
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
req_url = "%semby/Items?IncludeItemTypes=Series&Fields=ProductionYear&StartIndex=0&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||
self._host, name, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
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 res_item.get('Id')
|
||
except Exception as e:
|
||
logger.error(f"连接Items出错:" + str(e))
|
||
return None
|
||
return ""
|
||
|
||
def get_movies(self, title: str, year: str = None) -> Optional[List[dict]]:
|
||
"""
|
||
根据标题和年份,检查电影是否在Emby中存在,存在则返回列表
|
||
:param title: 标题
|
||
:param year: 年份,可以为空,为空时不按年份过滤
|
||
:return: 含title、year属性的字典列表
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
req_url = "%semby/Items?IncludeItemTypes=Movie&Fields=ProductionYear&StartIndex=0" \
|
||
"&Recursive=true&SearchTerm=%s&Limit=10&IncludeSearchTypes=false&api_key=%s" % (
|
||
self._host, title, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
res_items = res.json().get("Items")
|
||
if res_items:
|
||
ret_movies = []
|
||
for res_item in res_items:
|
||
if res_item.get('Name') == title and (
|
||
not year or str(res_item.get('ProductionYear')) == str(year)):
|
||
ret_movies.append(
|
||
{'title': res_item.get('Name'), 'year': str(res_item.get('ProductionYear'))})
|
||
return ret_movies
|
||
except Exception as e:
|
||
logger.error(f"连接Items出错:" + str(e))
|
||
return None
|
||
return []
|
||
|
||
def get_tv_episodes(self,
|
||
title: str = None,
|
||
year: str = None,
|
||
tmdb_id: int = None,
|
||
season: int = None) -> Optional[Dict[int, list]]:
|
||
"""
|
||
根据标题和年份和季,返回Emby中的剧集列表
|
||
:param title: 标题
|
||
:param year: 年份
|
||
:param tmdb_id: TMDBID
|
||
:param season: 季
|
||
:return: 每一季的已有集数
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
# 电视剧
|
||
item_id = self.__get_emby_series_id_by_name(title, year)
|
||
if item_id is None:
|
||
return None
|
||
if not item_id:
|
||
return {}
|
||
# 验证tmdbid是否相同
|
||
item_tmdbid = self.get_iteminfo(item_id).get("ProviderIds", {}).get("Tmdb")
|
||
if tmdb_id and item_tmdbid:
|
||
if str(tmdb_id) != str(item_tmdbid):
|
||
return {}
|
||
# /Shows/Id/Episodes 查集的信息
|
||
if not season:
|
||
season = ""
|
||
try:
|
||
req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % (
|
||
self._host, item_id, season, self._apikey)
|
||
res_json = RequestUtils().get_res(req_url)
|
||
if res_json:
|
||
res_items = res_json.json().get("Items")
|
||
season_episodes = {}
|
||
for res_item in res_items:
|
||
season_index = res_item.get("ParentIndexNumber")
|
||
if not season_index:
|
||
continue
|
||
if season and season != season_index:
|
||
continue
|
||
episode_index = res_item.get("IndexNumber")
|
||
if not episode_index:
|
||
continue
|
||
if season_index not in season_episodes:
|
||
season_episodes[season_index] = []
|
||
season_episodes[season_index].append(episode_index)
|
||
# 返回
|
||
return season_episodes
|
||
except Exception as e:
|
||
logger.error(f"连接Shows/Id/Episodes出错:" + str(e))
|
||
return None
|
||
return {}
|
||
|
||
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
|
||
"""
|
||
根据ItemId从Emby查询TMDB的图片地址
|
||
:param item_id: 在Emby中的ID
|
||
:param image_type: 图片的类弄地,poster或者backdrop等
|
||
:return: 图片对应在TMDB中的URL
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return None
|
||
req_url = "%semby/Items/%s/RemoteImages?api_key=%s" % (self._host, item_id, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res:
|
||
images = res.json().get("Images")
|
||
for image in images:
|
||
if image.get("ProviderName") == "TheMovieDb" and image.get("Type") == image_type:
|
||
return image.get("Url")
|
||
else:
|
||
logger.error(f"Items/RemoteImages 未获取到返回数据")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"连接Items/Id/RemoteImages出错:" + str(e))
|
||
return None
|
||
return None
|
||
|
||
def __refresh_emby_library_by_id(self, item_id: str) -> bool:
|
||
"""
|
||
通知Emby刷新一个项目的媒体库
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return False
|
||
req_url = "%semby/Items/%s/Refresh?Recursive=true&api_key=%s" % (self._host, item_id, self._apikey)
|
||
try:
|
||
res = RequestUtils().post_res(req_url)
|
||
if res:
|
||
return True
|
||
else:
|
||
logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!")
|
||
except Exception as e:
|
||
logger.error(f"连接Items/Id/Refresh出错:" + str(e))
|
||
return False
|
||
return False
|
||
|
||
def refresh_root_library(self) -> bool:
|
||
"""
|
||
通知Emby刷新整个媒体库
|
||
"""
|
||
if not self._host or not self._apikey:
|
||
return False
|
||
req_url = "%semby/Library/Refresh?api_key=%s" % (self._host, self._apikey)
|
||
try:
|
||
res = RequestUtils().post_res(req_url)
|
||
if res:
|
||
return True
|
||
else:
|
||
logger.info(f"刷新媒体库失败,无法连接Emby!")
|
||
except Exception as e:
|
||
logger.error(f"连接Library/Refresh出错:" + str(e))
|
||
return False
|
||
return False
|
||
|
||
def refresh_library_by_items(self, items: List[RefreshMediaItem]) -> bool:
|
||
"""
|
||
按类型、名称、年份来刷新媒体库
|
||
:param items: 已识别的需要刷新媒体库的媒体信息列表
|
||
"""
|
||
if not items:
|
||
return False
|
||
# 收集要刷新的媒体库信息
|
||
logger.info(f"开始刷新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()
|
||
for library_id in library_ids:
|
||
if library_id != "/":
|
||
return self.__refresh_emby_library_by_id(library_id)
|
||
logger.info(f"Emby媒体库刷新完成")
|
||
|
||
def __get_emby_library_id_by_item(self, item: RefreshMediaItem) -> Optional[str]:
|
||
"""
|
||
根据媒体信息查询在哪个媒体库,返回要刷新的位置的ID
|
||
:param item: {title, year, type, category, target_path}
|
||
"""
|
||
if not item.title or not item.year or not item.type:
|
||
return None
|
||
if item.type != MediaType.MOVIE.value:
|
||
item_id = self.__get_emby_series_id_by_name(item.title, item.year)
|
||
if item_id:
|
||
# 存在电视剧,则直接刷新这个电视剧就行
|
||
return item_id
|
||
else:
|
||
if self.get_movies(item.title, item.year):
|
||
# 已存在,不用刷新
|
||
return None
|
||
# 查找需要刷新的媒体库ID
|
||
item_path = Path(item.target_path)
|
||
for folder in self._folders:
|
||
# 找同级路径最多的媒体库(要求容器内映射路径与实际一致)
|
||
max_comm_path = ""
|
||
match_num = 0
|
||
match_id = None
|
||
# 匹配子目录
|
||
for subfolder in folder.get("SubFolders"):
|
||
try:
|
||
# 查询最大公共路径
|
||
subfolder_path = Path(subfolder.get("Path"))
|
||
item_path_parents = list(item_path.parents)
|
||
subfolder_path_parents = list(subfolder_path.parents)
|
||
common_path = next(p1 for p1, p2 in zip(reversed(item_path_parents),
|
||
reversed(subfolder_path_parents)
|
||
) if p1 == p2)
|
||
if len(common_path) > len(max_comm_path):
|
||
max_comm_path = common_path
|
||
match_id = subfolder.get("Id")
|
||
match_num += 1
|
||
except StopIteration:
|
||
continue
|
||
except Exception as err:
|
||
print(str(err))
|
||
# 检查匹配情况
|
||
if match_id:
|
||
return match_id if match_num == 1 else folder.get("Id")
|
||
# 如果找不到,只要路径中有分类目录名就命中
|
||
for subfolder in folder.get("SubFolders"):
|
||
if subfolder.get("Path") and re.search(r"[/\\]%s" % item.category,
|
||
subfolder.get("Path")):
|
||
return folder.get("Id")
|
||
# 刷新根目录
|
||
return "/"
|
||
|
||
def get_iteminfo(self, itemid: str) -> dict:
|
||
"""
|
||
获取单个项目详情
|
||
"""
|
||
if not itemid:
|
||
return {}
|
||
if not self._host or not self._apikey:
|
||
return {}
|
||
req_url = "%semby/Users/%s/Items/%s?api_key=%s" % (self._host, self._user, itemid, self._apikey)
|
||
try:
|
||
res = RequestUtils().get_res(req_url)
|
||
if res and res.status_code == 200:
|
||
return res.json()
|
||
except Exception as e:
|
||
logger.error(f"连接Items/Id出错:" + str(e))
|
||
return {}
|
||
|
||
def get_webhook_message(self, message_str: str) -> dict:
|
||
"""
|
||
解析Emby Webhook报文
|
||
"""
|
||
message = json.loads(message_str)
|
||
eventItem = {'event': message.get('Event', ''), "channel": "emby"}
|
||
if message.get('Item'):
|
||
if message.get('Item', {}).get('Type') == 'Episode':
|
||
eventItem['item_type'] = "TV"
|
||
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'))
|
||
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_path'] = message.get('Item', {}).get('Path')
|
||
eventItem['item_id'] = message.get('Item', {}).get('Id')
|
||
|
||
eventItem['tmdb_id'] = message.get('Item', {}).get('ProviderIds', {}).get('Tmdb')
|
||
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'):
|
||
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 eventItem.get("item_id"):
|
||
# 根据返回的item_id去调用媒体服务器获取
|
||
eventItem['image_url'] = self.get_remote_image_by_id(item_id=eventItem.get('item_id'),
|
||
image_type="Backdrop")
|
||
|
||
return eventItem
|