#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()