From 0ffb2df90275a7b019dbb5543a8446d6fc63e9f2 Mon Sep 17 00:00:00 2001 From: ygbhbox Date: Tue, 16 Dec 2025 08:29:29 +0000 Subject: [PATCH] emby.py created online with Bitbucket --- TVBoxOSC/tvbox/py/emby.py | 564 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 TVBoxOSC/tvbox/py/emby.py diff --git a/TVBoxOSC/tvbox/py/emby.py b/TVBoxOSC/tvbox/py/emby.py new file mode 100644 index 0000000..fd13d94 --- /dev/null +++ b/TVBoxOSC/tvbox/py/emby.py @@ -0,0 +1,564 @@ +#coding=utf-8 +#!/usr/bin/python +import sys +import json +import time +import random +import requests +import threading +from uuid import uuid4 +from urllib.parse import quote + +sys.path.append('..') +from base.spider import Spider + +class Spider(Spider): + def getName(self): + return "EMBY" + + def init(self, extend): + try: + extendDict = json.loads(extend) + self.baseUrl = extendDict['server'].strip('/') + self.username = extendDict['username'] + self.password = extendDict['password'] + self.proxy = extendDict['proxy'] + self.thread = extendDict['thread'] if 'thread' in extendDict else 0 + self.device_id = extendDict.get('device_id', str(uuid4())) + self.client = extendDict.get('client', 'Hills Windows') + self.device_name = extendDict.get('device_name', 'My Computer') + self.client_version = extendDict.get('client_version', '0.2.2') + except: + self.baseUrl = '' + self.username = '' + self.password = '' + self.proxy = '' + self.thread = 0 + self.device_id = str(uuid4()) + self.client = 'Hills Windows' + self.device_name = 'My Computer' + self.client_version = '0.2.2' + + # 初始化header + self.header = { + "User-Agent": f"{self.client}/{self.client_version}".replace(' ', '-'), # 替换空格为连字符 + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version + } + + # 初始化播放会话字典 + self.play_sessions = {} + + def destroy(self): + # 清理所有播放会话 + for session_id in list(self.play_sessions.keys()): + self._record_playback_stop(session_id) + self.play_sessions.clear() + + def isVideoFormat(self, url): + pass + + def manualVideoCheck(self): + pass + + def homeContent(self, filter): + try: + embyInfos = self.getAccessToken() + except: + return {'msg': '获取Emby服务器信息出错'} + + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + url = f"{self.baseUrl}/emby/Users/{embyInfos['User']['Id']}/Views" + params = { + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + } + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + typeInfos = r.json()["Items"] + classList = [] + for typeInfo in typeInfos: + if "播放列表" in typeInfo['Name'] or '相机' in typeInfo['Name']: + continue + classList.append({"type_name": typeInfo['Name'], "type_id": typeInfo['Id']}) + result = {'class': classList} + return result + + def homeVideoContent(self): + return {} + + def categoryContent(self, cid, page, filter, ext): + try: + embyInfos = self.getAccessToken() + except: + return {'list': [], 'msg': '获取Emby服务器信息出错'} + + result = {} + page = int(page) + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + url = f"{self.baseUrl}/emby/Users/{embyInfos['User']['Id']}/Items" + params = { + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'], + "SortBy": "DateLastContentAdded,SortName", + "IncludeItemTypes": "Movie,Series", + "SortOrder": "Descending", + "ParentId": cid, + "Recursive": "true", + "Limit": "30", + "ImageTypeLimit": 1, + "StartIndex": str((page - 1) * 30), + "EnableImageTypes": "Primary,Backdrop,Thumb,Banner", + "Fields": "BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,CommunityRating,Status,CriticRating,EndDate,Path", + "EnableUserData": "true" + } + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + videoList = r.json()['Items'] + videos = [] + for video in videoList: + name = self.cleanText(video['Name']) + videos.append({ + "vod_id": video['Id'], + "vod_name": name, + "vod_pic": f"{self.baseUrl}/emby/Items/{video['Id']}/Images/Primary?maxWidth=400&tag={video['ImageTags']['Primary']}&quality=90" if 'Primary' in video['ImageTags'] else '', + "vod_remarks": video['ProductionYear'] if 'ProductionYear' in video else '' + }) + result['list'] = videos + result['page'] = page + result['pagecount'] = page + 1 if page * 30 < int(r.json()['TotalRecordCount']) else page + result['limit'] = len(videos) + result['total'] = int(r.json()['TotalRecordCount']) if "TotalRecordCount" in r.json() else 0 + return result + + def detailContent(self, did): + try: + embyInfos = self.getAccessToken() + except: + return {'list': [], 'msg': '获取Emby服务器信息出错'} + + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + url = f"{self.baseUrl}/emby/Users/{embyInfos['User']['Id']}/Items/{did[0]}" + params = { + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + } + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + videoInfos = r.json() + vod = { + "vod_id": did[0], + "vod_name": videoInfos['Name'], + "vod_pic": f'{self.baseUrl}/emby/Items/{did[0]}/Images/Primary?maxWidth=400&tag={videoInfos["ImageTags"]["Primary"]}&quality=90' if 'Primary' in videoInfos['ImageTags'] else '', + "type_name": videoInfos['Genres'][0] if len(videoInfos['Genres']) > 0 else '', + "vod_year": videoInfos['ProductionYear'] if 'ProductionYear' in videoInfos else '', + "vod_content": videoInfos['Overview'].replace('\xa0', ' ').replace('\n\n', '\n').strip() if 'Overview' in videoInfos else '', + "vod_play_from": "宝盒4KHDR【臻彩视界】" + } + playUrl = '' + if not videoInfos['IsFolder']: + playUrl += f"{videoInfos['Name'].strip()}${videoInfos['Id']}#" + else: + url = f"{self.baseUrl}/emby/Shows/{did[0]}/Seasons" + params.update( + { + "UserId": embyInfos['User']['Id'], + "EnableImages": "true", + "Fields": "BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,CommunityRating", + "EnableUserData": "true", + "EnableTotalRecordCount": "false" + } + ) + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + if r.status_code == 200: + playInfos = r.json()['Items'] + for playInfo in playInfos: + url = f"{self.baseUrl}/emby/Shows/{playInfo['Id']}/Episodes" + params.update( + { + "SeasonId": playInfo['Id'], + "Fields": "BasicSyncInfo,CanDelete,CommunityRating,PrimaryImageAspectRatio,ProductionYear,Overview" + } + ) + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + videoList = r.json()['Items'] + for video in videoList: + playUrl += f"{playInfo['Name'].replace('#', '-').replace('$', '|').strip()}|{video['Name'].strip()}${video['Id']}#" + else: + url = f"{self.baseUrl}/emby/Users/{embyInfos['User']['Id']}/Items" + params = { + "ParentId": did[0], + "Fields": "BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,CommunityRating,CriticRating", + "ImageTypeLimit": "1", + "StartIndex": "0", + "EnableUserData": "true", + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + } + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + videoList = r.json()['Items'] + for video in videoList: + playUrl += f"{video['Name'].replace('#', '-').replace('$', '|').strip()}${video['Id']}#" + vod['vod_play_url'] = playUrl.strip('#') + result = {'list': [vod]} + return result + + def searchContent(self, key, quick, pg="1"): + return self.searchContentPage(key, quick, pg) + + def searchContentPage(self, keywords, quick, page): + try: + embyInfos = self.getAccessToken() + except: + return {'list': [], 'msg': '获取Emby服务器信息出错'} + page = int(page) + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + url = f"{self.baseUrl}/emby/Users/{embyInfos['User']['Id']}/Items" + params = { + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'], + "SortBy": "SortName", + "SortOrder": "Ascending", + "Fields": "BasicSyncInfo,CanDelete,Container,PrimaryImageAspectRatio,ProductionYear,Status,EndDate", + "StartIndex": str(((page-1)*50)), + "EnableImageTypes": "Primary,Backdrop,Thumb", + "ImageTypeLimit": "1", + "Recursive": "true", + "SearchTerm": keywords, + "IncludeItemTypes": "Movie,Series,BoxSet", + "GroupProgramsBySeries": "true", + "Limit": "50", + "EnableTotalRecordCount": "true" + } + r = requests.get(url, params=params, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + + videos = [] + vodList = r.json()['Items'] + for vod in vodList: + sid = vod['Id'] + name = self.cleanText(vod['Name']) + pic = f'{self.baseUrl}/emby/Items/{sid}/Images/Primary?maxWidth=400&tag={vod["ImageTags"]["Primary"]}&quality=90' if 'Primary' in vod["ImageTags"] else '' + videos.append({ + "vod_id": sid, + "vod_name": name, + "vod_pic": pic, + "vod_remarks": vod['ProductionYear'] if 'ProductionYear' in vod else '' + }) + result = {'list': videos} + return result + + def playerContent(self, flag, pid, vipFlags): + try: + embyInfos = self.getAccessToken() + except: + return {'list': [], 'msg': '获取Emby服务器信息出错'} + + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + + # 获取播放信息 + url = f"{self.baseUrl}/emby/Items/{pid}/PlaybackInfo" + params = { + "UserId": embyInfos['User']['Id'], + "IsPlayback": "false", + "AutoOpenLiveStream": "false", + "StartTimeTicks": 0, + "MaxStreamingBitrate": "2147483647", + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + } + data = "{\"DeviceProfile\":{\"SubtitleProfiles\":[{\"Method\":\"Embed\",\"Format\":\"ass\"},{\"Format\":\"ssa\",\"Method\":\"Embed\"},{\"Format\":\"subrip\",\"Method\":\"Embed\"},{\"Format\":\"sub\",\"Method\":\"Embed\"},{\"Method\":\"Embed\",\"Format\":\"pgssub\"},{\"Format\":\"subrip\",\"Method\":\"External\"},{\"Method\":\"External\",\"Format\":\"sub\"},{\"Method\":\"External\",\"Format\":\"ass\"},{\"Format\":\"ssa\",\"Method\":\"External\"},{\"Method\":\"External\",\"Format\":\"vtt\"},{\"Method\":\"External\",\"Format\":\"ass\"},{\"Format\":\"ssa\",\"Method\":\"External\"}],\"CodecProfiles\":[{\"Codec\":\"h264\",\"Type\":\"Video\",\"ApplyConditions\":[{\"Property\":\"IsAnamorphic\",\"Value\":\"true\",\"Condition\":\"NotEquals\",\"IsRequired\":false},{\"IsRequired\":false,\"Value\":\"high|main|baseline|constrained baseline\",\"Condition\":\"EqualsAny\",\"Property\":\"VideoProfile\"},{\"IsRequired\":false,\"Value\":\"80\",\"Condition\":\"LessThanEqual\",\"Property\":\"VideoLevel\"},{\"IsRequired\":false,\"Value\":\"true\",\"Condition\":\"NotEquals\",\"Property\":\"IsInterlaced\"}]},{\"Codec\":\"hevc\",\"ApplyConditions\":[{\"Property\":\"IsAnamorphic\",\"Value\":\"true\",\"Condition\":\"NotEquals\",\"IsRequired\":false},{\"IsRequired\":false,\"Value\":\"high|main|main 10\",\"Condition\":\"EqualsAny\",\"Property\":\"VideoProfile\"},{\"Property\":\"VideoLevel\",\"Value\":\"175\",\"Condition\":\"LessThanEqual\",\"IsRequired\":false},{\"IsRequired\":false,\"Value\":\"true\",\"Condition\":\"NotEquals\",\"Property\":\"IsInterlaced\"}],\"Type\":\"Video\"}],\"MaxStreamingBitrate\":40000000,\"TranscodingProfiles\":[{\"Container\":\"ts\",\"AudioCodec\":\"aac,mp3,wav,ac3,eac3,flac,opus\",\"VideoCodec\":\"hevc,h264,mpeg4\",\"BreakOnNonKeyFrames\":true,\"Type\":\"Video\",\"MaxAudioChannels\":\"6\",\"Protocol\":\"hls\",\"Context\":\"Streaming\",\"MinSegments\":2}],\"DirectPlayProfiles\":[{\"Container\":\"mov,mp4,mkv,hls,webm\",\"Type\":\"Video\",\"VideoCodec\":\"h264,hevc,dvhe,dvh1,h264,hevc,hev1,mpeg4,vp9\",\"AudioCodec\":\"aac,mp3,wav,ac3,eac3,flac,truehd,dts,dca,opus,pcm,pcm_s24le\"}],\"ResponseProfiles\":[{\"MimeType\":\"video/mp4\",\"Type\":\"Video\",\"Container\":\"m4v\"}],\"ContainerProfiles\":[],\"MusicStreamingTranscodingBitrate\":40000000,\"MaxStaticBitrate\":40000000}}" + r = requests.post(url, params=params, data=data, headers=header, timeout=120, proxies={"http": self.proxy, "https": self.proxy}) + + # 获取播放URL + media_sources = r.json()['MediaSources'] + if not media_sources: + return {'list': [], 'msg': '没有可用的媒体源'} + + # 使用第一个媒体源 + media_source = media_sources[0] + direct_stream_url = media_source.get('DirectStreamUrl') + + if not direct_stream_url: + return {'list': [], 'msg': '无法获取播放URL'} + + url = self.baseUrl + direct_stream_url + + # 记录播放开始 + try: + session_id = self._record_playback_start(embyInfos, pid, media_source) + # 启动播放进度更新线程 + self._start_progress_updater(embyInfos, pid, media_source, session_id) + except Exception as e: + print(f"记录播放开始失败: {e}") + + if int(self.thread) > 0: + try: + self.fetch('http://127.0.0.1:7777', timeout=120) + except: + self.fetch('http://127.0.0.1:9978/go') + url = f'http://127.0.0.1:7777/?url={quote(url)}&thread={self.thread}' + + result = { + "url": url, + "header": self.header, + "parse": 0 + } + return result + + def _record_playback_start(self, embyInfos, item_id, media_source): + """记录播放开始""" + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + + # 添加认证头 + header.update({ + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + }) + + # 生成唯一的会话ID + session_id = f"session_{int(time.time())}_{random.randint(1000, 9999)}" + + # 构建播放开始数据 + play_data = { + "ItemId": item_id, + "MediaSourceId": media_source.get('Id'), + "CanSeek": True, + "IsPaused": False, + "IsMuted": False, + "PositionTicks": 0, + "PlayMethod": "DirectStream", + "PlaySessionId": session_id, + "LiveStreamId": None, + "AudioStreamIndex": 1, + "SubtitleStreamIndex": -1, + "VolumeLevel": 100, + "PlaybackStartTimeTicks": int(time.time() * 10000000) + } + + # 发送播放开始请求 + play_url = f"{self.baseUrl}/Sessions/Playing" + try: + response = requests.post( + play_url, + json=play_data, + headers=header, + timeout=5, + proxies={"http": self.proxy, "https": self.proxy} + ) + if response.status_code == 200 or response.status_code == 204: + print(f"播放开始记录成功: {response.status_code}") + # 保存会话信息 + self.play_sessions[session_id] = { + 'embyInfos': embyInfos, + 'item_id': item_id, + 'media_source': media_source, + 'start_time': time.time(), + 'last_update': time.time() + } + return session_id + else: + print(f"播放开始记录失败: {response.status_code}, {response.text}") + return None + except Exception as e: + print(f"播放开始记录请求异常: {e}") + return None + + def _record_playback_progress(self, embyInfos, item_id, media_source, session_id, position_seconds): + """记录播放进度""" + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + + # 添加认证头 + header.update({ + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + }) + + # 构建播放进度数据 + progress_data = { + "ItemId": item_id, + "MediaSourceId": media_source.get('Id'), + "PositionTicks": int(position_seconds * 10000000), # 转换为ticks + "IsPaused": False, + "PlaySessionId": session_id, + "EventName": "timeupdate" + } + + # 发送播放进度请求 + progress_url = f"{self.baseUrl}/Sessions/Playing/Progress" + try: + response = requests.post( + progress_url, + json=progress_data, + headers=header, + timeout=5, + proxies={"http": self.proxy, "https": self.proxy} + ) + if response.status_code == 200 or response.status_code == 204: + print(f"播放进度更新成功: {position_seconds}秒") + return True + else: + print(f"播放进度更新失败: {response.status_code}, {response.text}") + return False + except Exception as e: + print(f"播放进度更新请求异常: {e}") + return False + + def _record_playback_stop(self, session_id): + """记录播放停止""" + if session_id not in self.play_sessions: + return False + + session_info = self.play_sessions[session_id] + embyInfos = session_info['embyInfos'] + item_id = session_info['item_id'] + media_source = session_info['media_source'] + total_duration = time.time() - session_info['start_time'] + + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + + # 添加认证头 + header.update({ + "X-Emby-Client": self.client, + "X-Emby-Device-Name": self.device_name, + "X-Emby-Device-Id": self.device_id, + "X-Emby-Client-Version": self.client_version, + "X-Emby-Token": embyInfos['AccessToken'] + }) + + # 构建播放停止数据 + stop_data = { + "ItemId": item_id, + "MediaSourceId": media_source.get('Id'), + "PositionTicks": int(total_duration * 10000000), # 转换为ticks + "PlaySessionId": session_id + } + + # 发送播放停止请求 + stop_url = f"{self.baseUrl}/Sessions/Playing/Stopped" + try: + response = requests.post( + stop_url, + json=stop_data, + headers=header, + timeout=5, + proxies={"http": self.proxy, "https": self.proxy} + ) + if response.status_code == 200 or response.status_code == 204: + print(f"播放停止记录成功: 总时长 {total_duration:.1f}秒") + # 移除会话信息 + if session_id in self.play_sessions: + del self.play_sessions[session_id] + return True + else: + print(f"播放停止记录失败: {response.status_code}, {response.text}") + return False + except Exception as e: + print(f"播放停止记录请求异常: {e}") + return False + + def _start_progress_updater(self, embyInfos, item_id, media_source, session_id): + """启动播放进度更新线程""" + if not session_id: + return + + def progress_updater(): + try: + start_time = time.time() + last_update = start_time + + # 每30秒更新一次播放进度 + while session_id in self.play_sessions: + current_time = time.time() + elapsed = current_time - start_time + + # 每30秒更新一次进度 + if current_time - last_update >= 30: + self._record_playback_progress( + embyInfos, item_id, media_source, session_id, elapsed + ) + last_update = current_time + + # 检查是否超过最大持续时间(2小时) + if elapsed >= 7200: # 2小时 + break + + time.sleep(5) # 每5秒检查一次 + + # 播放结束,记录停止 + if session_id in self.play_sessions: + self._record_playback_stop(session_id) + + except Exception as e: + print(f"播放进度更新线程异常: {e}") + # 确保在异常情况下也尝试记录播放停止 + if session_id in self.play_sessions: + self._record_playback_stop(session_id) + + # 启动线程 + thread = threading.Thread(target=progress_updater, daemon=True) + thread.start() + + def localProxy(self, params): + pass + + def getAccessToken(self): + key = f"emby_{self.baseUrl}_{self.username}_{self.password}" + embyInfos = self.getCache(key) + if embyInfos: + return embyInfos + + header = self.header.copy() + header['Content-Type'] = "application/json; charset=UTF-8" + + auth_data = { + "Username": self.username, + "Pw": self.password + } + + r = requests.post( + f"{self.baseUrl}/emby/Users/AuthenticateByName", + json=auth_data, + headers=header, + timeout=120, + proxies={"http": self.proxy, "https": self.proxy} + ) + embyInfos = r.json() + self.setCache(key, embyInfos) + return embyInfos + + def cleanText(self, text): + # 清理文本中的特殊字符 + if not text: + return "" + return text.replace("\n", " ").replace("\r", " ").replace("\t", " ").strip() \ No newline at end of file