This commit is contained in:
jxxghp
2023-06-06 07:15:17 +08:00
commit 4d06f86e62
217 changed files with 13959 additions and 0 deletions

200
app/modules/__init__.py Normal file
View File

@ -0,0 +1,200 @@
from abc import abstractmethod, ABCMeta
from pathlib import Path
from typing import Optional, List, Tuple, Union, Set
from fastapi import Request
from app.core.context import MediaInfo, TorrentInfo
from app.core.meta import MetaBase
class _ModuleBase(metaclass=ABCMeta):
"""
模块基类实现对应方法在有需要时会被自动调用返回None代表不启用该模块
输入参数与输出参数一致的,可以被多个模块重复实现
通过监听事件来实现多个模块之间的协作
"""
@abstractmethod
def init_module(self) -> None:
"""
模块初始化
"""
pass
@abstractmethod
def init_setting(self) -> Tuple[str, Union[str, bool]]:
"""
模块开关设置返回开关名和开关值开关值为True时代表有值即打开不实现该方法或返回None代表不使用开关
"""
pass
def prepare_recognize(self, title: str,
subtitle: str = None) -> Tuple[str, str]:
"""
识别前的预处理
:param title: 标题
:param subtitle: 副标题
:return: 处理后的标题、副标题注意如果返回None有可能是没有对应的处理模块应无视结果
"""
pass
def recognize_media(self, meta: MetaBase,
tmdbid: str = None) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param tmdbid: tmdbid
:return: 识别的媒体信息,包括剧集信息
"""
pass
def douban_info(self, doubanid: str) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
pass
def message_parser(self, request: Request) -> Optional[dict]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param request: 请求体
:return: 消息内容、用户ID
"""
pass
def webhook_parser(self, message: dict) -> Optional[dict]:
"""
解析Webhook报文体
:param message: 请求体
:return: 字典解析为消息时需要包含title、text、image
"""
pass
def obtain_image(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
获取图片
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息注意如果返回None有可能是没有对应的处理模块应无视结果
"""
pass
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
"""
pass
def search_torrents(self, mediainfo: Optional[MediaInfo], sites: List[dict],
keyword: str = None) -> Optional[List[TorrentInfo]]:
"""
搜索站点,多个站点需要多线程处理
:param mediainfo: 识别的媒体信息
:param sites: 站点列表
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
:reutrn: 资源列表
"""
pass
def refresh_torrents(self, sites: List[dict]) -> Optional[List[TorrentInfo]]:
"""
获取站点最新一页的种子,多个站点需要多线程处理
:param sites: 站点列表
:reutrn: 种子资源列表
"""
pass
def filter_torrents(self, torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
过滤资源
:param torrent_list: 资源列表
:return: 过滤后的资源列表注意如果返回None有可能是没有对应的处理模块应无视结果
"""
pass
def download(self, torrent_path: Path, cookie: str,
episodes: Set[int] = None) -> Optional[Tuple[Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param torrent_path: 种子文件地址
:param cookie: 站点Cookie
:param episodes: 需要下载的集数
:return: 种子Hash
"""
pass
def transfer(self, path: str, mediainfo: MediaInfo) -> Optional[bool]:
"""
文件转移
:param path: 文件路径
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
pass
def media_exists(self, mediainfo: MediaInfo) -> Optional[dict]:
"""
判断媒体文件是否存在
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
pass
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: str) -> Optional[bool]:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
pass
def post_message(self, title: str,
text: str = None, image: str = None, userid: Union[str, int] = None) -> Optional[bool]:
"""
发送消息
:param title: 标题
:param text: 内容
:param image: 图片
:param userid: 用户ID
:return: 成功或失败
"""
pass
def post_medias_message(self, title: str, items: List[MediaInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
发送媒体信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
pass
def post_torrents_message(self, title: str, items: List[TorrentInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
发送种子信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
pass
def scrape_metadata(self, path: str, mediainfo: MediaInfo) -> None:
"""
刮削元数据
:param path: 媒体文件路径
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
pass

Binary file not shown.

View File

@ -0,0 +1,115 @@
from typing import List, Optional, Tuple, Union
from app.core import MediaInfo, settings
from app.core.meta import MetaBase
from app.modules import _ModuleBase
from app.modules.douban.apiv2 import DoubanApi
from app.utils.types import MediaType
class Douban(_ModuleBase):
def __init__(self):
super().__init__()
self.doubanapi = DoubanApi()
def init_module(self) -> None:
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def douban_info(self, doubanid: str) -> Optional[dict]:
"""
获取豆瓣信息
:param doubanid: 豆瓣ID
:return: 识别的媒体信息,包括剧集信息
"""
if not doubanid:
return None
douban_info = self.doubanapi.movie_detail(doubanid)
if douban_info:
celebrities = self.doubanapi.movie_celebrities(doubanid)
if celebrities:
douban_info["directors"] = celebrities.get("directors")
douban_info["actors"] = celebrities.get("actors")
else:
douban_info = self.doubanapi.tv_detail(doubanid)
celebrities = self.doubanapi.tv_celebrities(doubanid)
if douban_info and celebrities:
douban_info["directors"] = celebrities.get("directors")
douban_info["actors"] = celebrities.get("actors")
return self.__extend_doubaninfo(douban_info)
@staticmethod
def __extend_doubaninfo(doubaninfo: dict):
"""
补充添加豆瓣信息
"""
# 类型
if doubaninfo.get("type") == "movie":
doubaninfo['media_type'] = MediaType.MOVIE
elif doubaninfo.get("type") == "tv":
doubaninfo['media_type'] = MediaType.TV
else:
return doubaninfo
# 评分
rating = doubaninfo.get('rating')
if rating:
doubaninfo['vote_average'] = float(rating.get("value"))
else:
doubaninfo['vote_average'] = 0
# 海报
if doubaninfo.get("type") == "movie":
poster_path = doubaninfo.get('cover', {}).get("url")
if not poster_path:
poster_path = doubaninfo.get('cover_url')
if not poster_path:
poster_path = doubaninfo.get('pic', {}).get("large")
else:
poster_path = doubaninfo.get('pic', {}).get("normal")
if poster_path:
poster_path = poster_path.replace("s_ratio_poster", "m_ratio_poster")
doubaninfo['poster_path'] = poster_path
# 简介
doubaninfo['overview'] = doubaninfo.get("card_subtitle") or ""
return doubaninfo
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
"""
# 未启用豆瓣搜索时返回None
if settings.SEARCH_SOURCE != "douban":
return None
if not meta.get_name():
return []
result = self.doubanapi.search(meta.get_name())
if not result:
return []
# 返回数据
ret_medias = []
for item_obj in result.get("items"):
if meta.type and meta.type.value != item_obj.get("type_name"):
continue
if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value):
continue
ret_medias.append(MediaInfo(douban_info=item_obj.get("target")))
return ret_medias
def scrape_metadata(self, path: str, mediainfo: MediaInfo) -> None:
"""
TODO 刮削元数据
:param path: 媒体文件路径
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
if settings.SCRAP_SOURCE != "douban":
return None

Binary file not shown.

260
app/modules/douban/apiv2.py Normal file
View File

@ -0,0 +1,260 @@
# -*- coding: utf-8 -*-
import base64
import hashlib
import hmac
from datetime import datetime
from functools import lru_cache
from random import choice
from urllib import parse
import requests
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class DoubanApi(metaclass=Singleton):
_urls = {
# 搜索类
# sort=U:近期热门 T:标记最多 S:评分最高 R:最新上映
# q=search_word&start=0&count=20&sort=U
# 聚合搜索
"search": "/search/weixin",
"search_agg": "/search",
# 电影探索
# sort=U:综合排序 T:近期热度 S:高分优先 R:首播时间
# tags='日本,动画,2022'&start=0&count=20&sort=U
"movie_recommend": "/movie/recommend",
# 电视剧探索
"tv_recommend": "/tv/recommend",
# 搜索
"movie_tag": "/movie/tag",
"tv_tag": "/tv/tag",
# q=search_word&start=0&count=20
"movie_search": "/search/movie",
"tv_search": "/search/movie",
"book_search": "/search/book",
"group_search": "/search/group",
# 各类主题合集
# start=0&count=20
# 正在上映
"movie_showing": "/subject_collection/movie_showing/items",
# 热门电影
"movie_hot_gaia": "/subject_collection/movie_hot_gaia/items",
# 即将上映
"movie_soon": "/subject_collection/movie_soon/items",
# TOP250
"movie_top250": "/subject_collection/movie_top250/items",
# 高分经典科幻片榜
"movie_scifi": "/subject_collection/movie_scifi/items",
# 高分经典喜剧片榜
"movie_comedy": "/subject_collection/movie_comedy/items",
# 高分经典动作片榜
"movie_action": "/subject_collection/movie_action/items",
# 高分经典爱情片榜
"movie_love": "/subject_collection/movie_love/items",
# 热门剧集
"tv_hot": "/subject_collection/tv_hot/items",
# 国产剧
"tv_domestic": "/subject_collection/tv_domestic/items",
# 美剧
"tv_american": "/subject_collection/tv_american/items",
# 本剧
"tv_japanese": "/subject_collection/tv_japanese/items",
# 韩剧
"tv_korean": "/subject_collection/tv_korean/items",
# 动画
"tv_animation": "/subject_collection/tv_animation/items",
# 综艺
"tv_variety_show": "/subject_collection/tv_variety_show/items",
# 华语口碑周榜
"tv_chinese_best_weekly": "/subject_collection/tv_chinese_best_weekly/items",
# 全球口碑周榜
"tv_global_best_weekly": "/subject_collection/tv_global_best_weekly/items",
# 执门综艺
"show_hot": "/subject_collection/show_hot/items",
# 国内综艺
"show_domestic": "/subject_collection/show_domestic/items",
# 国外综艺
"show_foreign": "/subject_collection/show_foreign/items",
"book_bestseller": "/subject_collection/book_bestseller/items",
"book_top250": "/subject_collection/book_top250/items",
# 虚构类热门榜
"book_fiction_hot_weekly": "/subject_collection/book_fiction_hot_weekly/items",
# 非虚构类热门
"book_nonfiction_hot_weekly": "/subject_collection/book_nonfiction_hot_weekly/items",
# 音乐
"music_single": "/subject_collection/music_single/items",
# rank list
"movie_rank_list": "/movie/rank_list",
"movie_year_ranks": "/movie/year_ranks",
"book_rank_list": "/book/rank_list",
"tv_rank_list": "/tv/rank_list",
# movie info
"movie_detail": "/movie/",
"movie_rating": "/movie/%s/rating",
"movie_photos": "/movie/%s/photos",
"movie_trailers": "/movie/%s/trailers",
"movie_interests": "/movie/%s/interests",
"movie_reviews": "/movie/%s/reviews",
"movie_recommendations": "/movie/%s/recommendations",
"movie_celebrities": "/movie/%s/celebrities",
# tv info
"tv_detail": "/tv/",
"tv_rating": "/tv/%s/rating",
"tv_photos": "/tv/%s/photos",
"tv_trailers": "/tv/%s/trailers",
"tv_interests": "/tv/%s/interests",
"tv_reviews": "/tv/%s/reviews",
"tv_recommendations": "/tv/%s/recommendations",
"tv_celebrities": "/tv/%s/celebrities",
# book info
"book_detail": "/book/",
"book_rating": "/book/%s/rating",
"book_interests": "/book/%s/interests",
"book_reviews": "/book/%s/reviews",
"book_recommendations": "/book/%s/recommendations",
# music info
"music_detail": "/music/",
"music_rating": "/music/%s/rating",
"music_interests": "/music/%s/interests",
"music_reviews": "/music/%s/reviews",
"music_recommendations": "/music/%s/recommendations",
# doulist
"doulist": "/doulist/",
"doulist_items": "/doulist/%s/items",
}
_user_agents = [
"api-client/1 com.douban.frodo/7.22.0.beta9(231) Android/23 product/Mate 40 vendor/HUAWEI model/Mate 40 brand/HUAWEI rom/android network/wifi platform/AndroidPad"
"api-client/1 com.douban.frodo/7.18.0(230) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1",
"api-client/1 com.douban.frodo/7.1.0(205) Android/29 product/perseus vendor/Xiaomi model/Mi MIX 3 rom/miui6 network/wifi platform/mobile nd/1",
"api-client/1 com.douban.frodo/7.3.0(207) Android/22 product/MI 9 vendor/Xiaomi model/MI 9 brand/Android rom/miui6 network/wifi platform/mobile nd/1"]
_api_secret_key = "bf7dddc7c9cfe6f7"
_api_key = "0dad551ec0f84ed02907ff5c42e8ec70"
_base_url = "https://frodo.douban.com/api/v2"
_session = requests.Session()
def __init__(self):
pass
@classmethod
def __sign(cls, url: str, ts: int, method='GET') -> str:
url_path = parse.urlparse(url).path
raw_sign = '&'.join([method.upper(), parse.quote(url_path, safe=''), str(ts)])
return base64.b64encode(hmac.new(cls._api_secret_key.encode(), raw_sign.encode(), hashlib.sha1).digest()
).decode()
@classmethod
@lru_cache(maxsize=256)
def __invoke(cls, url, **kwargs):
req_url = cls._base_url + url
params = {'apiKey': cls._api_key}
if kwargs:
params.update(kwargs)
ts = params.pop('_ts', int(datetime.strftime(datetime.now(), '%Y%m%d')))
params.update({'os_rom': 'android', 'apiKey': cls._api_key, '_ts': ts, '_sig': cls.__sign(url=req_url, ts=ts)})
resp = RequestUtils(ua=choice(cls._user_agents), session=cls._session).get_res(url=req_url, params=params)
return resp.json() if resp else {}
def search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["search"], q=keyword, start=start, count=count, _ts=ts)
def movie_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_search"], q=keyword, start=start, count=count, _ts=ts)
def tv_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_search"], q=keyword, start=start, count=count, _ts=ts)
def book_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["book_search"], q=keyword, start=start, count=count, _ts=ts)
def group_search(self, keyword, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["group_search"], q=keyword, start=start, count=count, _ts=ts)
def movie_showing(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_showing"], start=start, count=count, _ts=ts)
def movie_soon(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_soon"], start=start, count=count, _ts=ts)
def movie_hot_gaia(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_hot_gaia"], start=start, count=count, _ts=ts)
def tv_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_hot"], start=start, count=count, _ts=ts)
def tv_animation(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_animation"], start=start, count=count, _ts=ts)
def tv_variety_show(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_variety_show"], start=start, count=count, _ts=ts)
def tv_rank_list(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_rank_list"], start=start, count=count, _ts=ts)
def show_hot(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["show_hot"], start=start, count=count, _ts=ts)
def movie_detail(self, subject_id):
return self.__invoke(self._urls["movie_detail"] + subject_id)
def movie_celebrities(self, subject_id):
return self.__invoke(self._urls["movie_celebrities"] % subject_id)
def tv_detail(self, subject_id):
return self.__invoke(self._urls["tv_detail"] + subject_id)
def tv_celebrities(self, subject_id):
return self.__invoke(self._urls["tv_celebrities"] % subject_id)
def book_detail(self, subject_id):
return self.__invoke(self._urls["book_detail"] + subject_id)
def movie_top250(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_top250"], start=start, count=count, _ts=ts)
def movie_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["movie_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
def tv_recommend(self, tags='', sort='R', start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_recommend"], tags=tags, sort=sort, start=start, count=count, _ts=ts)
def tv_chinese_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_chinese_best_weekly"], start=start, count=count, _ts=ts)
def tv_global_best_weekly(self, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
return self.__invoke(self._urls["tv_global_best_weekly"], start=start, count=count, _ts=ts)
def doulist_detail(self, subject_id):
"""
豆列详情
:param subject_id: 豆列id
"""
return self.__invoke(self._urls["doulist"] + subject_id)
def doulist_items(self, subject_id, start=0, count=20, ts=datetime.strftime(datetime.now(), '%Y%m%d')):
"""
豆列列表
:param subject_id: 豆列id
:param start: 开始
:param count: 数量
:param ts: 时间戳
"""
return self.__invoke(self._urls["doulist_items"] % subject_id, start=start, count=count, _ts=ts)

View File

@ -0,0 +1,69 @@
from typing import Optional, Tuple, Union
from app.core import MediaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.emby.emby import Emby
from app.utils.types import MediaType
class EmbyModule(_ModuleBase):
emby: Emby = None
def init_module(self) -> None:
self.emby = Emby()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "emby"
def webhook_parser(self, message: dict) -> Optional[dict]:
"""
解析Webhook报文体
:param message: 请求体
:return: 字典解析为消息时需要包含title、text、image
"""
return self.emby.get_webhook_message(message)
def media_exists(self, mediainfo: MediaInfo) -> Optional[dict]:
"""
判断媒体文件是否存在
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if mediainfo.type == MediaType.MOVIE:
movies = self.emby.get_movies(title=mediainfo.title, year=mediainfo.year)
if movies:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"媒体库中已存在:{movies}")
return {"type": MediaType.MOVIE}
else:
tvs = self.emby.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id)
if not tvs:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"{mediainfo.get_title_string()} 媒体库中已存在:{tvs}")
return {"type": MediaType.TV, "seasons": tvs}
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: str) -> Optional[bool]:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
items = [
{
"title": mediainfo.title,
"year": mediainfo.year,
"type": mediainfo.type,
"category": mediainfo.category,
"target_path": file_path
}
]
return self.emby.refresh_library_by_items(items)

Binary file not shown.

Binary file not shown.

484
app/modules/emby/emby.py Normal file
View File

@ -0,0 +1,484 @@
import re
from pathlib import Path
from typing import List, Optional, Union, Dict
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
from app.utils.types import MediaType
class Emby(metaclass=Singleton):
def __init__(self):
self._host = settings.EMBY_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: str = 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[dict]) -> bool:
"""
按类型、名称、年份来刷新媒体库
:param items: 已识别的需要刷新媒体库的媒体信息列表
"""
if not items:
return False
# 收集要刷新的媒体库信息
logger.info(f"开始刷新Emby媒体库...")
library_ids = []
for item in items:
if not item:
continue
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: dict) -> Optional[str]:
"""
根据媒体信息查询在哪个媒体库返回要刷新的位置的ID
:param item: {title, year, type, category, target_path}
"""
if not item.get("title") or not item.get("year") or not item.get("type"):
return None
if item.get("type") != MediaType.MOVIE.value:
item_id = self.__get_emby_series_id_by_name(item.get("title"), item.get("year"))
if item_id:
# 存在电视剧,则直接刷新这个电视剧就行
return item_id
else:
if self.get_movies(item.get("title"), item.get("year")):
# 已存在,不用刷新
return None
# 查找需要刷新的媒体库ID
item_path = Path(item.get("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.get("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 {}
@staticmethod
def get_webhook_message(message: dict) -> dict:
"""
解析Emby Webhook报文
"""
eventItem = {'event': message.get('Event', '')}
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')
return eventItem

View File

@ -0,0 +1,74 @@
import re
from functools import lru_cache
from typing import Optional, Tuple, Union
from app.core import MediaInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.utils.http import RequestUtils
from app.utils.types import MediaType
class FanartModule(_ModuleBase):
# 代理
_proxies: dict = settings.PROXY
# Fanart Api
_movie_url: str = f'https://webservice.fanart.tv/v3/movies/%s?api_key={settings.FANART_API_KEY}'
_tv_url: str = f'https://webservice.fanart.tv/v3/tv/%s?api_key={settings.FANART_API_KEY}'
def init_module(self) -> None:
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FANART_API_KEY", True
def obtain_image(self, mediainfo: MediaInfo) -> Optional[MediaInfo]:
"""
获取图片
:param mediainfo: 识别的媒体信息
:return: 更新后的媒体信息注意如果返回None有可能是没有对应的处理模块应无视结果
"""
if mediainfo.type == MediaType.MOVIE:
result = self.__request_fanart(mediainfo.type, mediainfo.tmdb_id)
else:
result = self.__request_fanart(mediainfo.type, mediainfo.tvdb_id)
if not result or result.get('status') == 'error':
logger.warn(f"没有获取到 {mediainfo.get_title_string()} 的Fanart图片数据")
return
for name, images in result.items():
if not images:
continue
if not isinstance(images, list):
continue
# 按欢迎程度倒排
images.sort(key=lambda x: int(x.get('likes', 0)), reverse=True)
mediainfo.set_image(self.__name(name), images[0].get('url'))
return mediainfo
@staticmethod
def __name(fanart_name: str) -> str:
"""
转换Fanart图片的名字
"""
words_to_remove = r'tv|movie|hdmovie|hdtv'
pattern = re.compile(words_to_remove, re.IGNORECASE)
result = re.sub(pattern, '', fanart_name)
return result
@classmethod
@lru_cache(maxsize=256)
def __request_fanart(cls, media_type: MediaType, queryid: str) -> Optional[dict]:
if media_type == MediaType.MOVIE:
image_url = cls._movie_url % queryid
else:
image_url = cls._tv_url % queryid
try:
ret = RequestUtils(proxies=cls._proxies, timeout=5).get_res(image_url)
if ret:
return ret.json()
except Exception as err:
logger.error(f"获取{queryid}的Fanart图片失败{err}")
return None

View File

@ -0,0 +1,22 @@
from typing import Optional, Tuple, Union
from app.core import MediaInfo
from app.modules import _ModuleBase
class FileTransferModule(_ModuleBase):
def init_module(self) -> None:
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "TRANSFER_TYPE", True
def transfer(self, path: str, mediainfo: MediaInfo) -> Optional[bool]:
"""
TODO 文件转移
:param path: 文件路径
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
pass

View File

@ -0,0 +1,20 @@
from typing import List, Tuple, Union
from app.core import TorrentInfo
from app.modules import _ModuleBase
class FilterModule(_ModuleBase):
def init_module(self) -> None:
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "FILTER_RULE", True
def filter_torrents(self, torrent_list: List[TorrentInfo]) -> List[TorrentInfo]:
"""
TODO 过滤资源
:param torrent_list: 资源列表
:return: 过滤后的资源列表注意如果返回None有可能是没有对应的处理模块应无视结果
"""
pass

View File

@ -0,0 +1,154 @@
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from typing import List, Optional, Tuple, Union
from app.core import MediaInfo, TorrentInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.indexer.spider import TorrentSpider
from app.modules.indexer.tnode import TNodeSpider
from app.modules.indexer.torrentleech import TorrentLeech
from app.utils.types import MediaType
class IndexerModule(_ModuleBase):
"""
索引模块
"""
def init_module(self) -> None:
pass
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "INDEXER", "builtin"
def search_torrents(self, mediainfo: Optional[MediaInfo], sites: List[dict],
keyword: str = None) -> Optional[List[TorrentInfo]]:
"""
搜索站点,多个站点需要多线程处理
:param mediainfo: 识别的媒体信息
:param sites: 站点列表
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
:reutrn: 资源列表
"""
# 开始计时
start_time = datetime.now()
# 多线程
executor = ThreadPoolExecutor(max_workers=len(sites))
all_task = []
for site in sites:
task = executor.submit(self.__search, mediainfo=mediainfo,
site=site, keyword=keyword)
all_task.append(task)
results = []
finish_count = 0
for future in as_completed(all_task):
finish_count += 1
result = future.result()
if result:
results += result
# 计算耗时
end_time = datetime.now()
logger.info(f"所有站点搜索完成,有效资源数:{len(results)},总耗时 {(end_time - start_time).seconds}")
# 返回
return results
def __search(self, mediainfo: MediaInfo, site: dict,
keyword: str = None) -> Optional[List[TorrentInfo]]:
"""
搜索一个站点
:param mediainfo: 识别的媒体信息
:param site: 站点
:param keyword: 搜索关键词,如有按关键词搜索,否则按媒体信息名称搜索
:return: 资源列表
"""
# 确认搜索的名字
if keyword:
search_word = keyword
elif mediainfo:
search_word = mediainfo.title
else:
search_word = None
# 开始索引
result_array = []
# 开始计时
start_time = datetime.now()
try:
if site.get('parser') == "TNodeSpider":
error_flag, result_array = TNodeSpider(site).search(keyword=search_word)
elif site.get('parser') == "TorrentLeech":
error_flag, result_array = TorrentLeech(site).search(keyword=search_word)
else:
error_flag, result_array = self.__spider_search(
keyword=search_word,
indexer=site,
mtype=mediainfo.type
)
except Exception as err:
error_flag = True
print(str(err))
# 索引花费的时间
seconds = round((datetime.now() - start_time).seconds, 1)
if error_flag:
logger.error(f"{site.get('name')} 搜索发生错误,耗时 {seconds}")
else:
logger.info(f"{site.get('name')} 搜索完成,耗时 {seconds}")
# 返回结果
if len(result_array) == 0:
logger.warn(f"{site.get('name')} 未搜索到数据")
return []
else:
logger.warn(f"{site.get('name')} 返回数据:{len(result_array)}")
# 合并站点信息以TorrentInfo返回
return [TorrentInfo(site=site.get("id"),
site_name=site.get("name"),
site_cookie=site.get("cookie"),
site_ua=site.get("ua"),
site_proxy=site.get("proxy"),
site_order=site.get("order"),
**result) for result in result_array]
@staticmethod
def __spider_search(indexer: dict,
keyword: str = None,
mtype: MediaType = None,
page: int = None, timeout: int = 30) -> (bool, List[dict]):
"""
根据关键字搜索单个站点
:param: indexer: 站点配置
:param: keyword: 关键字
:param: page: 页码
:param: mtype: 媒体类型
:param: timeout: 超时时间
:return: 是否发生错误, 种子列表
"""
_spider = TorrentSpider()
_spider.setparam(indexer=indexer,
mtype=mtype,
keyword=keyword,
page=page)
_spider.start()
# 循环判断是否获取到数据
sleep_count = 0
while not _spider.is_complete:
sleep_count += 1
time.sleep(1)
if sleep_count > timeout:
break
# 是否发生错误
result_flag = _spider.is_error
# 种子列表
result_array = _spider.torrents_info_array.copy()
# 重置状态
_spider.torrents_info_array.clear()
return result_flag, result_array
def refresh_torrents(self, sites: List[dict]) -> Optional[List[TorrentInfo]]:
"""
获取站点最新一页的种子,多个站点需要多线程处理
:param sites: 站点列表
:reutrn: 种子资源列表
"""
return self.search_torrents(mediainfo=None, sites=sites, keyword=None)

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,640 @@
import copy
import datetime
import re
from urllib.parse import quote
import feapder
from feapder.utils.tools import urlencode
from jinja2 import Template
from pyquery import PyQuery
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.utils.types import MediaType
class TorrentSpider(feapder.AirSpider):
__custom_setting__ = dict(
SPIDER_THREAD_COUNT=1,
SPIDER_MAX_RETRY_TIMES=0,
REQUEST_LOST_TIMEOUT=10,
RETRY_FAILED_REQUESTS=False,
LOG_LEVEL="ERROR",
RANDOM_HEADERS=False,
WEBDRIVER=dict(
pool_size=1,
load_images=False,
proxy=None,
headless=True,
driver_type="CHROME",
timeout=20,
window_size=(1024, 800),
executable_path=None,
render_time=10,
custom_argument=["--ignore-certificate-errors"],
)
)
# 是否搜索完成标志
is_complete: bool = False
# 是否出现错误
is_error: bool = False
# 索引器ID
indexerid: int = None
# 索引器名称
indexername: str = None
# 站点域名
domain: str = None
# 站点Cookie
cookie: str = None
# 站点UA
ua: str = None
# 代理
proxies: bool = None
# 是否渲染
render: bool = False
# Referer
referer: str = None
# 搜索关键字
keyword: str = None
# 媒体类型
mtype: MediaType = None
# 搜索路径、方式配置
search: dict = {}
# 批量搜索配置
batch: dict = {}
# 浏览配置
browse: dict = {}
# 站点分类配置
category: dict = {}
# 站点种子列表配置
list: dict = {}
# 站点种子字段配置
fields: dict = {}
# 页码
page: int = 0
# 搜索条数
result_num: int = 100
# 单个种子信息
torrents_info: dict = {}
# 种子列表
torrents_info_array: list = []
def setparam(self, indexer,
keyword: [str, list] = None,
page=None,
referer=None,
mtype: MediaType = None):
"""
设置查询参数
:param indexer: 索引器
:param keyword: 搜索关键字,如果数组则为批量搜索
:param page: 页码
:param referer: Referer
:param mtype: 媒体类型
"""
if not indexer:
return
self.keyword = keyword
self.mtype = mtype
self.indexerid = indexer.get('id')
self.indexername = indexer.get('name')
self.search = indexer.get('search')
self.batch = indexer.get('batch')
self.browse = indexer.get('browse')
self.category = indexer.get('category')
self.list = indexer.get('torrents').get('list', {})
self.fields = indexer.get('torrents').get('fields')
self.render = indexer.get('render')
self.domain = indexer.get('domain')
self.page = page
if self.domain and not str(self.domain).endswith("/"):
self.domain = self.domain + "/"
if indexer.get('ua'):
self.ua = indexer.get('ua')
else:
self.ua = settings.USER_AGENT
if indexer.get('proxy'):
self.proxies = settings.PROXY
if indexer.get('cookie'):
self.cookie = indexer.get('cookie')
if referer:
self.referer = referer
self.torrents_info_array = []
def start_requests(self):
"""
开始请求
"""
if not self.search or not self.domain:
self.is_complete = True
return
# 种子搜索相对路径
paths = self.search.get('paths', [])
torrentspath = ""
if len(paths) == 1:
torrentspath = paths[0].get('path', '')
else:
for path in paths:
if path.get("type") == "all" and not self.mtype:
torrentspath = path.get('path')
break
elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE:
torrentspath = path.get('path')
break
elif path.get("type") == "tv" and self.mtype == MediaType.TV:
torrentspath = path.get('path')
break
# 关键字搜索
if self.keyword:
if isinstance(self.keyword, list):
# 批量查询
if self.batch:
delimiter = self.batch.get('delimiter') or ' '
space_replace = self.batch.get('space_replace') or ' '
search_word = delimiter.join([str(k).replace(' ', space_replace) for k in self.keyword])
else:
search_word = " ".join(self.keyword)
# 查询模式:或
search_mode = "1"
else:
# 单个查询
search_word = self.keyword
# 查询模式与
search_mode = "0"
# 搜索URL
if self.search.get("params"):
# 变量字典
inputs_dict = {
"keyword": search_word
}
# 查询参数
params = {
"search_mode": search_mode,
"page": self.page or 0,
"notnewword": 1
}
# 额外参数
for key, value in self.search.get("params").items():
params.update({
"%s" % key: str(value).format(**inputs_dict)
})
# 分类条件
if self.category:
if self.mtype == MediaType.MOVIE:
cats = self.category.get("movie") or []
elif self.mtype:
cats = self.category.get("tv") or []
else:
cats = (self.category.get("movie") or []) + (self.category.get("tv") or [])
for cat in cats:
if self.category.get("field"):
value = params.get(self.category.get("field"), "")
params.update({
"%s" % self.category.get("field"): value + self.category.get("delimiter",
' ') + cat.get("id")
})
else:
params.update({
"cat%s" % cat.get("id"): 1
})
searchurl = self.domain + torrentspath + "?" + urlencode(params)
else:
# 变量字典
inputs_dict = {
"keyword": quote(search_word),
"page": self.page or 0
}
# 无额外参数
searchurl = self.domain + str(torrentspath).format(**inputs_dict)
# 列表浏览
else:
# 变量字典
inputs_dict = {
"page": self.page or 0,
"keyword": ""
}
# 有单独浏览路径
if self.browse:
torrentspath = self.browse.get("path")
if self.browse.get("start"):
start_page = int(self.browse.get("start")) + int(self.page or 0)
inputs_dict.update({
"page": start_page
})
elif self.page:
torrentspath = torrentspath + f"?page={self.page}"
# 搜索Url
searchurl = self.domain + str(torrentspath).format(**inputs_dict)
logger.info(f"开始请求:{searchurl}")
yield feapder.Request(url=searchurl,
use_session=True,
render=self.render)
def download_midware(self, request):
request.headers = {
"User-Agent": self.ua
}
request.cookies = RequestUtils.cookie_parse(self.cookie)
if self.proxies:
request.proxies = self.proxies
return request
def Gettitle_default(self, torrent):
# title default
if 'title' not in self.fields:
return
selector = self.fields.get('title', {})
if 'selector' in selector:
title = torrent(selector.get('selector', '')).clone()
self.__remove(title, selector)
items = self.__attribute_or_text(title, selector)
self.torrents_info['title'] = self.__index(items, selector)
elif 'text' in selector:
render_dict = {}
if "title_default" in self.fields:
title_default_selector = self.fields.get('title_default', {})
title_default_item = torrent(title_default_selector.get('selector', '')).clone()
self.__remove(title_default_item, title_default_selector)
items = self.__attribute_or_text(title_default_item, selector)
title_default = self.__index(items, title_default_selector)
render_dict.update({'title_default': title_default})
if "title_optional" in self.fields:
title_optional_selector = self.fields.get('title_optional', {})
title_optional_item = torrent(title_optional_selector.get('selector', '')).clone()
self.__remove(title_optional_item, title_optional_selector)
items = self.__attribute_or_text(title_optional_item, title_optional_selector)
title_optional = self.__index(items, title_optional_selector)
render_dict.update({'title_optional': title_optional})
self.torrents_info['title'] = Template(selector.get('text')).render(fields=render_dict)
self.torrents_info['title'] = self.__filter_text(self.torrents_info.get('title'),
selector.get('filters'))
def Gettitle_optional(self, torrent):
# title optional
if 'description' not in self.fields:
return
selector = self.fields.get('description', {})
if "selector" in selector \
or "selectors" in selector:
description = torrent(selector.get('selector', selector.get('selectors', ''))).clone()
if description:
self.__remove(description, selector)
items = self.__attribute_or_text(description, selector)
self.torrents_info['description'] = self.__index(items, selector)
elif "text" in selector:
render_dict = {}
if "tags" in self.fields:
tags_selector = self.fields.get('tags', {})
tags_item = torrent(tags_selector.get('selector', '')).clone()
self.__remove(tags_item, tags_selector)
items = self.__attribute_or_text(tags_item, tags_selector)
tag = self.__index(items, tags_selector)
render_dict.update({'tags': tag})
if "subject" in self.fields:
subject_selector = self.fields.get('subject', {})
subject_item = torrent(subject_selector.get('selector', '')).clone()
self.__remove(subject_item, subject_selector)
items = self.__attribute_or_text(subject_item, subject_selector)
subject = self.__index(items, subject_selector)
render_dict.update({'subject': subject})
if "description_free_forever" in self.fields:
description_free_forever_selector = self.fields.get("description_free_forever", {})
description_free_forever_item = torrent(description_free_forever_selector.get("selector", '')).clone()
self.__remove(description_free_forever_item, description_free_forever_selector)
items = self.__attribute_or_text(description_free_forever_item, description_free_forever_selector)
description_free_forever = self.__index(items, description_free_forever_selector)
render_dict.update({"description_free_forever": description_free_forever})
if "description_normal" in self.fields:
description_normal_selector = self.fields.get("description_normal", {})
description_normal_item = torrent(description_normal_selector.get("selector", '')).clone()
self.__remove(description_normal_item, description_normal_selector)
items = self.__attribute_or_text(description_normal_item, description_normal_selector)
description_normal = self.__index(items, description_normal_selector)
render_dict.update({"description_normal": description_normal})
self.torrents_info['description'] = Template(selector.get('text')).render(fields=render_dict)
self.torrents_info['description'] = self.__filter_text(self.torrents_info.get('description'),
selector.get('filters'))
def Getdetails(self, torrent):
# details
if 'details' not in self.fields:
return
selector = self.fields.get('details', {})
details = torrent(selector.get('selector', '')).clone()
self.__remove(details, selector)
items = self.__attribute_or_text(details, selector)
item = self.__index(items, selector)
detail_link = self.__filter_text(item, selector.get('filters'))
if detail_link:
if not detail_link.startswith("http"):
if detail_link.startswith("//"):
self.torrents_info['page_url'] = self.domain.split(":")[0] + ":" + detail_link
elif detail_link.startswith("/"):
self.torrents_info['page_url'] = self.domain + detail_link[1:]
else:
self.torrents_info['page_url'] = self.domain + detail_link
else:
self.torrents_info['page_url'] = detail_link
def Getdownload(self, torrent):
# download link
if 'download' not in self.fields:
return
selector = self.fields.get('download', {})
download = torrent(selector.get('selector', '')).clone()
self.__remove(download, selector)
items = self.__attribute_or_text(download, selector)
item = self.__index(items, selector)
download_link = self.__filter_text(item, selector.get('filters'))
if download_link:
if not download_link.startswith("http") and not download_link.startswith("magnet"):
self.torrents_info['enclosure'] = self.domain + download_link[1:] if download_link.startswith(
"/") else self.domain + download_link
else:
self.torrents_info['enclosure'] = download_link
def Getimdbid(self, torrent):
# imdbid
if "imdbid" not in self.fields:
return
selector = self.fields.get('imdbid', {})
imdbid = torrent(selector.get('selector', '')).clone()
self.__remove(imdbid, selector)
items = self.__attribute_or_text(imdbid, selector)
item = self.__index(items, selector)
self.torrents_info['imdbid'] = item
self.torrents_info['imdbid'] = self.__filter_text(self.torrents_info.get('imdbid'),
selector.get('filters'))
def Getsize(self, torrent):
# torrent size
if 'size' not in self.fields:
return
selector = self.fields.get('size', {})
size = torrent(selector.get('selector', selector.get("selectors", ''))).clone()
self.__remove(size, selector)
items = self.__attribute_or_text(size, selector)
item = self.__index(items, selector)
if item:
self.torrents_info['size'] = StringUtils.num_filesize(item.replace("\n", "").strip())
self.torrents_info['size'] = self.__filter_text(self.torrents_info.get('size'),
selector.get('filters'))
self.torrents_info['size'] = StringUtils.num_filesize(self.torrents_info.get('size'))
def Getleechers(self, torrent):
# torrent leechers
if 'leechers' not in self.fields:
return
selector = self.fields.get('leechers', {})
leechers = torrent(selector.get('selector', '')).clone()
self.__remove(leechers, selector)
items = self.__attribute_or_text(leechers, selector)
item = self.__index(items, selector)
if item:
self.torrents_info['peers'] = item.split("/")[0]
self.torrents_info['peers'] = self.__filter_text(self.torrents_info.get('peers'),
selector.get('filters'))
else:
self.torrents_info['peers'] = 0
def Getseeders(self, torrent):
# torrent leechers
if 'seeders' not in self.fields:
return
selector = self.fields.get('seeders', {})
seeders = torrent(selector.get('selector', '')).clone()
self.__remove(seeders, selector)
items = self.__attribute_or_text(seeders, selector)
item = self.__index(items, selector)
if item:
self.torrents_info['seeders'] = item.split("/")[0]
self.torrents_info['seeders'] = self.__filter_text(self.torrents_info.get('seeders'),
selector.get('filters'))
else:
self.torrents_info['seeders'] = 0
def Getgrabs(self, torrent):
# torrent grabs
if 'grabs' not in self.fields:
return
selector = self.fields.get('grabs', {})
grabs = torrent(selector.get('selector', '')).clone()
self.__remove(grabs, selector)
items = self.__attribute_or_text(grabs, selector)
item = self.__index(items, selector)
if item:
self.torrents_info['grabs'] = item.split("/")[0]
self.torrents_info['grabs'] = self.__filter_text(self.torrents_info.get('grabs'),
selector.get('filters'))
else:
self.torrents_info['grabs'] = 0
def Getpubdate(self, torrent):
# torrent pubdate
if 'date_added' not in self.fields:
return
selector = self.fields.get('date_added', {})
pubdate = torrent(selector.get('selector', '')).clone()
self.__remove(pubdate, selector)
items = self.__attribute_or_text(pubdate, selector)
self.torrents_info['pubdate'] = self.__index(items, selector)
self.torrents_info['pubdate'] = self.__filter_text(self.torrents_info.get('pubdate'),
selector.get('filters'))
def Getelapsed_date(self, torrent):
# torrent pubdate
if 'date_elapsed' not in self.fields:
return
selector = self.fields.get('date_elapsed', {})
date_elapsed = torrent(selector.get('selector', '')).clone()
self.__remove(date_elapsed, selector)
items = self.__attribute_or_text(date_elapsed, selector)
self.torrents_info['date_elapsed'] = self.__index(items, selector)
self.torrents_info['date_elapsed'] = self.__filter_text(self.torrents_info.get('date_elapsed'),
selector.get('filters'))
def Getdownloadvolumefactor(self, torrent):
# downloadvolumefactor
selector = self.fields.get('downloadvolumefactor', {})
if not selector:
return
self.torrents_info['downloadvolumefactor'] = 1
if 'case' in selector:
for downloadvolumefactorselector in list(selector.get('case', {}).keys()):
downloadvolumefactor = torrent(downloadvolumefactorselector)
if len(downloadvolumefactor) > 0:
self.torrents_info['downloadvolumefactor'] = selector.get('case', {}).get(
downloadvolumefactorselector)
break
elif "selector" in selector:
downloadvolume = torrent(selector.get('selector', '')).clone()
self.__remove(downloadvolume, selector)
items = self.__attribute_or_text(downloadvolume, selector)
item = self.__index(items, selector)
if item:
downloadvolumefactor = re.search(r'(\d+\.?\d*)', item)
if downloadvolumefactor:
self.torrents_info['downloadvolumefactor'] = int(downloadvolumefactor.group(1))
def Getuploadvolumefactor(self, torrent):
# uploadvolumefactor
selector = self.fields.get('uploadvolumefactor', {})
if not selector:
return
self.torrents_info['uploadvolumefactor'] = 1
if 'case' in selector:
for uploadvolumefactorselector in list(selector.get('case', {}).keys()):
uploadvolumefactor = torrent(uploadvolumefactorselector)
if len(uploadvolumefactor) > 0:
self.torrents_info['uploadvolumefactor'] = selector.get('case', {}).get(
uploadvolumefactorselector)
break
elif "selector" in selector:
uploadvolume = torrent(selector.get('selector', '')).clone()
self.__remove(uploadvolume, selector)
items = self.__attribute_or_text(uploadvolume, selector)
item = self.__index(items, selector)
if item:
uploadvolumefactor = re.search(r'(\d+\.?\d*)', item)
if uploadvolumefactor:
self.torrents_info['uploadvolumefactor'] = int(uploadvolumefactor.group(1))
def Getlabels(self, torrent):
# labels
if 'labels' not in self.fields:
return
selector = self.fields.get('labels', {})
labels = torrent(selector.get("selector", "")).clone()
self.__remove(labels, selector)
items = self.__attribute_or_text(labels, selector)
if items:
self.torrents_info['labels'] = items
def Getinfo(self, torrent):
"""
解析单条种子数据
"""
self.torrents_info = {'indexer': self.indexerid}
try:
self.Gettitle_default(torrent)
self.Gettitle_optional(torrent)
self.Getdetails(torrent)
self.Getdownload(torrent)
self.Getgrabs(torrent)
self.Getleechers(torrent)
self.Getseeders(torrent)
self.Getsize(torrent)
self.Getimdbid(torrent)
self.Getdownloadvolumefactor(torrent)
self.Getuploadvolumefactor(torrent)
self.Getpubdate(torrent)
self.Getelapsed_date(torrent)
self.Getlabels(torrent)
except Exception as err:
logger.error("%s 搜索出现错误:%s" % (self.indexername, str(err)))
return self.torrents_info
@staticmethod
def __filter_text(text, filters):
"""
对文件进行处理
"""
if not text or not filters or not isinstance(filters, list):
return text
if not isinstance(text, str):
text = str(text)
for filter_item in filters:
if not text:
break
try:
method_name = filter_item.get("name")
args = filter_item.get("args")
if method_name == "re_search" and isinstance(args, list):
text = re.search(r"%s" % args[0], text).group(args[-1])
elif method_name == "split" and isinstance(args, list):
text = text.split(r"%s" % args[0])[args[-1]]
elif method_name == "replace" and isinstance(args, list):
text = text.replace(r"%s" % args[0], r"%s" % args[-1])
elif method_name == "dateparse" and isinstance(args, str):
text = datetime.datetime.strptime(text, r"%s" % args)
elif method_name == "strip":
text = text.strip()
elif method_name == "appendleft":
text = f"{args}{text}"
except Exception as err:
print(str(err))
return text.strip()
@staticmethod
def __remove(item, selector):
"""
移除元素
"""
if selector and "remove" in selector:
removelist = selector.get('remove', '').split(', ')
for v in removelist:
item.remove(v)
@staticmethod
def __attribute_or_text(item, selector):
if not selector:
return item
if not item:
return []
if 'attribute' in selector:
items = [i.attr(selector.get('attribute')) for i in item.items() if i]
else:
items = [i.text() for i in item.items() if i]
return items
@staticmethod
def __index(items, selector):
if not selector:
return items
if not items:
return items
if "contents" in selector \
and len(items) > int(selector.get("contents")):
items = items[0].split("\n")[selector.get("contents")]
elif "index" in selector \
and len(items) > int(selector.get("index")):
items = items[int(selector.get("index"))]
elif isinstance(items, list):
items = items[0]
return items
def parse(self, request, response):
"""
解析整个页面
"""
try:
# 获取站点文本
html_text = response.extract()
if not html_text:
self.is_error = True
self.is_complete = True
return
# 解析站点文本对象
html_doc = PyQuery(html_text)
# 种子筛选器
torrents_selector = self.list.get('selector', '')
# 遍历种子html列表
for torn in html_doc(torrents_selector):
self.torrents_info_array.append(copy.deepcopy(self.Getinfo(PyQuery(torn))))
if len(self.torrents_info_array) >= int(self.result_num):
break
except Exception as err:
self.is_error = True
logger.warn(f"错误:{self.indexername} {err}")
finally:
self.is_complete = True

View File

@ -0,0 +1,105 @@
import re
from typing import Tuple, List
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class TNodeSpider:
_indexerid = None
_domain = None
_name = ""
_proxy = None
_cookie = None
_ua = None
_token = None
_size = 100
_searchurl = "%sapi/torrent/advancedSearch"
_downloadurl = "%sapi/torrent/download/%s"
_pageurl = "%storrent/info/%s"
def __init__(self, indexer: dict):
if indexer:
self._indexerid = indexer.get('id')
self._domain = indexer.get('domain')
self._searchurl = self._searchurl % self._domain
self._name = indexer.get('name')
if indexer.get('proxy'):
self._proxy = settings.PROXY
self._cookie = indexer.get('cookie')
self._ua = indexer.get('ua')
self.init_config()
def init_config(self):
self.__get_token()
def __get_token(self):
if not self._domain:
return
res = RequestUtils(ua=self._ua,
cookies=self._cookie,
proxies=self._proxy,
timeout=15).get_res(url=self._domain)
if res and res.status_code == 200:
csrf_token = re.search(r'<meta name="x-csrf-token" content="(.+?)">', res.text)
if csrf_token:
self._token = csrf_token.group(1)
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
if not self._token:
logger.warn(f"{self._name} 未获取到token无法搜索")
return True, []
params = {
"page": int(page) + 1,
"size": self._size,
"type": "title",
"keyword": keyword or "",
"sorter": "id",
"order": "desc",
"tags": [],
"category": [501, 502, 503, 504],
"medium": [],
"videoCoding": [],
"audioCoding": [],
"resolution": [],
"group": []
}
res = RequestUtils(
headers={
'X-CSRF-TOKEN': self._token,
"Content-Type": "application/json; charset=utf-8",
"User-Agent": f"{self._ua}"
},
cookies=self._cookie,
proxies=self._proxy,
timeout=30
).post_res(url=self._searchurl, json=params)
torrents = []
if res and res.status_code == 200:
results = res.json().get('data', {}).get("torrents") or []
for result in results:
torrent = {
'indexer': self._indexerid,
'title': result.get('title'),
'description': result.get('subtitle'),
'enclosure': self._downloadurl % (self._domain, result.get('id')),
'pubdate': StringUtils.timestamp_to_date(result.get('upload_time')),
'size': result.get('size'),
'seeders': result.get('seeding'),
'peers': result.get('leeching'),
'grabs': result.get('complete'),
'downloadvolumefactor': result.get('downloadRate'),
'uploadvolumefactor': result.get('uploadRate'),
'page_url': self._pageurl % (self._domain, result.get('id')),
'imdbid': result.get('imdb')
}
torrents.append(torrent)
elif res is not None:
logger.warn(f"{self._name} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"{self._name} 搜索失败,无法连接 {self._domain}")
return True, []
return False, torrents

View File

@ -0,0 +1,64 @@
from typing import List, Tuple
from urllib.parse import quote
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
class TorrentLeech:
_indexer = None
_proxy = None
_size = 100
_searchurl = "%storrents/browse/list/query/%s"
_browseurl = "%storrents/browse/list/page/2%s"
_downloadurl = "%sdownload/%s/%s"
_pageurl = "%storrent/%s"
def __init__(self, indexer: dict):
self._indexer = indexer
if indexer.get('proxy'):
self._proxy = settings.PROXY
def search(self, keyword: str, page: int = 0) -> Tuple[bool, List[dict]]:
if keyword:
url = self._searchurl % (self._indexer.get('domain'), quote(keyword))
else:
url = self._browseurl % (self._indexer.get('domain'), int(page) + 1)
res = RequestUtils(
headers={
"Content-Type": "application/json; charset=utf-8",
"User-Agent": f"{self._indexer.get('ua')}",
},
cookies=self._indexer.get('cookie'),
proxies=self._proxy,
timeout=30
).get_res(url)
torrents = []
if res and res.status_code == 200:
results = res.json().get('torrentList') or []
for result in results:
torrent = {
'indexer': self._indexer.get('id'),
'title': result.get('name'),
'enclosure': self._downloadurl % (self._indexer.get('domain'), result.get('fid'), result.get('filename')),
'pubdate': StringUtils.timestamp_to_date(result.get('addedTimestamp')),
'size': result.get('size'),
'seeders': result.get('seeders'),
'peers': result.get('leechers'),
'grabs': result.get('completed'),
'downloadvolumefactor': result.get('download_multiplier'),
'uploadvolumefactor': 1,
'page_url': self._pageurl % (self._indexer.get('domain'), result.get('fid')),
'imdbid': result.get('imdbID')
}
torrents.append(torrent)
elif res is not None:
logger.warn(f"【INDEXER】{self._indexer.get('name')} 搜索失败,错误码:{res.status_code}")
return True, []
else:
logger.warn(f"【INDEXER】{self._indexer.get('name')} 搜索失败,无法连接 {self._indexer.get('domain')}")
return True, []
return False, torrents

View File

@ -0,0 +1,68 @@
from typing import Optional, Tuple, Union
from app.core import MediaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.jellyfin.jellyfin import Jellyfin
from app.utils.types import MediaType
class JellyfinModule(_ModuleBase):
jellyfin: Jellyfin = None
def init_module(self) -> None:
self.jellyfin = Jellyfin()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "jellyfin"
def webhook_parser(self, message: dict) -> Optional[dict]:
"""
解析Webhook报文体
:param message: 请求体
:return: 字典解析为消息时需要包含title、text、image
"""
return self.jellyfin.get_webhook_message(message)
def media_exists(self, mediainfo: MediaInfo) -> Optional[dict]:
"""
判断媒体文件是否存在
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if mediainfo.type == MediaType.MOVIE:
movies = self.jellyfin.get_movies(title=mediainfo.title, year=mediainfo.year)
if movies:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"媒体库中已存在:{movies}")
return {"type": MediaType.MOVIE}
else:
tvs = self.jellyfin.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year,
tmdb_id=mediainfo.tmdb_id)
if not tvs:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"{mediainfo.get_title_string()} 媒体库中已存在:{tvs}")
return {"type": MediaType.TV, "seasons": tvs}
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: str) -> Optional[bool]:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
items = [
{
"title": mediainfo.title,
"year": mediainfo.year,
"type": mediainfo.type,
"category": mediainfo.category,
"target_path": file_path
}
]
return self.jellyfin.refresh_library_by_items(items)

View File

@ -0,0 +1,337 @@
import re
from typing import List, Union, Optional, Dict
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
from app.utils.string import StringUtils
class Jellyfin(metaclass=Singleton):
def __init__(self):
self._host = settings.JELLYFIN_HOST
self._apikey = settings.JELLYFIN_API_KEY
self._user = self.get_user()
self._serverid = self.get_server_id()
def get_jellyfin_librarys(self) -> List[dict]:
"""
获取Jellyfin媒体库的信息
"""
if not self._host or not self._apikey:
return []
req_url = f"{self._host}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"Users/Views 未获取到返回数据")
return []
except Exception as e:
logger.error(f"连接Users/Views 出错:" + str(e))
return []
def get_user_count(self) -> int:
"""
获得用户数量
"""
if not self._host or not self._apikey:
return 0
req_url = "%sUsers?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().get_res(req_url)
if res:
return len(res.json())
else:
logger.error(f"Users 未获取到返回数据")
return 0
except Exception as e:
logger.error(f"连接Users出错" + str(e))
return 0
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_activity_log(self, num: int = 30) -> List[dict]:
"""
获取Jellyfin活动记录
"""
if not self._host or not self._apikey:
return []
req_url = "%sSystem/ActivityLog/Entries?api_key=%s&Limit=%s" % (self._host, self._apikey, num)
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") == "SessionStarted":
event_type = "LG"
event_date = re.sub(r'\dZ', 'Z', item.get("Date"))
event_str = "%s, %s" % (item.get("Name"), item.get("ShortOverview"))
activity = {"type": event_type, "event": event_str,
"date": StringUtils.get_time(event_date)}
ret_array.append(activity)
if item.get("Type") in ["VideoPlayback", "VideoPlaybackStopped"]:
event_type = "PL"
event_date = re.sub(r'\dZ', 'Z', item.get("Date"))
activity = {"type": event_type, "event": item.get("Name"),
"date": StringUtils.get_time(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
def get_medias_count(self) -> Optional[dict]:
"""
获得电影、电视剧、动漫媒体数量
:return: MovieCount SeriesCount SongCount
"""
if not self._host or not self._apikey:
return None
req_url = "%sItems/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_jellyfin_series_id_by_name(self, name: str, year: str) -> Optional[str]:
"""
根据名称查询Jellyfin中剧集的SeriesId
"""
if not self._host or not self._apikey or not self._user:
return None
req_url = "%sUsers/%s/Items?api_key=%s&searchTerm=%s&IncludeItemTypes=Series&Limit=10&Recursive=true" % (
self._host, self._user, self._apikey, name)
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]]:
"""
根据标题和年份检查电影是否在Jellyfin中存在存在则返回列表
:param title: 标题
:param year: 年份,为空则不过滤
:return: 含title、year属性的字典列表
"""
if not self._host or not self._apikey or not self._user:
return None
req_url = "%sUsers/%s/Items?api_key=%s&searchTerm=%s&IncludeItemTypes=Movie&Limit=10&Recursive=true" % (
self._host, self._user, self._apikey, title)
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: str = None,
season: int = None) -> Optional[Dict[str, list]]:
"""
根据标题和年份和季返回Jellyfin中的剧集列表
:param title: 标题
:param year: 年份
:param tmdb_id: TMDBID
:param season: 季
:return: 集号的列表
"""
if not self._host or not self._apikey or not self._user:
return None
# 查TVID
item_id = self.__get_jellyfin_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 {}
if not season:
season = ""
try:
req_url = "%sShows/%s/Episodes?season=%s&&userId=%s&isMissing=false&api_key=%s" % (
self._host, item_id, season, self._user, 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 not season_episodes.get(season_index):
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从Jellyfin查询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 = "%sItems/%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_root_library(self) -> bool:
"""
通知Jellyfin刷新整个媒体库
"""
if not self._host or not self._apikey:
return False
req_url = "%sLibrary/Refresh?api_key=%s" % (self._host, self._apikey)
try:
res = RequestUtils().post_res(req_url)
if res:
return True
else:
logger.info(f"刷新媒体库失败无法连接Jellyfin")
except Exception as e:
logger.error(f"连接Library/Refresh出错" + str(e))
return False
def refresh_library_by_items(self, items: List[dict]) -> bool:
"""
按类型、名称、年份来刷新媒体库Jellyfin没有刷单个项目的API这里直接刷新整个库
:param items: 已识别的需要刷新媒体库的媒体信息列表
"""
# 没找到单项目刷新的对应的API先按全库刷新
if not items:
return False
if not self._host or not self._apikey:
return False
return self.refresh_root_library()
def get_iteminfo(self, itemid: str) -> dict:
"""
获取单个项目详情
"""
if not itemid:
return {}
if not self._host or not self._apikey:
return {}
req_url = "%sUsers/%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"连接Users/Items出错" + str(e))
return {}
@staticmethod
def get_webhook_message(message: dict) -> dict:
"""
解析Jellyfin报文
"""
eventItem = {'event': message.get('NotificationType', ''),
'item_name': message.get('Name'),
'user_name': message.get('NotificationUsername')
}
return eventItem

View File

@ -0,0 +1,68 @@
from typing import Optional, Tuple, Union
from app.core import MediaInfo
from app.log import logger
from app.modules import _ModuleBase
from app.modules.plex.plex import Plex
from app.utils.types import MediaType
class PlexModule(_ModuleBase):
plex: Plex = None
def init_module(self) -> None:
self.plex = Plex()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MEDIASERVER", "plex"
def webhook_parser(self, message: dict) -> Optional[dict]:
"""
解析Webhook报文体
:param message: 请求体
:return: 字典解析为消息时需要包含title、text、image
"""
return self.plex.get_webhook_message(message)
def media_exists(self, mediainfo: MediaInfo) -> Optional[dict]:
"""
判断媒体文件是否存在
:param mediainfo: 识别的媒体信息
:return: 如不存在返回None存在时返回信息包括每季已存在所有集{type: movie/tv, seasons: {season: [episodes]}}
"""
if mediainfo.type == MediaType.MOVIE:
movies = self.plex.get_movies(title=mediainfo.title, year=mediainfo.year)
if movies:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"媒体库中已存在:{movies}")
return {"type": MediaType.MOVIE}
else:
tvs = self.plex.get_tv_episodes(title=mediainfo.title,
year=mediainfo.year)
if not tvs:
logger.info(f"{mediainfo.get_title_string()} 在媒体库中不存在")
return None
else:
logger.info(f"{mediainfo.get_title_string()} 媒体库中已存在:{tvs}")
return {"type": MediaType.TV, "seasons": tvs}
def refresh_mediaserver(self, mediainfo: MediaInfo, file_path: str) -> Optional[bool]:
"""
刷新媒体库
:param mediainfo: 识别的媒体信息
:param file_path: 文件路径
:return: 成功或失败
"""
items = [
{
"title": mediainfo.title,
"year": mediainfo.year,
"type": mediainfo.type,
"category": mediainfo.category,
"target_path": file_path
}
]
return self.plex.refresh_library_by_items(items)

Binary file not shown.

Binary file not shown.

293
app/modules/plex/plex.py Normal file
View File

@ -0,0 +1,293 @@
from pathlib import Path
from typing import List, Optional, Dict, Tuple
from urllib.parse import quote_plus
from plexapi import media
from plexapi.server import PlexServer
from app.core import settings
from app.log import logger
from app.utils.singleton import Singleton
class Plex(metaclass=Singleton):
def __init__(self):
self._host = settings.PLEX_HOST
self._token = settings.PLEX_TOKEN
if self._host and self._token:
try:
self._plex = PlexServer(self._host, self._token)
self._libraries = self._plex.library.sections()
except Exception as e:
self._plex = None
logger.error(f"Plex服务器连接失败{str(e)}")
def get_activity_log(self, num: int = 30) -> Optional[List[dict]]:
"""
获取Plex活动记录
"""
if not self._plex:
return []
ret_array = []
try:
# type的含义: 1 电影 4 剧集单集 详见 plexapi/utils.py中SEARCHTYPES的定义
# 根据最后播放时间倒序获取数据
historys = self._plex.library.search(sort='lastViewedAt:desc', limit=num, type='1,4')
for his in historys:
# 过滤掉最后播放时间为空的
if his.lastViewedAt:
if his.type == "episode":
event_title = "%s %s%s %s" % (
his.grandparentTitle,
"S" + str(his.parentIndex),
"E" + str(his.index),
his.title
)
event_str = "开始播放剧集 %s" % event_title
else:
event_title = "%s %s" % (
his.title, "(" + str(his.year) + ")")
event_str = "开始播放电影 %s" % event_title
event_type = "PL"
event_date = his.lastViewedAt.strftime('%Y-%m-%d %H:%M:%S')
activity = {"type": event_type, "event": event_str, "date": event_date}
ret_array.append(activity)
except Exception as e:
logger.error(f"连接System/ActivityLog/Entries出错" + str(e))
return []
if ret_array:
ret_array = sorted(ret_array, key=lambda x: x['date'], reverse=True)
return ret_array
def get_medias_count(self) -> dict:
"""
获得电影、电视剧、动漫媒体数量
:return: MovieCount SeriesCount SongCount
"""
if not self._plex:
return {}
sections = self._plex.library.sections()
MovieCount = SeriesCount = SongCount = EpisodeCount = 0
for sec in sections:
if sec.type == "movie":
MovieCount += sec.totalSize
if sec.type == "show":
SeriesCount += sec.totalSize
EpisodeCount += sec.totalViewSize(libtype='episode')
if sec.type == "artist":
SongCount += sec.totalSize
return {
"MovieCount": MovieCount,
"SeriesCount": SeriesCount,
"SongCount": SongCount,
"EpisodeCount": EpisodeCount
}
def get_movies(self, title: str, year: str = None) -> Optional[List[dict]]:
"""
根据标题和年份检查电影是否在Plex中存在存在则返回列表
:param title: 标题
:param year: 年份,为空则不过滤
:return: 含title、year属性的字典列表
"""
if not self._plex:
return None
ret_movies = []
if year:
movies = self._plex.library.search(title=title, year=year, libtype="movie")
else:
movies = self._plex.library.search(title=title, libtype="movie")
for movie in movies:
ret_movies.append({'title': movie.title, 'year': movie.year})
return ret_movies
def get_tv_episodes(self,
title: str = None,
year: str = None,
season: int = None) -> Optional[Dict[str, list]]:
"""
根据标题、年份、季查询电视剧所有集信息
:param title: 标题
:param year: 年份,可以为空,为空时不按年份过滤
:param season: 季号,数字
:return: 所有集的列表
"""
if not self._plex:
return {}
videos = self._plex.library.search(title=title, year=year, libtype="show")
if not videos:
return {}
episodes = videos[0].episodes()
season_episodes = {}
for episode in episodes:
if season and episode.seasonNumber != int(season):
continue
if episode.seasonNumber not in season_episodes:
season_episodes[episode.seasonNumber] = []
season_episodes[episode.seasonNumber].append(episode.index)
return season_episodes
def get_remote_image_by_id(self, item_id: str, image_type: str) -> Optional[str]:
"""
根据ItemId从Plex查询图片地址
:param item_id: 在Emby中的ID
:param image_type: 图片的类型Poster或者Backdrop等
:return: 图片对应在TMDB中的URL
"""
if not self._plex:
return None
try:
if image_type == "Poster":
images = self._plex.fetchItems('/library/metadata/%s/posters' % item_id, cls=media.Poster)
else:
images = self._plex.fetchItems('/library/metadata/%s/arts' % item_id, cls=media.Art)
for image in images:
if hasattr(image, 'key') and image.key.startswith('http'):
return image.key
except Exception as e:
logger.error(f"获取封面出错:" + str(e))
return None
def refresh_root_library(self) -> bool:
"""
通知Plex刷新整个媒体库
"""
if not self._plex:
return False
return self._plex.library.update()
def refresh_library_by_items(self, items: List[dict]) -> bool:
"""
按路径刷新媒体库 item: target_path
"""
if not self._plex:
return False
result_dict = {}
for item in items:
file_path = item.get("target_path")
lib_key, path = self.__find_librarie(file_path, self._libraries)
# 如果存在同一剧集的多集,key(path)相同会合并
result_dict[path] = lib_key
if "" in result_dict:
# 如果有匹配失败的,刷新整个库
self._plex.library.update()
else:
# 否则一个一个刷新
for path, lib_key in result_dict.items():
logger.info(f"刷新媒体库:{lib_key} - {path}")
self._plex.query(f'/library/sections/{lib_key}/refresh?path={quote_plus(path)}')
@staticmethod
def __find_librarie(path: str, libraries: List[dict]) -> Tuple[str, str]:
"""
判断这个path属于哪个媒体库
多个媒体库配置的目录不应有重复和嵌套,
"""
def is_subpath(_path: str, _parent: str) -> bool:
"""
判断_path是否是_parent的子目录下
"""
_path = Path(_path).resolve()
_parent = Path(_parent).resolve()
return _path.parts[:len(_parent.parts)] == _parent.parts
if path is None:
return "", ""
try:
for lib in libraries:
if hasattr(lib, "locations") and lib.locations:
for location in lib.locations:
if is_subpath(path, location):
return lib.key, location
except Exception as err:
logger.error(f"查找媒体库出错:{err}")
return "", ""
def get_iteminfo(self, itemid: str) -> dict:
"""
获取单个项目详情
"""
if not self._plex:
return {}
try:
item = self._plex.fetchItem(itemid)
ids = self.__get_ids(item.guids)
return {'ProviderIds': {'Tmdb': ids['tmdb_id'], 'Imdb': ids['imdb_id']}}
except Exception as err:
logger.error(f"获取项目详情出错:{err}")
return {}
@staticmethod
def __get_ids(guids: List[dict]) -> dict:
guid_mapping = {
"imdb://": "imdb_id",
"tmdb://": "tmdb_id",
"tvdb://": "tvdb_id"
}
ids = {}
for prefix, varname in guid_mapping.items():
ids[varname] = None
for guid in guids:
for prefix, varname in guid_mapping.items():
if isinstance(guid, dict):
if guid['id'].startswith(prefix):
# 找到匹配的ID
ids[varname] = guid['id'][len(prefix):]
break
else:
if guid.id.startswith(prefix):
# 找到匹配的ID
ids[varname] = guid.id[len(prefix):]
break
return ids
@staticmethod
def get_webhook_message(message: dict) -> dict:
"""
解析Plex报文
eventItem 字段的含义
event 事件类型
item_type 媒体类型 TV,MOV
item_name TV:琅琊榜 S1E6 剖心明志 虎口脱险
MOV:猪猪侠大冒险(2001)
overview 剧情描述
"""
eventItem = {'event': message.get('event', '')}
if message.get('Metadata'):
if message.get('Metadata', {}).get('type') == 'episode':
eventItem['item_type'] = "TV"
eventItem['item_name'] = "%s %s%s %s" % (
message.get('Metadata', {}).get('grandparentTitle'),
"S" + str(message.get('Metadata', {}).get('parentIndex')),
"E" + str(message.get('Metadata', {}).get('index')),
message.get('Metadata', {}).get('title'))
eventItem['item_id'] = message.get('Metadata', {}).get('ratingKey')
eventItem['season_id'] = message.get('Metadata', {}).get('parentIndex')
eventItem['episode_id'] = message.get('Metadata', {}).get('index')
if message.get('Metadata', {}).get('summary') and len(message.get('Metadata', {}).get('summary')) > 100:
eventItem['overview'] = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
else:
eventItem['overview'] = message.get('Metadata', {}).get('summary')
else:
eventItem['item_type'] = "MOV" if message.get('Metadata', {}).get('type') == 'movie' else "SHOW"
eventItem['item_name'] = "%s %s" % (
message.get('Metadata', {}).get('title'), "(" + str(message.get('Metadata', {}).get('year')) + ")")
eventItem['item_id'] = message.get('Metadata', {}).get('ratingKey')
if len(message.get('Metadata', {}).get('summary')) > 100:
eventItem['overview'] = str(message.get('Metadata', {}).get('summary'))[:100] + "..."
else:
eventItem['overview'] = message.get('Metadata', {}).get('summary')
if message.get('Player'):
eventItem['ip'] = message.get('Player').get('publicAddress')
eventItem['client'] = message.get('Player').get('title')
# 这里给个空,防止拼消息的时候出现None
eventItem['device_name'] = ' '
if message.get('Account'):
eventItem['user_name'] = message.get("Account").get('title')
return eventItem

View File

@ -0,0 +1,75 @@
from pathlib import Path
from typing import Set, Tuple, Optional, Union
from app.core import settings, MetaInfo
from app.modules import _ModuleBase
from app.modules.qbittorrent.qbittorrent import Qbittorrent
from app.utils.string import StringUtils
class QbittorrentModule(_ModuleBase):
qbittorrent: Qbittorrent = None
def init_module(self) -> None:
self.qbittorrent = Qbittorrent()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "DOWNLOADER", "qbittorrent"
def download(self, torrent_path: Path, cookie: str,
episodes: Set[int] = None) -> Optional[Tuple[Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param torrent_path: 种子文件地址
:param cookie: cookie
:param episodes: 需要下载的集数
:return: 种子Hash
"""
if not torrent_path.exists():
return None, f"种子文件不存在:{torrent_path}"
# 生成随机Tag
tag = StringUtils.generate_random_str(10)
# 如果要选择文件则先暂停
is_paused = True if episodes else False
# 添加任务
state = self.qbittorrent.add_torrent(content=torrent_path.read_bytes(),
download_dir=settings.DOWNLOAD_PATH,
is_paused=is_paused,
tag=tag,
cookie=cookie)
if not state:
return None, f"添加种子任务失败:{torrent_path}"
else:
# 获取种子Hash
torrent_hash = self.qbittorrent.get_torrent_id_by_tag(tag=tag)
if not torrent_hash:
return None, f"获取种子Hash失败{torrent_path}"
else:
if is_paused:
# 种子文件
torrent_files = self.qbittorrent.get_files(torrent_hash)
if not torrent_files:
return torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
# 不需要的文件ID
file_ids = []
# 需要的集清单
sucess_epidised = []
for torrent_file in torrent_files:
file_id = torrent_file.get("id")
file_name = torrent_file.get("name")
meta_info = MetaInfo(file_name)
if not meta_info.get_episode_list() \
or not set(meta_info.get_episode_list()).issubset(episodes):
file_ids.append(file_id)
else:
sucess_epidised = list(set(sucess_epidised).union(set(meta_info.get_episode_list())))
if sucess_epidised and file_ids:
# 选择文件
self.qbittorrent.set_files(torrent_hash=torrent_hash, file_ids=file_ids, priority=0)
# 开始任务
self.qbittorrent.start_torrents(torrent_hash)
return torrent_hash, f"添加下载成功,已选择集数:{sucess_epidised}"
else:
return torrent_hash, "添加下载成功"

View File

@ -0,0 +1,325 @@
import time
from pathlib import Path
from typing import Optional, Union, Tuple
import qbittorrentapi
from qbittorrentapi import TorrentFilesList
from qbittorrentapi.client import Client
from app.core import settings
from app.log import logger
from app.utils.singleton import Singleton
class Qbittorrent(metaclass=Singleton):
_host: str = None
_port: int = None
_username: str = None
_passowrd: str = None
def __init__(self):
host = settings.QB_HOST
if host and host.find(":") != -1:
self._host = settings.QB_HOST.split(":")[0]
self._port = settings.QB_HOST.split(":")[1]
self._username = settings.QB_USER
self._password = settings.QB_PASSWORD
if self._host and self._port and self._username and self._password:
self.qbc = self.__login_qbittorrent()
def __login_qbittorrent(self) -> Optional[Client]:
"""
连接qbittorrent
:return: qbittorrent对象
"""
try:
# 登录
qbt = qbittorrentapi.Client(host=self._host,
port=self._port,
username=self._username,
password=self._password,
VERIFY_WEBUI_CERTIFICATE=False,
REQUESTS_ARGS={'timeout': (15, 60)})
try:
qbt.auth_log_in()
except qbittorrentapi.LoginFailed as e:
print(str(e))
return qbt
except Exception as err:
logger.error(f"qbittorrent 连接出错:{err}")
return None
def get_torrents(self, ids: Union[str, list] = None,
status: Union[str, list] = None, tag: Union[str, list] = None) -> Tuple[list, bool]:
"""
获取种子列表
return: 种子列表, 是否发生异常
"""
if not self.qbc:
return [], True
try:
torrents = self.qbc.torrents_info(torrent_hashes=ids,
status_filter=status)
if tag:
results = []
if not isinstance(tag, list):
tag = [tag]
for torrent in torrents:
include_flag = True
for t in tag:
if t and t not in torrent.get("tags"):
include_flag = False
break
if include_flag:
results.append(torrent)
return results or [], False
return torrents or [], False
except Exception as err:
logger.error(f"获取种子列表出错:{err}")
return [], True
def get_completed_torrents(self, ids: Union[str, list] = None,
tag: Union[str, list] = None) -> Optional[list]:
"""
获取已完成的种子
return: 种子列表, 如发生异常则返回None
"""
if not self.qbc:
return None
torrents, error = self.get_torrents(status=["completed"], ids=ids, tag=tag)
return None if error else torrents or []
def get_downloading_torrents(self, ids: Union[str, list] = None,
tag: Union[str, list] = None) -> Optional[list]:
"""
获取正在下载的种子
return: 种子列表, 如发生异常则返回None
"""
if not self.qbc:
return None
torrents, error = self.get_torrents(ids=ids,
status=["downloading"],
tag=tag)
return None if error else torrents or []
def remove_torrents_tag(self, ids: Union[str, list], tag: Union[str, list]) -> bool:
"""
移除种子Tag
:param ids: 种子Hash列表
:param tag: 标签内容
"""
try:
self.qbc.torrents_delete_tags(torrent_hashes=ids, tags=tag)
return True
except Exception as err:
logger.error(f"移除种子Tag出错{err}")
return False
def set_torrents_status(self, ids: Union[str, list]):
"""
设置种子状态为已整理,以及是否强制做种
"""
if not self.qbc:
return
try:
# 打标签
self.qbc.torrents_add_tags(tags="已整理", torrent_hashes=ids)
except Exception as err:
logger.error(f"设置种子Tag出错{err}")
def torrents_set_force_start(self, ids: Union[str, list]):
"""
设置强制作种
"""
try:
self.qbc.torrents_set_force_start(enable=True, torrent_hashes=ids)
except Exception as err:
logger.error(f"设置强制作种出错:{err}")
def get_transfer_task(self, tag: Union[str, list] = None) -> Optional[list]:
"""
获取下载文件转移任务种子
"""
# 处理下载完成的任务
torrents = self.get_completed_torrents() or []
trans_tasks = []
for torrent in torrents:
torrent_tags = torrent.get("tags") or ""
# 含"已整理"tag的不处理
if "已整理" in torrent_tags:
continue
# 开启标签隔离,未包含指定标签的不处理
if tag and tag not in torrent_tags:
logger.debug(f"{torrent.get('name')} 未包含指定标签:{tag}")
continue
path = torrent.get("save_path")
# 无法获取下载路径的不处理
if not path:
logger.warn(f"未获取到 {torrent.get('name')} 下载保存路径")
continue
content_path = torrent.get("content_path")
if content_path:
trans_name = content_path.replace(path, "").replace("\\", "/")
if trans_name.startswith('/'):
trans_name = trans_name[1:]
else:
trans_name = torrent.get('name')
trans_tasks.append({
'path': Path(settings.DOWNLOAD_PATH) / trans_name,
'id': torrent.get('hash')
})
return trans_tasks
def __get_last_add_torrentid_by_tag(self, tag: Union[str, list],
status: Union[str, list] = None) -> Optional[str]:
"""
根据种子的下载链接获取下载中或暂停的钟子的ID
:return: 种子ID
"""
try:
torrents, _ = self.get_torrents(status=status, tag=tag)
except Exception as err:
logger.error(f"获取种子列表出错:{err}")
return None
if torrents:
return torrents[0].get("hash")
else:
return None
def get_torrent_id_by_tag(self, tag: Union[str, list],
status: Union[str, list] = None) -> Optional[str]:
"""
通过标签多次尝试获取刚添加的种子ID并移除标签
"""
torrent_id = None
# QB添加下载后需要时间重试5次每次等待5秒
for i in range(1, 6):
time.sleep(5)
torrent_id = self.__get_last_add_torrentid_by_tag(tag=tag,
status=status)
if torrent_id is None:
continue
else:
self.remove_torrents_tag(torrent_id, tag)
break
return torrent_id
def add_torrent(self,
content: Union[str, bytes],
is_paused: bool = False,
download_dir: str = None,
tag: Union[str, list] = None,
cookie=None
) -> bool:
"""
添加种子
:param content: 种子urls或文件内容
:param is_paused: 添加后暂停
:param tag: 标签
:param download_dir: 下载路径
:param cookie: 站点Cookie用于辅助下载种子
:return: bool
"""
if not self.qbc or not content:
return False
if isinstance(content, str):
urls = content
torrent_files = None
else:
urls = None
torrent_files = content
if download_dir:
save_path = download_dir
is_auto = False
else:
save_path = None
is_auto = None
if tag:
tags = tag
else:
tags = None
try:
# 添加下载
qbc_ret = self.qbc.torrents_add(urls=urls,
torrent_files=torrent_files,
save_path=save_path,
is_paused=is_paused,
tags=tags,
use_auto_torrent_management=is_auto,
cookie=cookie)
return True if qbc_ret and str(qbc_ret).find("Ok") != -1 else False
except Exception as err:
logger.error(f"添加种子出错:{err}")
return False
def start_torrents(self, ids: Union[str, list]) -> bool:
"""
启动种子
"""
if not self.qbc:
return False
try:
self.qbc.torrents_resume(torrent_hashes=ids)
return True
except Exception as err:
logger.error(f"启动种子出错:{err}")
return False
def stop_torrents(self, ids: Union[str, list]) -> bool:
"""
暂停种子
"""
if not self.qbc:
return False
try:
self.qbc.torrents_pause(torrent_hashes=ids)
return True
except Exception as err:
logger.error(f"暂停种子出错:{err}")
return False
def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:
"""
删除种子
"""
if not self.qbc:
return False
if not ids:
return False
try:
self.qbc.torrents_delete(delete_files=delete_file, torrent_hashes=ids)
return True
except Exception as err:
logger.error(f"删除种子出错:{err}")
return False
def get_files(self, tid: str) -> Optional[TorrentFilesList]:
"""
获取种子文件清单
"""
try:
return self.qbc.torrents_files(torrent_hash=tid)
except Exception as err:
logger.error(f"获取种子文件列表出错:{err}")
return None
def set_files(self, **kwargs) -> bool:
"""
设置下载文件的状态priority为0为不下载priority为1为下载
"""
if not kwargs.get("torrent_hash") or not kwargs.get("file_ids"):
return False
try:
self.qbc.torrents_file_priority(torrent_hash=kwargs.get("torrent_hash"),
file_ids=kwargs.get("file_ids"),
priority=kwargs.get("priority"))
return True
except Exception as err:
logger.error(f"设置种子文件状态出错:{err}")
return False

View File

@ -0,0 +1,111 @@
from typing import Optional, Union, List, Tuple
from fastapi import Request
from app.core import MediaInfo, TorrentInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.telegram.telegram import Telegram
class TelegramModule(_ModuleBase):
telegram: Telegram = None
def init_module(self) -> None:
self.telegram = Telegram()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "telegram"
async def message_parser(self, request: Request) -> Optional[dict]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param request: 请求体
:return: 消息内容、用户ID
"""
"""
{
'update_id': ,
'message': {
'message_id': ,
'from': {
'id': ,
'is_bot': False,
'first_name': '',
'username': '',
'language_code': 'zh-hans'
},
'chat': {
'id': ,
'first_name': '',
'username': '',
'type': 'private'
},
'date': ,
'text': ''
}
}
"""
msg_json: dict = await request.json()
if msg_json:
message = msg_json.get("message", {})
text = message.get("text")
user_id = message.get("from", {}).get("id")
# 获取用户名
user_name = message.get("from", {}).get("username")
if text:
logger.info(f"收到Telegram消息userid={user_id}, username={user_name}, text={text}")
# 检查权限
if text.startswith("/"):
if str(user_id) not in settings.TELEGRAM_ADMINS.split(',') \
and str(user_id) != settings.TELEGRAM_CHAT_ID:
self.telegram.send_msg(title="只有管理员才有权限执行此命令", userid=user_id)
return {}
else:
if not str(user_id) in settings.TELEGRAM_USERS.split(','):
self.telegram.send_msg(title="你不在用户白名单中,无法使用此机器人", userid=user_id)
return {}
return {
"userid": user_id,
"username": user_name,
"text": text
}
return None
def post_message(self, title: str,
text: str = None, image: str = None, userid: Union[str, int] = None) -> Optional[bool]:
"""
发送消息
:param title: 标题
:param text: 内容
:param image: 图片
:param userid: 用户ID
:return: 成功或失败
"""
return self.telegram.send_msg(title=title, text=text, image=image, userid=userid)
def post_medias_message(self, title: str, items: List[MediaInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
发送媒体信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
return self.telegram.send_meidas_msg(title=title, medias=items, userid=userid)
def post_torrents_message(self, title: str, items: List[TorrentInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
TODO 发送种子信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
pass

View File

@ -0,0 +1,194 @@
from threading import Event, Thread
from typing import Optional, List
from urllib.parse import urlencode
from app.core import settings, MediaInfo
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
class Telegram(metaclass=Singleton):
_poll_timeout: int = 5
_event = Event()
def __init__(self):
"""
初始化参数
"""
# Token
self._telegram_token = settings.TELEGRAM_TOKEN
# Chat Id
self._telegram_chat_id = settings.TELEGRAM_CHAT_ID
# 用户Chat Id列表
self._telegram_user_ids = settings.TELEGRAM_USERS.split(",")
# 管理员Chat Id列表
self._telegram_admin_ids = settings.TELEGRAM_ADMINS.split(",")
# 消息轮循
if self._telegram_token and self._telegram_chat_id:
self._thread = Thread(target=self.__start_telegram_message_proxy)
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = "") -> Optional[bool]:
"""
发送Telegram消息
:param title: 消息标题
:param text: 消息内容
:param image: 消息图片地址
:param userid: 用户ID如有则只发消息给该用户
:userid: 发送消息的目标用户ID为空则发给管理员
"""
if not self._telegram_token or not self._telegram_chat_id:
return None
if not title and not text:
logger.warn("标题和内容不能同时为空")
return False
try:
# text中的Markdown特殊字符转义
text = text.replace("[", r"\[").replace("_", r"\_").replace("*", r"\*").replace("`", r"\`")
# 拼装消息内容
titles = str(title).split('\n')
if len(titles) > 1:
title = titles[0]
if not text:
text = "\n".join(titles[1:])
else:
text = "%s\n%s" % ("\n".join(titles[1:]), text)
if text:
caption = "*%s*\n%s" % (title, text.replace("\n\n", "\n"))
else:
caption = title
if userid:
chat_id = userid
else:
chat_id = self._telegram_chat_id
return self.__send_request(chat_id=chat_id, image=image, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
def send_meidas_msg(self, medias: List[MediaInfo], userid: str = "", title: str = "") -> Optional[bool]:
"""
发送媒体列表消息
"""
if not self._telegram_token or not self._telegram_chat_id:
return None
try:
index, image, caption = 1, "", "*%s*" % title
for media in medias:
if not image:
image = media.get_message_image()
if media.get_vote_string():
caption = "%s\n%s. [%s](%s)\n%s%s" % (caption,
index,
media.get_title_string(),
media.get_detail_url(),
media.get_type_string(),
media.get_vote_string())
else:
caption = "%s\n%s. [%s](%s)\n%s" % (caption,
index,
media.get_title_string(),
media.get_detail_url(),
media.get_type_string())
index += 1
if userid:
chat_id = userid
else:
chat_id = self._telegram_chat_id
return self.__send_request(chat_id=chat_id, image=image, caption=caption)
except Exception as msg_e:
logger.error(f"发送消息失败:{msg_e}")
return False
def __send_request(self, chat_id="", image="", caption="") -> bool:
"""
向Telegram发送报文
"""
def __res_parse(result):
if result and result.status_code == 200:
ret_json = result.json()
status = ret_json.get("ok")
if status:
return True
else:
logger.error(
f"发送消息错误,错误码:{ret_json.get('error_code')},错误原因:{ret_json.get('description')}")
return False
elif result is not None:
logger.error(f"发送消息错误,错误码:{result.status_code},错误原因:{result.reason}")
return False
else:
logger.error("发送消息错误,未知错误")
return False
# 请求
request = RequestUtils(proxies=settings.PROXY)
# 发送图文消息
if image:
res = request.get_res("https://api.telegram.org/bot%s/sendPhoto?" % self._telegram_token + urlencode(
{"chat_id": chat_id, "photo": image, "caption": caption, "parse_mode": "Markdown"}))
if __res_parse(res):
return True
else:
photo_req = request.get_res(image)
if photo_req and photo_req.content:
res = request.post_res("https://api.telegram.org/bot%s/sendPhoto" % self._telegram_token,
data={"chat_id": chat_id, "caption": caption, "parse_mode": "Markdown"},
files={"photo": photo_req.content})
if __res_parse(res):
return True
# 发送文本消息
res = request.get_res("https://api.telegram.org/bot%s/sendMessage?" % self._telegram_token + urlencode(
{"chat_id": chat_id, "text": caption, "parse_mode": "Markdown"}))
return __res_parse(res)
def __start_telegram_message_proxy(self):
logger.info("Telegram消息接收服务启动")
def consume_messages(_offset: int, _sc_url: str, _ds_url: str) -> int:
try:
res = RequestUtils(proxies=settings.PROXY).get_res(
_sc_url + urlencode({"timeout": self._poll_timeout, "offset": _offset}))
if res and res.json():
for msg in res.json().get("result", []):
# 无论本地是否成功先更新offset即消息最多成功消费一次
_offset = msg["update_id"] + 1
logger.debug("Telegram接收到消息: %s" % msg)
local_res = RequestUtils(timeout=10).post_res(_ds_url, json=msg)
logger.debug("Telegram message: %s processed, response is: %s" % (msg, local_res.text))
except Exception as e:
logger.error("Telegram 消息接收出现错误: %s" % e)
return _offset
offset = 0
while True:
if self._event.is_set():
logger.info("Telegram消息接收服务已停止")
break
index = 0
while index < 20 and not self._event.is_set():
offset = consume_messages(_offset=offset,
_sc_url="https://api.telegram.org/bot%s/getUpdates?" % self._telegram_token,
_ds_url="http://127.0.0.1:%s/api/v1/messages?token=%s" % (
settings.PORT, settings.API_TOKEN))
index += 1
def stop(self):
"""
停止Telegram消息接收服务
"""
self._event.set()

View File

@ -0,0 +1,149 @@
from typing import Optional, List, Tuple, Union
from app.core import settings, MediaInfo
from app.core.meta import MetaBase
from app.modules import _ModuleBase
from app.modules.themoviedb.category import CategoryHelper
from app.modules.themoviedb.tmdb import TmdbHelper
from app.modules.themoviedb.tmdb_cache import TmdbCache
from app.utils.types import MediaType
class TheMovieDb(_ModuleBase):
"""
TMDB媒体信息匹配
"""
# 元数据缓存
cache: TmdbCache = None
# TMDB
tmdb: TmdbHelper = None
# 二级分类
category: CategoryHelper = None
def init_module(self) -> None:
self.cache = TmdbCache()
self.tmdb = TmdbHelper()
self.category = CategoryHelper()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
pass
def recognize_media(self, meta: MetaBase,
tmdbid: str = None) -> Optional[MediaInfo]:
"""
识别媒体信息
:param meta: 识别的元数据
:param tmdbid: tmdbid
:return: 识别的媒体信息,包括剧集信息
"""
if not meta:
return None
cache_info = self.cache.get(meta)
if not cache_info:
# 缓存没有或者强制不使用缓存
if tmdbid:
# 直接查询详情
info = self.tmdb.get_tmdb_info(mtype=meta.type, tmdbid=tmdbid)
else:
if meta.type != MediaType.TV and not meta.year:
info = self.tmdb.search_multi_tmdb(meta.get_name())
else:
if meta.type == MediaType.TV:
# 确定是电视
info = self.tmdb.search_tmdb(name=meta.get_name(),
year=meta.year,
mtype=meta.type,
season_year=meta.year,
season_number=meta.begin_season
)
if meta.year:
# 非严格模式下去掉年份再查一次
info = self.tmdb.search_tmdb(name=meta.get_name(),
mtype=meta.type)
else:
# 有年份先按电影查
info = self.tmdb.search_tmdb(name=meta.get_name(),
year=meta.year,
mtype=MediaType.MOVIE)
# 没有再按电视剧查
if not info:
info = self.tmdb.search_tmdb(name=meta.get_name(),
year=meta.year,
mtype=MediaType.TV
)
if not info:
# 非严格模式下去掉年份和类型再查一次
info = self.tmdb.search_multi_tmdb(name=meta.get_name())
if not info:
# 从网站查询
info = self.tmdb.search_tmdb_web(name=meta.get_name(),
mtype=meta.type)
# 补充全量信息
if info and not info.get("genres"):
info = self.tmdb.get_tmdb_info(mtype=info.get("media_type"),
tmdbid=info.get("id"))
# 保存到缓存
self.cache.update(meta, info)
else:
# 使用缓存信息
if cache_info.get("title"):
info = self.tmdb.get_tmdb_info(mtype=cache_info.get("type"),
tmdbid=cache_info.get("id"))
else:
info = None
# 赋值TMDB信息并返回
mediainfo = MediaInfo(tmdb_info=info)
# 确定二级分类
if info:
if info.get('media_type') == MediaType.MOVIE:
cat = self.category.get_movie_category(info)
else:
cat = self.category.get_tv_category(info)
mediainfo.set_category(cat)
return mediainfo
def search_medias(self, meta: MetaBase) -> Optional[List[MediaInfo]]:
"""
搜索媒体信息
:param meta: 识别的元数据
:reutrn: 媒体信息
"""
# 未启用时返回None
if settings.SEARCH_SOURCE != "themoviedb":
return None
if not meta.get_name():
return []
if not meta.type and not meta.year:
results = self.tmdb.search_multi_tmdbinfos(meta.get_name())
else:
if not meta.type:
results = list(
set(self.tmdb.search_movie_tmdbinfos(meta.get_name(), meta.year))
.union(set(self.tmdb.search_tv_tmdbinfos(meta.get_name(), meta.year)))
)
# 组合结果的情况下要排序
results = sorted(
results,
key=lambda x: x.get("release_date") or x.get("first_air_date") or "0000-00-00",
reverse=True
)
elif meta.type == MediaType.MOVIE:
results = self.tmdb.search_movie_tmdbinfos(meta.get_name(), meta.year)
else:
results = self.tmdb.search_tv_tmdbinfos(meta.get_name(), meta.year)
return [MediaInfo(tmdb_info=info) for info in results]
def scrape_metadata(self, path: str, mediainfo: MediaInfo) -> None:
"""
TODO 刮削元数据
:param path: 媒体文件路径
:param mediainfo: 识别的媒体信息
:return: 成功或失败
"""
if settings.SCRAP_SOURCE != "themoviedb":
return None

View File

@ -0,0 +1,128 @@
import shutil
from pathlib import Path
import ruamel.yaml
from app.core import settings
from app.log import logger
from app.utils.singleton import Singleton
class CategoryHelper(metaclass=Singleton):
def __init__(self):
self._category_path: Path = settings.CONFIG_PATH / "category.yaml"
# 二级分类策略关闭
if not settings.LIBRARY_CATEGORY:
return
try:
if not self._category_path.exists():
shutil.copy(settings.INNER_CONFIG_PATH / "category.yaml", self._category_path)
with open(self._category_path, mode='r', encoding='utf-8') as f:
try:
yaml = ruamel.yaml.YAML()
self._categorys = yaml.load(f)
except Exception as e:
logger.warn(f"二级分类策略配置文件格式出现严重错误!请检查:{str(e)}")
self._categorys = {}
except Exception as err:
logger.warn(f"二级分类策略配置文件加载出错:{err}")
if self._categorys:
self._movie_categorys = self._categorys.get('movie')
self._tv_categorys = self._categorys.get('tv')
logger.info(f"已加载二级分类策略 category.yaml")
@property
def is_movie_category(self) -> bool:
"""
获取电影分类标志
"""
if self._movie_categorys:
return True
return False
@property
def is_tv_category(self) -> bool:
"""
获取电视剧分类标志
"""
if self._tv_categorys:
return True
return False
@property
def movie_categorys(self) -> list:
"""
获取电影分类清单
"""
if not self._movie_categorys:
return []
return self._movie_categorys.keys()
@property
def tv_categorys(self) -> list:
"""
获取电视剧分类清单
"""
if not self._tv_categorys:
return []
return self._tv_categorys.keys()
def get_movie_category(self, tmdb_info) -> str:
"""
判断电影的分类
:param tmdb_info: 识别的TMDB中的信息
:return: 二级分类的名称
"""
return self.get_category(self._movie_categorys, tmdb_info)
def get_tv_category(self, tmdb_info) -> str:
"""
判断电视剧的分类
:param tmdb_info: 识别的TMDB中的信息
:return: 二级分类的名称
"""
return self.get_category(self._tv_categorys, tmdb_info)
@staticmethod
def get_category(categorys: dict, tmdb_info: dict) -> str:
"""
根据 TMDB信息与分类配置文件进行比较确定所属分类
:param categorys: 分类配置
:param tmdb_info: TMDB信息
:return: 分类的名称
"""
if not tmdb_info:
return ""
if not categorys:
return ""
for key, item in categorys.items():
if not item:
return key
match_flag = True
for attr, value in item.items():
if not value:
continue
info_value = tmdb_info.get(attr)
if not info_value:
match_flag = False
continue
elif attr == "production_countries":
info_values = [str(val.get("iso_3166_1")).upper() for val in info_value]
else:
if isinstance(info_value, list):
info_values = [str(val).upper() for val in info_value]
else:
info_values = [str(info_value).upper()]
if value.find(",") != -1:
values = [str(val).upper() for val in value.split(",")]
else:
values = [str(value).upper()]
if not set(values).intersection(set(info_values)):
match_flag = False
if match_flag:
return key
return ""

View File

@ -0,0 +1,895 @@
from functools import lru_cache
from typing import Optional, Tuple, List
import zhconv
from lxml import etree
from tmdbv3api import TMDb, Search, Movie, TV
from tmdbv3api.exceptions import TMDbException
from app.core import settings
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.string import StringUtils
from app.utils.types import MediaType
class TmdbHelper:
"""
TMDB识别匹配
"""
tmdb: TMDb = None
search: Search = None
movie: Movie = None
tv: TV = None
def __init__(self):
# TMDB主体
self.tmdb = TMDb()
# 域名
self.tmdb.domain = settings.TMDB_API_DOMAIN
# 开启缓存
self.tmdb.cache = True
# 缓存大小
self.tmdb.REQUEST_CACHE_MAXSIZE = 256
# APIKEY
self.tmdb.api_key = settings.TMDB_API_KEY
# 语种
self.tmdb.language = 'zh'
# 代理
self.tmdb.proxies = settings.PROXY
# 调试模式
self.tmdb.debug = False
# 查询对象
self.search = Search()
self.movie = Movie()
self.tv = TV()
def search_multi_tmdbinfos(self, title: str) -> List[dict]:
"""
同时查询模糊匹配的电影、电视剧TMDB信息
"""
if not title:
return []
ret_infos = []
multis = self.search.multi({"query": title}) or []
for multi in multis:
if multi.get("media_type") in ["movie", "tv"]:
multi['media_type'] = MediaType.MOVIE if multi.get("media_type") == "movie" else MediaType.TV
ret_infos.append(multi)
return ret_infos
def search_movie_tmdbinfos(self, title: str, year: str) -> List[dict]:
"""
查询模糊匹配的所有电影TMDB信息
"""
if not title:
return []
ret_infos = []
if year:
movies = self.search.movies({"query": title, "year": year}) or []
else:
movies = self.search.movies({"query": title}) or []
for movie in movies:
if title in movie.get("title"):
movie['media_type'] = MediaType.MOVIE
ret_infos.append(movie)
return ret_infos
def search_tv_tmdbinfos(self, title: str, year: str) -> List[dict]:
"""
查询模糊匹配的所有电视剧TMDB信息
"""
if not title:
return []
ret_infos = []
if year:
tvs = self.search.tv_shows({"query": title, "first_air_date_year": year}) or []
else:
tvs = self.search.tv_shows({"query": title}) or []
for tv in tvs:
if title in tv.get("name"):
tv['media_type'] = MediaType.TV
ret_infos.append(tv)
return ret_infos
@staticmethod
def __compare_tmdb_names(file_name: str, tmdb_names: list) -> bool:
"""
比较文件名是否匹配,忽略大小写和特殊字符
:param file_name: 识别的文件名或者种子名
:param tmdb_names: TMDB返回的译名
:return: True or False
"""
if not file_name or not tmdb_names:
return False
if not isinstance(tmdb_names, list):
tmdb_names = [tmdb_names]
file_name = StringUtils.clear_special_chars(file_name).upper()
for tmdb_name in tmdb_names:
tmdb_name = StringUtils.clear_special_chars(tmdb_name).strip().upper()
if file_name == tmdb_name:
return True
return False
def __get_tmdb_names(self, mtype: MediaType, tmdb_id: str) -> Tuple[Optional[dict], List[str]]:
"""
搜索tmdb中所有的标题和译名用于名称匹配
:param mtype: 类型:电影、电视剧、动漫
:param tmdb_id: TMDB的ID
:return: 所有译名的清单
"""
if not mtype or not tmdb_id:
return {}, []
ret_names = []
tmdb_info = self.get_tmdb_info(mtype=mtype, tmdbid=tmdb_id)
if not tmdb_info:
return tmdb_info, []
if mtype == MediaType.MOVIE:
alternative_titles = tmdb_info.get("alternative_titles", {}).get("titles", [])
for alternative_title in alternative_titles:
title = alternative_title.get("title")
if title and title not in ret_names:
ret_names.append(title)
translations = tmdb_info.get("translations", {}).get("translations", [])
for translation in translations:
title = translation.get("data", {}).get("title")
if title and title not in ret_names:
ret_names.append(title)
else:
alternative_titles = tmdb_info.get("alternative_titles", {}).get("results", [])
for alternative_title in alternative_titles:
name = alternative_title.get("title")
if name and name not in ret_names:
ret_names.append(name)
translations = tmdb_info.get("translations", {}).get("translations", [])
for translation in translations:
name = translation.get("data", {}).get("name")
if name and name not in ret_names:
ret_names.append(name)
return tmdb_info, ret_names
def search_tmdb(self, name: str,
mtype: MediaType,
year: str = None,
season_year: str = None,
season_number: int = None) -> Optional[dict]:
"""
搜索tmdb中的媒体信息匹配返回一条尽可能正确的信息
:param name: 剑索的名称
:param mtype: 类型:电影、电视剧
:param year: 年份,如要是季集需要是首播年份(first_air_date)
:param season_year: 当前季集年份
:param season_number: 季集,整数
:return: TMDB的INFO同时会将mtype赋值到media_type中
"""
if not self.search:
return None
if not name:
return None
# TMDB搜索
info = {}
if mtype == MediaType.MOVIE:
year_range = [year]
if year:
year_range.append(str(int(year) + 1))
year_range.append(str(int(year) - 1))
for year in year_range:
logger.debug(
f"正在识别{mtype.value}{name}, 年份={year} ...")
info = self.__search_movie_by_name(name, year)
if info:
info['media_type'] = MediaType.MOVIE
logger.info("%s 识别到 电影TMDBID=%s, 名称=%s, 上映日期=%s" % (
name,
info.get('id'),
info.get('title'),
info.get('release_date')))
break
else:
# 有当前季和当前季集年份,使用精确匹配
if season_year and season_number:
logger.debug(
f"正在识别{mtype.value}{name}, 季集={season_number}, 季集年份={season_year} ...")
info = self.__search_tv_by_season(name,
season_year,
season_number)
if not info:
logger.debug(
f"正在识别{mtype.value}{name}, 年份={year} ...")
info = self.__search_tv_by_name(name,
year)
if info:
info['media_type'] = MediaType.TV
logger.info("%s 识别到 电视剧TMDBID=%s, 名称=%s, 首播日期=%s" % (
name,
info.get('id'),
info.get('name'),
info.get('first_air_date')))
# 返回
if not info:
logger.info("%s 以年份 %s 在TMDB中未找到%s信息!" % (
name, year, mtype.value if mtype else ""))
return info
def __search_movie_by_name(self, name: str, year: str) -> Optional[dict]:
"""
根据名称查询电影TMDB匹配
:param name: 识别的文件名或种子名
:param year: 电影上映日期
:return: 匹配的媒体信息
"""
try:
if year:
movies = self.search.movies({"query": name, "year": year})
else:
movies = self.search.movies({"query": name})
except TMDbException as err:
logger.error(f"连接TMDB出错{err}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
return None
logger.debug(f"API返回{str(self.search.total_results)}")
if len(movies) == 0:
logger.debug(f"{name} 未找到相关电影信息!")
return {}
else:
info = {}
if year:
for movie in movies:
if movie.get('release_date'):
if self.__compare_tmdb_names(name, movie.get('title')) \
and movie.get('release_date')[0:4] == str(year):
return movie
if self.__compare_tmdb_names(name, movie.get('original_title')) \
and movie.get('release_date')[0:4] == str(year):
return movie
else:
for movie in movies:
if self.__compare_tmdb_names(name, movie.get('title')) \
or self.__compare_tmdb_names(name, movie.get('original_title')):
return movie
if not info:
index = 0
for movie in movies:
if year:
if not movie.get('release_date'):
continue
if movie.get('release_date')[0:4] != str(year):
continue
index += 1
info, names = self.__get_tmdb_names(MediaType.MOVIE, movie.get("id"))
if self.__compare_tmdb_names(name, names):
return info
else:
index += 1
info, names = self.__get_tmdb_names(MediaType.MOVIE, movie.get("id"))
if self.__compare_tmdb_names(name, names):
return info
if index > 5:
break
return {}
def __search_tv_by_name(self, name: str, year: str) -> Optional[dict]:
"""
根据名称查询电视剧TMDB匹配
:param name: 识别的文件名或者种子名
:param year: 电视剧的首播年份
:return: 匹配的媒体信息
"""
try:
if year:
tvs = self.search.tv_shows({"query": name, "first_air_date_year": year})
else:
tvs = self.search.tv_shows({"query": name})
except TMDbException as err:
logger.error(f"连接TMDB出错{err}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
return None
logger.debug(f"API返回{str(self.search.total_results)}")
if len(tvs) == 0:
logger.debug(f"{name} 未找到相关剧集信息!")
return {}
else:
info = {}
if year:
for tv in tvs:
if tv.get('first_air_date'):
if self.__compare_tmdb_names(name, tv.get('name')) \
and tv.get('first_air_date')[0:4] == str(year):
return tv
if self.__compare_tmdb_names(name, tv.get('original_name')) \
and tv.get('first_air_date')[0:4] == str(year):
return tv
else:
for tv in tvs:
if self.__compare_tmdb_names(name, tv.get('name')) \
or self.__compare_tmdb_names(name, tv.get('original_name')):
return tv
if not info:
index = 0
for tv in tvs:
if year:
if not tv.get('first_air_date'):
continue
if tv.get('first_air_date')[0:4] != str(year):
continue
index += 1
info, names = self.__get_tmdb_names(MediaType.TV, tv.get("id"))
if self.__compare_tmdb_names(name, names):
return info
else:
index += 1
info, names = self.__get_tmdb_names(MediaType.TV, tv.get("id"))
if self.__compare_tmdb_names(name, names):
return info
if index > 5:
break
return {}
def __search_tv_by_season(self, name: str, season_year: str, season_number: int) -> Optional[dict]:
"""
根据电视剧的名称和季的年份及序号匹配TMDB
:param name: 识别的文件名或者种子名
:param season_year: 季的年份
:param season_number: 季序号
:return: 匹配的媒体信息
"""
def __season_match(tv_info: dict, _season_year: str) -> bool:
if not tv_info:
return False
try:
seasons = self.__get_tmdb_tv_seasons(tv_info)
for season, season_info in seasons.values():
if season_info.get("air_date"):
if season.get("air_date")[0:4] == str(_season_year) \
and season == int(season_number):
return True
except Exception as e1:
logger.error(f"连接TMDB出错{e1}")
return False
return False
try:
tvs = self.search.tv_shows({"query": name})
except TMDbException as err:
logger.error(f"连接TMDB出错{err}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{e}")
return None
if len(tvs) == 0:
logger.debug("%s 未找到季%s相关信息!" % (name, season_number))
return {}
else:
for tv in tvs:
if (self.__compare_tmdb_names(name, tv.get('name'))
or self.__compare_tmdb_names(name, tv.get('original_name'))) \
and (tv.get('first_air_date') and tv.get('first_air_date')[0:4] == str(season_year)):
return tv
for tv in tvs[:5]:
info, names = self.__get_tmdb_names(MediaType.TV, tv.get("id"))
if not self.__compare_tmdb_names(name, names):
continue
if __season_match(tv_info=info, _season_year=season_year):
return info
return {}
@staticmethod
def __get_tmdb_tv_seasons(tv_info: dict) -> Optional[dict]:
"""
查询TMDB电视剧的所有季
:param tv_info: TMDB 的季信息
:return: 包括每季集数的字典
"""
"""
"seasons": [
{
"air_date": "2006-01-08",
"episode_count": 11,
"id": 3722,
"name": "特别篇",
"overview": "",
"poster_path": "/snQYndfsEr3Sto2jOmkmsQuUXAQ.jpg",
"season_number": 0
},
{
"air_date": "2005-03-27",
"episode_count": 9,
"id": 3718,
"name": "第 1 季",
"overview": "",
"poster_path": "/foM4ImvUXPrD2NvtkHyixq5vhPx.jpg",
"season_number": 1
}
]
"""
if not tv_info:
return {}
ret_seasons = {}
for season_info in tv_info.get("seasons") or []:
if not season_info.get("season_number"):
continue
ret_seasons[season_info.get("season_number")] = season_info
return ret_seasons
def search_multi_tmdb(self, name: str) -> Optional[dict]:
"""
根据名称同时查询电影和电视剧,不带年份
:param name: 识别的文件名或种子名
:return: 匹配的媒体信息
"""
try:
multis = self.search.multi({"query": name}) or []
except TMDbException as err:
logger.error(f"连接TMDB出错{err}")
return None
except Exception as e:
logger.error(f"连接TMDB出错{str(e)}")
return None
logger.debug(f"API返回{str(self.search.total_results)}")
if len(multis) == 0:
logger.debug(f"{name} 未找到相关媒体息!")
return {}
else:
info = {}
for multi in multis:
if multi.get("media_type") == "movie":
if self.__compare_tmdb_names(name, multi.get('title')) \
or self.__compare_tmdb_names(name, multi.get('original_title')):
info = multi
elif multi.get("media_type") == "tv":
if self.__compare_tmdb_names(name, multi.get('name')) \
or self.__compare_tmdb_names(name, multi.get('original_name')):
info = multi
if not info:
for multi in multis[:5]:
if multi.get("media_type") == "movie":
movie_info, names = self.__get_tmdb_names(MediaType.MOVIE, multi.get("id"))
if self.__compare_tmdb_names(name, names):
info = movie_info
elif multi.get("media_type") == "tv":
tv_info, names = self.__get_tmdb_names(MediaType.TV, multi.get("id"))
if self.__compare_tmdb_names(name, names):
info = tv_info
# 返回
if info:
info['media_type'] = MediaType.MOVIE if info.get('media_type') in ['movie',
MediaType.MOVIE] else MediaType.TV
else:
logger.info("%s 在TMDB中未找到媒体信息!" % name)
return info
@lru_cache(maxsize=128)
def search_tmdb_web(self, name: str, mtype: MediaType) -> Optional[dict]:
"""
搜索TMDB网站直接抓取结果结果只有一条时才返回
:param name: 名称
:param mtype: 媒体类型
"""
if not name:
return None
if StringUtils.is_chinese(name):
return {}
logger.info("正在从TheDbMovie网站查询%s ..." % name)
tmdb_url = "https://www.themoviedb.org/search?query=%s" % name
res = RequestUtils(timeout=5).get_res(url=tmdb_url)
if res and res.status_code == 200:
html_text = res.text
if not html_text:
return None
try:
tmdb_links = []
html = etree.HTML(html_text)
if mtype == MediaType.TV:
links = html.xpath("//a[@data-id and @data-media-type='tv']/@href")
else:
links = html.xpath("//a[@data-id]/@href")
for link in links:
if not link or (not link.startswith("/tv") and not link.startswith("/movie")):
continue
if link not in tmdb_links:
tmdb_links.append(link)
if len(tmdb_links) == 1:
tmdbinfo = self.get_tmdb_info(
mtype=MediaType.TV if tmdb_links[0].startswith("/tv") else MediaType.MOVIE,
tmdbid=tmdb_links[0].split("/")[-1])
if tmdbinfo:
if mtype == MediaType.TV and tmdbinfo.get('media_type') != MediaType.TV:
return {}
if tmdbinfo.get('media_type') == MediaType.MOVIE:
logger.info("%s 从WEB识别到 电影TMDBID=%s, 名称=%s, 上映日期=%s" % (
name,
tmdbinfo.get('id'),
tmdbinfo.get('title'),
tmdbinfo.get('release_date')))
else:
logger.info("%s 从WEB识别到 电视剧TMDBID=%s, 名称=%s, 首播日期=%s" % (
name,
tmdbinfo.get('id'),
tmdbinfo.get('name'),
tmdbinfo.get('first_air_date')))
return tmdbinfo
elif len(tmdb_links) > 1:
logger.info("%s TMDB网站返回数据过多%s" % (name, len(tmdb_links)))
else:
logger.info("%s TMDB网站未查询到媒体信息" % name)
except Exception as err:
print(str(err))
return None
return None
def get_tmdb_info(self,
mtype: MediaType,
tmdbid: str) -> dict:
"""
给定TMDB号查询一条媒体信息
:param mtype: 类型:电影、电视剧、动漫,为空时都查(此时用不上年份)
:param tmdbid: TMDB的ID有tmdbid时优先使用tmdbid否则使用年份和标题
"""
def __get_genre_ids(genres: list) -> list:
"""
从TMDB详情中获取genre_id列表
"""
if not genres:
return []
genre_ids = []
for genre in genres:
genre_ids.append(genre.get('id'))
return genre_ids
# 设置语言
if mtype == MediaType.MOVIE:
tmdb_info = self.__get_tmdb_movie_detail(tmdbid)
if tmdb_info:
tmdb_info['media_type'] = MediaType.MOVIE
else:
tmdb_info = self.__get_tmdb_tv_detail(tmdbid)
if tmdb_info:
tmdb_info['media_type'] = MediaType.TV
if tmdb_info:
# 转换genreid
tmdb_info['genre_ids'] = __get_genre_ids(tmdb_info.get('genres'))
# 转换中文标题
self.__update_tmdbinfo_cn_title(tmdb_info)
return tmdb_info
@staticmethod
def __update_tmdbinfo_cn_title(tmdb_info: dict):
"""
更新TMDB信息中的中文名称
"""
def __get_tmdb_chinese_title(tmdbinfo):
"""
从别名中获取中文标题
"""
if not tmdbinfo:
return None
if tmdbinfo.get("media_type") == MediaType.MOVIE:
alternative_titles = tmdbinfo.get("alternative_titles", {}).get("titles", [])
else:
alternative_titles = tmdbinfo.get("alternative_titles", {}).get("results", [])
for alternative_title in alternative_titles:
iso_3166_1 = alternative_title.get("iso_3166_1")
if iso_3166_1 == "CN":
title = alternative_title.get("title")
if title and StringUtils.is_chinese(title) \
and zhconv.convert(title, "zh-hans") == title:
return title
return tmdbinfo.get("title") if tmdbinfo.get("media_type") == MediaType.MOVIE else tmdbinfo.get("name")
# 查找中文名
org_title = tmdb_info.get("title") \
if tmdb_info.get("media_type") == MediaType.MOVIE \
else tmdb_info.get("name")
if not StringUtils.is_chinese(org_title):
cn_title = __get_tmdb_chinese_title(tmdb_info)
if cn_title and cn_title != org_title:
if tmdb_info.get("media_type") == MediaType.MOVIE:
tmdb_info['title'] = cn_title
else:
tmdb_info['name'] = cn_title
def __get_tmdb_movie_detail(self,
tmdbid: str,
append_to_response: str = "images,"
"credits,"
"alternative_titles,"
"translations,"
"external_ids") -> Optional[dict]:
"""
获取电影的详情
:param tmdbid: TMDB ID
:return: TMDB信息
"""
"""
{
"adult": false,
"backdrop_path": "/r9PkFnRUIthgBp2JZZzD380MWZy.jpg",
"belongs_to_collection": {
"id": 94602,
"name": "穿靴子的猫(系列)",
"poster_path": "/anHwj9IupRoRZZ98WTBvHpTiE6A.jpg",
"backdrop_path": "/feU1DWV5zMWxXUHJyAIk3dHRQ9c.jpg"
},
"budget": 90000000,
"genres": [
{
"id": 16,
"name": "动画"
},
{
"id": 28,
"name": "动作"
},
{
"id": 12,
"name": "冒险"
},
{
"id": 35,
"name": "喜剧"
},
{
"id": 10751,
"name": "家庭"
},
{
"id": 14,
"name": "奇幻"
}
],
"homepage": "",
"id": 315162,
"imdb_id": "tt3915174",
"original_language": "en",
"original_title": "Puss in Boots: The Last Wish",
"overview": "时隔11年臭屁自大又爱卖萌的猫大侠回来了如今的猫大侠安东尼奥·班德拉斯 配音),依旧幽默潇洒又不拘小节、数次“花式送命”后,九条命如今只剩一条,于是不得不请求自己的老搭档兼“宿敌”——迷人的软爪妞(萨尔玛·海耶克 配音)来施以援手来恢复自己的九条生命。",
"popularity": 8842.129,
"poster_path": "/rnn30OlNPiC3IOoWHKoKARGsBRK.jpg",
"production_companies": [
{
"id": 33,
"logo_path": "/8lvHyhjr8oUKOOy2dKXoALWKdp0.png",
"name": "Universal Pictures",
"origin_country": "US"
},
{
"id": 521,
"logo_path": "/kP7t6RwGz2AvvTkvnI1uteEwHet.png",
"name": "DreamWorks Animation",
"origin_country": "US"
}
],
"production_countries": [
{
"iso_3166_1": "US",
"name": "United States of America"
}
],
"release_date": "2022-12-07",
"revenue": 260725470,
"runtime": 102,
"spoken_languages": [
{
"english_name": "English",
"iso_639_1": "en",
"name": "English"
},
{
"english_name": "Spanish",
"iso_639_1": "es",
"name": "Español"
}
],
"status": "Released",
"tagline": "",
"title": "穿靴子的猫2",
"video": false,
"vote_average": 8.614,
"vote_count": 2291
}
"""
if not self.movie:
return {}
try:
logger.info("正在查询TMDB电影%s ..." % tmdbid)
tmdbinfo = self.movie.details(tmdbid, append_to_response)
if tmdbinfo:
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('title')}")
return tmdbinfo or {}
except Exception as e:
print(str(e))
return None
def __get_tmdb_tv_detail(self,
tmdbid: str,
append_to_response: str = "images,"
"credits,"
"alternative_titles,"
"translations,"
"external_ids") -> Optional[dict]:
"""
获取电视剧的详情
:param tmdbid: TMDB ID
:return: TMDB信息
"""
"""
{
"adult": false,
"backdrop_path": "/uDgy6hyPd82kOHh6I95FLtLnj6p.jpg",
"created_by": [
{
"id": 35796,
"credit_id": "5e84f06a3344c600153f6a57",
"name": "Craig Mazin",
"gender": 2,
"profile_path": "/uEhna6qcMuyU5TP7irpTUZ2ZsZc.jpg"
},
{
"id": 1295692,
"credit_id": "5e84f03598f1f10016a985c0",
"name": "Neil Druckmann",
"gender": 2,
"profile_path": "/bVUsM4aYiHbeSYE1xAw2H5Z1ANU.jpg"
}
],
"episode_run_time": [],
"first_air_date": "2023-01-15",
"genres": [
{
"id": 18,
"name": "剧情"
},
{
"id": 10765,
"name": "Sci-Fi & Fantasy"
},
{
"id": 10759,
"name": "动作冒险"
}
],
"homepage": "https://www.hbo.com/the-last-of-us",
"id": 100088,
"in_production": true,
"languages": [
"en"
],
"last_air_date": "2023-01-15",
"last_episode_to_air": {
"air_date": "2023-01-15",
"episode_number": 1,
"id": 2181581,
"name": "当你迷失在黑暗中",
"overview": "在一场全球性的流行病摧毁了文明之后,一个顽强的幸存者负责照顾一个 14 岁的小女孩,她可能是人类最后的希望。",
"production_code": "",
"runtime": 81,
"season_number": 1,
"show_id": 100088,
"still_path": "/aRquEWm8wWF1dfa9uZ1TXLvVrKD.jpg",
"vote_average": 8,
"vote_count": 33
},
"name": "最后生还者",
"next_episode_to_air": {
"air_date": "2023-01-22",
"episode_number": 2,
"id": 4071039,
"name": "虫草变异菌",
"overview": "",
"production_code": "",
"runtime": 55,
"season_number": 1,
"show_id": 100088,
"still_path": "/jkUtYTmeap6EvkHI4n0j5IRFrIr.jpg",
"vote_average": 10,
"vote_count": 1
},
"networks": [
{
"id": 49,
"name": "HBO",
"logo_path": "/tuomPhY2UtuPTqqFnKMVHvSb724.png",
"origin_country": "US"
}
],
"number_of_episodes": 9,
"number_of_seasons": 1,
"origin_country": [
"US"
],
"original_language": "en",
"original_name": "The Last of Us",
"overview": "不明真菌疫情肆虐之后的美国被真菌感染的人都变成了可怕的怪物乔尔Joel为了换回武器答应将小女孩儿艾莉Ellie送到指定地点由此开始了两人穿越美国的漫漫旅程。",
"popularity": 5585.639,
"poster_path": "/nOY3VBFO0VnlN9nlRombnMTztyh.jpg",
"production_companies": [
{
"id": 3268,
"logo_path": "/tuomPhY2UtuPTqqFnKMVHvSb724.png",
"name": "HBO",
"origin_country": "US"
},
{
"id": 11073,
"logo_path": "/aCbASRcI1MI7DXjPbSW9Fcv9uGR.png",
"name": "Sony Pictures Television Studios",
"origin_country": "US"
},
{
"id": 23217,
"logo_path": "/kXBZdQigEf6QiTLzo6TFLAa7jKD.png",
"name": "Naughty Dog",
"origin_country": "US"
},
{
"id": 115241,
"logo_path": null,
"name": "The Mighty Mint",
"origin_country": "US"
},
{
"id": 119645,
"logo_path": null,
"name": "Word Games",
"origin_country": "US"
},
{
"id": 125281,
"logo_path": "/3hV8pyxzAJgEjiSYVv1WZ0ZYayp.png",
"name": "PlayStation Productions",
"origin_country": "US"
}
],
"production_countries": [
{
"iso_3166_1": "US",
"name": "United States of America"
}
],
"seasons": [
{
"air_date": "2023-01-15",
"episode_count": 9,
"id": 144593,
"name": "第 1 季",
"overview": "",
"poster_path": "/aUQKIpZZ31KWbpdHMCmaV76u78T.jpg",
"season_number": 1
}
],
"spoken_languages": [
{
"english_name": "English",
"iso_639_1": "en",
"name": "English"
}
],
"status": "Returning Series",
"tagline": "",
"type": "Scripted",
"vote_average": 8.924,
"vote_count": 601
}
"""
if not self.tv:
return {}
try:
logger.info("正在查询TMDB电视剧%s ..." % tmdbid)
tmdbinfo = self.tv.details(tmdbid, append_to_response)
if tmdbinfo:
logger.info(f"{tmdbid} 查询结果:{tmdbinfo.get('name')}")
return tmdbinfo or {}
except Exception as e:
print(str(e))
return None

View File

@ -0,0 +1,235 @@
import pickle
import random
import threading
import time
from pathlib import Path
from threading import RLock
from typing import Optional
from app.core import settings
from app.core.meta import MetaBase
from app.utils.singleton import Singleton
from app.utils.types import MediaType
lock = RLock()
CACHE_EXPIRE_TIMESTAMP_STR = "cache_expire_timestamp"
EXPIRE_TIMESTAMP = 7 * 24 * 3600
class TmdbCache(metaclass=Singleton):
"""
TMDB缓存数据
{
"id": '',
"title": '',
"year": '',
"type": MediaType
}
"""
_meta_data: dict = {}
# 缓存文件路径
_meta_path: Path = None
# TMDB缓存过期
_tmdb_cache_expire: bool = True
# 自动保存暗隔时间
_save_interval: int = 600
def __init__(self):
# 创建计时器
self.timer = threading.Timer(self._save_interval, self.save)
self.init_config()
def init_config(self):
self._meta_path = settings.TEMP_PATH / "__tmdb_cache__"
self._meta_data = self.__load(self._meta_path)
def clear(self):
"""
清空所有TMDB缓存
"""
with lock:
self._meta_data = {}
@staticmethod
def __get_key(meta: MetaBase) -> str:
"""
获取缓存KEY
"""
return f"[{meta.type.value}]{meta.get_name()}-{meta.year}-{meta.begin_season}"
def get(self, meta: MetaBase):
"""
根据KEY值获取缓存值
"""
key = self.__get_key(meta)
with lock:
info: dict = self._meta_data.get(key)
if info:
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire or int(time.time()) < expire:
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
self.update(meta, info)
elif expire and self._tmdb_cache_expire:
self.delete(key)
return info or {}
def delete(self, key: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@return: 被删除的缓存内容
"""
with lock:
return self._meta_data.pop(key, None)
def delete_by_tmdbid(self, tmdbid: str) -> None:
"""
清空对应TMDBID的所有缓存记录以强制更新TMDB中最新的数据
"""
for key in list(self._meta_data):
if str(self._meta_data.get(key, {}).get("id")) == str(tmdbid):
with lock:
self._meta_data.pop(key)
def delete_unknown(self) -> None:
"""
清除未识别的缓存记录以便重新搜索TMDB
"""
for key in list(self._meta_data):
if str(self._meta_data.get(key, {}).get("id")) == '0':
with lock:
self._meta_data.pop(key)
def modify(self, key: str, title: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@param title: 标题
@return: 被修改后缓存内容
"""
with lock:
if self._meta_data.get(key):
self._meta_data[key]['title'] = title
self._meta_data[key][CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
return self._meta_data.get(key)
@staticmethod
def __load(path) -> dict:
"""
从文件中加载缓存
"""
try:
if Path(path).exists():
with open(path, 'rb') as f:
data = pickle.load(f)
return data
return {}
except Exception as e:
print(str(e))
return {}
def update(self, meta: MetaBase, info: dict) -> None:
"""
新增或更新缓存条目
"""
if info:
# 缓存标题
cache_title = info.get("title") \
if info.get("media_type") == MediaType.MOVIE else info.get("name")
# 缓存年份
cache_year = info.get('release_date') \
if info.get("media_type") == MediaType.MOVIE else info.get('first_air_date')
if cache_year:
cache_year = cache_year[:4]
self._meta_data[self.__get_key(meta)] = {
"id": info.get("id"),
"type": info.get("media_type"),
"year": cache_year,
"title": cache_title,
"poster_path": info.get("poster_path"),
"backdrop_path": info.get("backdrop_path"),
CACHE_EXPIRE_TIMESTAMP_STR: int(time.time()) + EXPIRE_TIMESTAMP
}
else:
self._meta_data[self.__get_key(meta)] = {'id': 0}
def save(self, force: bool = False) -> None:
"""
保存缓存数据到文件
"""
meta_data = self.__load(self._meta_path)
new_meta_data = {k: v for k, v in self._meta_data.items() if str(v.get("id")) != '0'}
if not force \
and not self._random_sample(new_meta_data) \
and meta_data.keys() == new_meta_data.keys():
return
with open(self._meta_path, 'wb') as f:
pickle.dump(new_meta_data, f, pickle.HIGHEST_PROTOCOL)
if not force:
# 重新创建计时器
self.timer = threading.Timer(self._save_interval, self.save)
# 启动计时器
self.timer.start()
def _random_sample(self, new_meta_data: dict) -> bool:
"""
采样分析是否需要保存
"""
ret = False
if len(new_meta_data) < 25:
keys = list(new_meta_data.keys())
for k in keys:
info = new_meta_data.get(k)
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire:
ret = True
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
elif int(time.time()) >= expire:
ret = True
if self._tmdb_cache_expire:
new_meta_data.pop(k)
else:
count = 0
keys = random.sample(new_meta_data.keys(), 25)
for k in keys:
info = new_meta_data.get(k)
expire = info.get(CACHE_EXPIRE_TIMESTAMP_STR)
if not expire:
ret = True
info[CACHE_EXPIRE_TIMESTAMP_STR] = int(time.time()) + EXPIRE_TIMESTAMP
elif int(time.time()) >= expire:
ret = True
if self._tmdb_cache_expire:
new_meta_data.pop(k)
count += 1
if count >= 5:
ret |= self._random_sample(new_meta_data)
return ret
def get_title(self, key: str) -> Optional[str]:
"""
获取缓存的标题
"""
cache_media_info = self._meta_data.get(key)
if not cache_media_info or not cache_media_info.get("id"):
return None
return cache_media_info.get("title")
def set_title(self, key: str, cn_title: str) -> None:
"""
重新设置缓存标题
"""
cache_media_info = self._meta_data.get(key)
if not cache_media_info:
return
self._meta_data[key]['title'] = cn_title
def __del__(self):
"""
退出
"""
self.timer.cancel()

View File

@ -0,0 +1,70 @@
from pathlib import Path
from typing import Set, Tuple, Optional, Union
from app.core import settings, MetaInfo
from app.modules import _ModuleBase
from app.modules.transmission.transmission import Transmission
class TransmissionModule(_ModuleBase):
transmission: Transmission = None
def init_module(self) -> None:
self.transmission = Transmission()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "DOWNLOADER", "transmission"
def download(self, torrent_path: Path, cookie: str,
episodes: Set[int] = None) -> Optional[Tuple[Optional[str], str]]:
"""
根据种子文件,选择并添加下载任务
:param torrent_path: 种子文件地址
:param cookie: cookie
:param episodes: 需要下载的集数
:return: 种子Hash
"""
# 如果要选择文件则先暂停
is_paused = True if episodes else False
# 添加任务
torrent = self.transmission.add_torrent(content=torrent_path.read_bytes(),
download_dir=settings.DOWNLOAD_PATH,
is_paused=is_paused,
cookie=cookie)
if not torrent:
return None, f"添加种子任务失败:{torrent_path}"
else:
torrent_hash = torrent.hashString
torrent_id = torrent.id
if is_paused:
# 选择文件
torrent_files = self.transmission.get_files(torrent_hash)
if not torrent_files:
return torrent_hash, "获取种子文件失败,下载任务可能在暂停状态"
# 需要的文件信息
files_info = {}
# 需要的集清单
sucess_epidised = []
for torrent_file in torrent_files:
file_id = torrent_file.id
file_name = torrent_file.name
meta_info = MetaInfo(file_name)
if not meta_info.get_episode_list():
selected = False
else:
selected = set(meta_info.get_episode_list()).issubset(set(episodes))
if selected:
sucess_epidised = list(set(sucess_epidised).union(set(meta_info.get_episode_list())))
if not files_info.get(torrent_id):
files_info[torrent_id] = {file_id: {'priority': 'normal', 'selected': selected}}
else:
files_info[torrent_id][file_id] = {'priority': 'normal', 'selected': selected}
if sucess_epidised and files_info:
self.transmission.set_files(file_info=files_info)
# 开始任务
self.transmission.start_torrents(torrent_hash)
else:
return torrent_hash, "添加下载任务成功"

View File

@ -0,0 +1,282 @@
from pathlib import Path
from typing import Optional, Union, Tuple, List
import transmission_rpc
from transmission_rpc import Client, Torrent, File
from app.core import settings
from app.log import logger
from app.utils.singleton import Singleton
class Transmission(metaclass=Singleton):
_host: str = None
_port: int = None
_username: str = None
_passowrd: str = None
# 参考transmission web仅查询需要的参数加速种子搜索
_trarg = ["id", "name", "status", "labels", "hashString", "totalSize", "percentDone", "addedDate", "trackerStats",
"leftUntilDone", "rateDownload", "rateUpload", "recheckProgress", "rateDownload", "rateUpload",
"peersGettingFromUs", "peersSendingToUs", "uploadRatio", "uploadedEver", "downloadedEver", "downloadDir",
"error", "errorString", "doneDate", "queuePosition", "activityDate", "trackers"]
def __init__(self):
host = settings.TR_HOST
if host and host.find(":") != -1:
self._host = settings.TR_HOST.split(":")[0]
self._port = settings.TR_HOST.split(":")[1]
self._username = settings.TR_USER
self._password = settings.TR_PASSWORD
if self._host and self._port and self._username and self._password:
self.trc = self.__login_transmission()
def __login_transmission(self) -> Optional[Client]:
"""
连接transmission
:return: transmission对象
"""
try:
# 登录
trt = transmission_rpc.Client(host=self._host,
port=self._port,
username=self._username,
password=self._password,
timeout=60)
return trt
except Exception as err:
logger.error(f"连接出错:{err}")
return None
def get_torrents(self, ids: Union[str, list] = None, status: Union[str, list] = None,
tag: Union[str, list] = None) -> Tuple[list, bool]:
"""
获取种子列表
返回结果 种子列表, 是否有错误
"""
if not self.trc:
return [], True
try:
torrents = self.trc.get_torrents(ids=ids, arguments=self._trarg)
except Exception as err:
logger.error(f"获取种子列表出错:{err}")
return [], True
if status and not isinstance(status, list):
status = [status]
if tag and not isinstance(tag, list):
tag = [tag]
ret_torrents = []
for torrent in torrents:
if status and torrent.status not in status:
continue
labels = torrent.labels if hasattr(torrent, "labels") else []
include_flag = True
if tag:
for t in tag:
if t and t not in labels:
include_flag = False
break
if include_flag:
ret_torrents.append(torrent)
return ret_torrents, False
def get_completed_torrents(self, ids: Union[str, list] = None, tag: Union[str, list] = None) -> Optional[list]:
"""
获取已完成的种子列表
return 种子列表, 发生错误时返回None
"""
if not self.trc:
return None
try:
torrents, error = self.get_torrents(status=["seeding", "seed_pending"], ids=ids, tag=tag)
return None if error else torrents or []
except Exception as err:
logger.error(f"获取已完成的种子列表出错:{err}")
return None
def get_downloading_torrents(self, ids: Union[str, list] = None,
tag: Union[str, list] = None) -> Optional[list]:
"""
获取正在下载的种子列表
return 种子列表, 发生错误时返回None
"""
if not self.trc:
return None
try:
torrents, error = self.get_torrents(ids=ids,
status=["downloading", "download_pending"],
tag=tag)
return None if error else torrents or []
except Exception as err:
logger.error(f"获取正在下载的种子列表出错:{err}")
return None
def set_torrents_status(self, ids: Union[str, list],
tags: Union[str, list] = None) -> bool:
"""
设置种子为已整理状态
"""
if not self.trc:
return False
# 合成标签
if tags:
if not isinstance(tags, list):
tags = [tags, "已整理"]
else:
tags.append("已整理")
else:
tags = ["已整理"]
# 打标签
try:
self.trc.change_torrent(labels=tags, ids=ids)
return True
except Exception as err:
logger.error(f"设置种子为已整理状态出错:{err}")
return False
def set_torrent_tag(self, ids: str, tag: Union[str, list]) -> bool:
"""
设置种子标签
"""
if not ids or not tag:
return False
try:
self.trc.change_torrent(labels=tag, ids=ids)
return True
except Exception as err:
logger.error(f"设置种子标签出错:{err}")
return False
def get_transfer_task(self, tag: Union[str, list] = None) -> List[dict]:
"""
获取下载文件转移任务种子
"""
# 处理下载完成的任务
torrents = self.get_completed_torrents() or []
trans_tasks = []
for torrent in torrents:
# 3.0版本以下的Transmission没有labels
if not hasattr(torrent, "labels"):
logger.error(f"版本可能过低无labels属性请安装3.0以上版本!")
break
torrent_tags = torrent.labels or ""
# 含"已整理"tag的不处理
if "已整理" in torrent_tags:
continue
# 开启标签隔离,未包含指定标签的不处理
if tag and tag not in torrent_tags:
logger.debug(f"{torrent.name} 未包含指定标签:{tag}")
continue
path = torrent.download_dir
# 无法获取下载路径的不处理
if not path:
logger.debug(f"未获取到 {torrent.name} 下载保存路径")
continue
trans_tasks.append({
'path': Path(settings.DOWNLOAD_PATH) / torrent.name,
'id': torrent.hashString,
'tags': torrent.labels
})
return trans_tasks
def add_torrent(self, content: Union[str, bytes],
is_paused: bool = False,
download_dir: str = None,
cookie=None) -> Optional[Torrent]:
"""
添加下载任务
:param content: 种子urls或文件内容
:param is_paused: 添加后暂停
:param download_dir: 下载路径
:param cookie: 站点Cookie用于辅助下载种子
:return: Torrent
"""
try:
return self.trc.add_torrent(torrent=content,
download_dir=download_dir,
paused=is_paused,
cookies=cookie)
except Exception as err:
logger.error(f"添加种子出错:{err}")
return None
def start_torrents(self, ids: Union[str, list]) -> bool:
"""
启动种子
"""
if not self.trc:
return False
try:
self.trc.start_torrent(ids=ids)
return True
except Exception as err:
logger.error(f"启动种子出错:{err}")
return False
def stop_torrents(self, ids: Union[str, list]) -> bool:
"""
停止种子
"""
if not self.trc:
return False
try:
self.trc.stop_torrent(ids=ids)
return True
except Exception as err:
logger.error(f"停止种子出错:{err}")
return False
def delete_torrents(self, delete_file: bool, ids: Union[str, list]) -> bool:
"""
删除种子
"""
if not self.trc:
return False
if not ids:
return False
try:
self.trc.remove_torrent(delete_data=delete_file, ids=ids)
return True
except Exception as err:
logger.error(f"删除种子出错:{err}")
return False
def get_files(self, tid: str) -> Optional[List[File]]:
"""
获取种子文件列表
"""
if not tid:
return None
try:
torrent = self.trc.get_torrent(tid)
except Exception as err:
logger.error(f"获取种子文件列表出错:{err}")
return None
if torrent:
return torrent.files()
else:
return None
def set_files(self, **kwargs) -> bool:
"""
设置下载文件的状态
{
<torrent id>: {
<file id>: {
'priority': <priority ('high'|'normal'|'low')>,
'selected': <selected for download (True|False)>
},
...
},
...
}
"""
if not kwargs.get("file_info"):
return False
try:
self.trc.set_files(kwargs.get("file_info"))
return True
except Exception as err:
logger.error(f"设置下载文件状态出错:{err}")
return False

View File

@ -0,0 +1,300 @@
#!/usr/bin/env python
# -*- encoding:utf-8 -*-
""" 对企业微信发送给企业后台的消息加解密示例代码.
@copyright: Copyright (c) 1998-2014 Tencent Inc.
"""
import base64
import hashlib
# ------------------------------------------------------------------------
import logging
import random
import socket
import struct
import time
import xml.etree.cElementTree as ET
from Crypto.Cipher import AES
# Description:定义错误码含义
#########################################################################
WXBizMsgCrypt_OK = 0
WXBizMsgCrypt_ValidateSignature_Error = -40001
WXBizMsgCrypt_ParseXml_Error = -40002
WXBizMsgCrypt_ComputeSignature_Error = -40003
WXBizMsgCrypt_IllegalAesKey = -40004
WXBizMsgCrypt_ValidateCorpid_Error = -40005
WXBizMsgCrypt_EncryptAES_Error = -40006
WXBizMsgCrypt_DecryptAES_Error = -40007
WXBizMsgCrypt_IllegalBuffer = -40008
WXBizMsgCrypt_EncodeBase64_Error = -40009
WXBizMsgCrypt_DecodeBase64_Error = -40010
WXBizMsgCrypt_GenReturnXml_Error = -40011
"""
关于Crypto.Cipher模块ImportError: No module named 'Crypto'解决方案
请到官方网站 https://www.dlitz.net/software/pycrypto/ 下载pycrypto。
下载后按照README中的“Installation”小节的提示进行pycrypto安装。
"""
class FormatException(Exception):
pass
def throw_exception(message, exception_class=FormatException):
"""my define raise exception function"""
raise exception_class(message)
class SHA1:
"""计算企业微信的消息签名接口"""
@staticmethod
def getSHA1(token, timestamp, nonce, encrypt):
"""用SHA1算法生成安全签名
@param token: 票据
@param timestamp: 时间戳
@param encrypt: 密文
@param nonce: 随机字符串
@return: 安全签名
"""
try:
sortlist = [token, timestamp, nonce, encrypt]
sortlist.sort()
sha = hashlib.sha1()
sha.update("".join(sortlist).encode())
return WXBizMsgCrypt_OK, sha.hexdigest()
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return WXBizMsgCrypt_ComputeSignature_Error, None
class XMLParse:
"""提供提取消息格式中的密文及生成回复消息格式的接口"""
# xml消息模板
AES_TEXT_RESPONSE_TEMPLATE = """<xml>
<Encrypt><![CDATA[%(msg_encrypt)s]]></Encrypt>
<MsgSignature><![CDATA[%(msg_signaturet)s]]></MsgSignature>
<TimeStamp>%(timestamp)s</TimeStamp>
<Nonce><![CDATA[%(nonce)s]]></Nonce>
</xml>"""
@staticmethod
def extract(xmltext):
"""提取出xml数据包中的加密消息
@param xmltext: 待提取的xml字符串
@return: 提取出的加密消息字符串
"""
try:
xml_tree = ET.fromstring(xmltext)
encrypt = xml_tree.find("Encrypt")
return WXBizMsgCrypt_OK, encrypt.text
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return WXBizMsgCrypt_ParseXml_Error, None
def generate(self, encrypt, signature, timestamp, nonce):
"""生成xml消息
@param encrypt: 加密后的消息密文
@param signature: 安全签名
@param timestamp: 时间戳
@param nonce: 随机字符串
@return: 生成的xml字符串
"""
resp_dict = {
'msg_encrypt': encrypt,
'msg_signaturet': signature,
'timestamp': timestamp,
'nonce': nonce,
}
resp_xml = self.AES_TEXT_RESPONSE_TEMPLATE % resp_dict
return resp_xml
class PKCS7Encoder:
"""提供基于PKCS7算法的加解密接口"""
block_size = 32
def encode(self, text):
""" 对需要加密的明文进行填充补位
@param text: 需要进行填充补位操作的明文
@return: 补齐明文字符串
"""
text_length = len(text)
# 计算需要填充的位数
amount_to_pad = self.block_size - (text_length % self.block_size)
if amount_to_pad == 0:
amount_to_pad = self.block_size
# 获得补位所用的字符
pad = chr(amount_to_pad)
return text + (pad * amount_to_pad).encode()
@staticmethod
def decode(decrypted):
"""删除解密后明文的补位字符
@param decrypted: 解密后的明文
@return: 删除补位字符后的明文
"""
pad = ord(decrypted[-1])
if pad < 1 or pad > 32:
pad = 0
return decrypted[:-pad]
class Prpcrypt(object):
"""提供接收和推送给企业微信消息的加解密接口"""
def __init__(self, key):
# self.key = base64.b64decode(key+"=")
self.key = key
# 设置加解密模式为AES的CBC模式
self.mode = AES.MODE_CBC
def encrypt(self, text, receiveid):
"""对明文进行加密
@param text: 需要加密的明文
@param receiveid: receiveid
@return: 加密得到的字符串
"""
# 16位随机字符串添加到明文开头
text = text.encode()
text = self.get_random_str() + struct.pack("I", socket.htonl(len(text))) + text + receiveid.encode()
# 使用自定义的填充方式对明文进行补位填充
pkcs7 = PKCS7Encoder()
text = pkcs7.encode(text)
# 加密
cryptor = AES.new(self.key, self.mode, self.key[:16])
try:
ciphertext = cryptor.encrypt(text)
# 使用BASE64对加密后的字符串进行编码
return WXBizMsgCrypt_OK, base64.b64encode(ciphertext)
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return WXBizMsgCrypt_EncryptAES_Error, None
def decrypt(self, text, receiveid):
"""对解密后的明文进行补位删除
@param text: 密文
@param receiveid: receiveid
@return: 删除填充补位后的明文
"""
try:
cryptor = AES.new(self.key, self.mode, self.key[:16])
# 使用BASE64对密文进行解码然后AES-CBC解密
plain_text = cryptor.decrypt(base64.b64decode(text))
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return WXBizMsgCrypt_DecryptAES_Error, None
try:
pad = plain_text[-1]
# 去掉补位字符串
# pkcs7 = PKCS7Encoder()
# plain_text = pkcs7.encode(plain_text)
# 去除16位随机字符串
content = plain_text[16:-pad]
xml_len = socket.ntohl(struct.unpack("I", content[: 4])[0])
xml_content = content[4: xml_len + 4]
from_receiveid = content[xml_len + 4:]
except Exception as e:
logger = logging.getLogger()
logger.error(e)
return WXBizMsgCrypt_IllegalBuffer, None
if from_receiveid.decode('utf8') != receiveid:
return WXBizMsgCrypt_ValidateCorpid_Error, None
return 0, xml_content
@staticmethod
def get_random_str():
""" 随机生成16位字符串
@return: 16位字符串
"""
return str(random.randint(1000000000000000, 9999999999999999)).encode()
class WXBizMsgCrypt(object):
# 构造函数
def __init__(self, sToken, sEncodingAESKey, sReceiveId):
try:
self.key = base64.b64decode(sEncodingAESKey + "=")
assert len(self.key) == 32
except Exception as err:
print(str(err))
throw_exception("[error]: EncodingAESKey unvalid !", FormatException)
# return WXBizMsgCrypt_IllegalAesKey,None
self.m_sToken = sToken
self.m_sReceiveId = sReceiveId
# 验证URL
# @param sMsgSignature: 签名串对应URL参数的msg_signature
# @param sTimeStamp: 时间戳对应URL参数的timestamp
# @param sNonce: 随机串对应URL参数的nonce
# @param sEchoStr: 随机串对应URL参数的echostr
# @param sReplyEchoStr: 解密之后的echostr当return返回0时有效
# @return成功0失败返回对应的错误码
def VerifyURL(self, sMsgSignature, sTimeStamp, sNonce, sEchoStr):
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, sEchoStr)
if ret != 0:
return ret, None
if not signature == sMsgSignature:
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = Prpcrypt(self.key)
ret, sReplyEchoStr = pc.decrypt(sEchoStr, self.m_sReceiveId)
return ret, sReplyEchoStr
def EncryptMsg(self, sReplyMsg, sNonce, timestamp=None):
# 将企业回复用户的消息加密打包
# @param sReplyMsg: 企业号待回复用户的消息xml格式的字符串
# @param sTimeStamp: 时间戳可以自己生成也可以用URL参数的timestamp,如为None则自动用当前时间
# @param sNonce: 随机串可以自己生成也可以用URL参数的nonce
# sEncryptMsg: 加密后的可以直接回复用户的密文包括msg_signature, timestamp, nonce, encrypt的xml格式的字符串,
# return成功0sEncryptMsg,失败返回对应的错误码None
pc = Prpcrypt(self.key)
ret, encrypt = pc.encrypt(sReplyMsg, self.m_sReceiveId)
encrypt = encrypt.decode('utf8')
if ret != 0:
return ret, None
if timestamp is None:
timestamp = str(int(time.time()))
# 生成安全签名
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, timestamp, sNonce, encrypt)
if ret != 0:
return ret, None
xmlParse = XMLParse()
return ret, xmlParse.generate(encrypt, signature, timestamp, sNonce)
def DecryptMsg(self, sPostData, sMsgSignature, sTimeStamp, sNonce):
# 检验消息的真实性,并且获取解密后的明文
# @param sMsgSignature: 签名串对应URL参数的msg_signature
# @param sTimeStamp: 时间戳对应URL参数的timestamp
# @param sNonce: 随机串对应URL参数的nonce
# @param sPostData: 密文对应POST请求的数据
# xml_content: 解密后的原文当return返回0时有效
# @return: 成功0失败返回对应的错误码
# 验证安全签名
xmlParse = XMLParse()
ret, encrypt = xmlParse.extract(sPostData)
if ret != 0:
return ret, None
sha1 = SHA1()
ret, signature = sha1.getSHA1(self.m_sToken, sTimeStamp, sNonce, encrypt)
if ret != 0:
return ret, None
if not signature == sMsgSignature:
return WXBizMsgCrypt_ValidateSignature_Error, None
pc = Prpcrypt(self.key)
ret, xml_content = pc.decrypt(encrypt, self.m_sReceiveId)
return ret, xml_content

View File

@ -0,0 +1,144 @@
from typing import Optional, Union, List, Tuple
from fastapi import Request
import xml.dom.minidom
from app.core import MediaInfo, TorrentInfo, settings
from app.log import logger
from app.modules import _ModuleBase
from app.modules.wechat.WXBizMsgCrypt3 import WXBizMsgCrypt
from app.modules.wechat.wechat import WeChat
from app.utils.dom import DomUtils
class WechatModule(_ModuleBase):
wechat: WeChat = None
def init_module(self) -> None:
self.wechat = WeChat()
def init_setting(self) -> Tuple[str, Union[str, bool]]:
return "MESSAGER", "wechat"
def message_parser(self, request: Request) -> Optional[dict]:
"""
解析消息内容,返回字典,注意以下约定值:
userid: 用户ID
username: 用户名
text: 内容
:param request: 请求体
:return: 消息内容、用户ID
"""
try:
# URL参数
sVerifyMsgSig = request.query_params.get("msg_signature")
sVerifyTimeStamp = request.query_params.get("timestamp")
sVerifyNonce = request.query_params.get("nonce")
# 解密模块
wxcpt = WXBizMsgCrypt(sToken=settings.WECHAT_TOKEN,
sEncodingAESKey=settings.WECHAT_ENCODING_AESKEY,
sReceiveId=settings.WECHAT_CORPID)
# 报文数据
sReqData = request.form()
if not sReqData:
return None
logger.debug(f"收到微信请求:{sReqData}")
ret, sMsg = wxcpt.DecryptMsg(sPostData=sReqData,
sMsgSignature=sVerifyMsgSig,
sTimeStamp=sVerifyTimeStamp,
sNonce=sVerifyNonce)
if ret != 0:
logger.error(f"解密微信消息失败 DecryptMsg ret = {ret}")
return None
# 解析XML报文
"""
1、消息格式
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[fromUser]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[text]]></MsgType>
<Content><![CDATA[this is a test]]></Content>
<MsgId>1234567890123456</MsgId>
<AgentID>1</AgentID>
</xml>
2、事件格式
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[UserID]]></FromUserName>
<CreateTime>1348831860</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<AgentID>1</AgentID>
</xml>
"""
dom_tree = xml.dom.minidom.parseString(sMsg.decode('UTF-8'))
root_node = dom_tree.documentElement
# 消息类型
msg_type = DomUtils.tag_value(root_node, "MsgType")
# Event event事件只有click才有效,enter_agent无效
event = DomUtils.tag_value(root_node, "Event")
# 用户ID
user_id = DomUtils.tag_value(root_node, "FromUserName")
# 没的消息类型和用户ID的消息不要
if not msg_type or not user_id:
return None
# 解析消息内容
if msg_type == "event" and event == "click":
# 校验用户有权限执行交互命令
wechat_admins = settings.WECHAT_ADMINS.split(',')
if wechat_admins and not any(
user_id == admin_user for admin_user in wechat_admins):
self.wechat.send_msg(title="用户无权限执行菜单命令", userid=user_id)
return {}
elif msg_type == "text":
# 文本消息
content = DomUtils.tag_value(root_node, "Content", default="")
if content:
logger.info(f"收到微信消息userid={user_id}, text={content}")
# 处理消息内容
return {
"userid": user_id,
"username": user_id,
"text": content
}
except Exception as err:
logger.error(f"微信消息处理发生错误:{err}")
return None
def post_message(self, title: str,
text: str = None, image: str = None, userid: Union[str, int] = None) -> Optional[bool]:
"""
发送消息
:param title: 标题
:param text: 内容
:param image: 图片
:param userid: 用户ID
:return: 成功或失败
"""
return self.wechat.send_msg(title=title, text=text, image=image, userid=userid)
def post_medias_message(self, title: str, items: List[MediaInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
发送媒体信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
# 先发送标题
self.wechat.send_msg(title=title)
# 再发送内容
return self.wechat.send_medias_msg(medias=items, userid=userid)
def post_torrents_message(self, title: str, items: List[TorrentInfo],
userid: Union[str, int] = None) -> Optional[bool]:
"""
TODO 发送种子信息选择列表
:param title: 标题
:param items: 消息列表
:param userid: 用户ID
:return: 成功或失败
"""
pass

Binary file not shown.

View File

@ -0,0 +1,216 @@
import json
import threading
from datetime import datetime
from typing import Optional, List
from app.core import settings, MediaInfo
from app.log import logger
from app.utils.http import RequestUtils
from app.utils.singleton import Singleton
lock = threading.Lock()
class WeChat(metaclass=Singleton):
# 企业微信Token
_access_token = None
# 企业微信Token过期时间
_expires_in: int = None
# 企业微信Token获取时间
_access_token_time: datetime = None
# 企业微信CorpID
_corpid = None
# 企业微信AppSecret
_appsecret = None
# 企业微信AppID
_appid = None
# 企业微信发送消息URL
_send_msg_url = "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=%s"
# 企业微信获取TokenURL
_token_url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=%s&corpsecret=%s"
def __init__(self):
"""
初始化
"""
self._corpid = settings.WECHAT_CORPID
self._appsecret = settings.WECHAT_APP_SECRET
self._appid = settings.WECHAT_APP_ID
if self._corpid and self._appsecret and self._appid:
self.__get_access_token()
def __get_access_token(self, force=False):
"""
获取微信Token
:return 微信Token
"""
token_flag = True
if not self._access_token:
token_flag = False
else:
if (datetime.now() - self._access_token_time).seconds >= self._expires_in:
token_flag = False
if not token_flag or force:
if not self._corpid or not self._appsecret:
return None
try:
token_url = self._token_url % (self._corpid, self._appsecret)
res = RequestUtils().get_res(token_url)
if res:
ret_json = res.json()
if ret_json.get('errcode') == 0:
self._access_token = ret_json.get('access_token')
self._expires_in = ret_json.get('expires_in')
self._access_token_time = datetime.now()
except Exception as e:
logger.error(f"获取微信access_token失败错误信息{e}")
return None
return self._access_token
def __send_message(self, title: str, text: str, userid: str = None) -> Optional[bool]:
"""
发送文本消息
:param title: 消息标题
:param text: 消息内容
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
message_url = self._send_msg_url % self.__get_access_token()
if text:
conent = "%s\n%s" % (title, text.replace("\n\n", "\n"))
else:
conent = title
if not userid:
userid = "@all"
req_json = {
"touser": userid,
"msgtype": "text",
"agentid": self._appid,
"text": {
"content": conent
},
"safe": 0,
"enable_id_trans": 0,
"enable_duplicate_check": 0
}
return self.__post_request(message_url, req_json)
def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None) -> Optional[bool]:
"""
发送图文消息
:param title: 消息标题
:param text: 消息内容
:param image_url: 图片地址
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
message_url = self._send_msg_url % self.__get_access_token()
if text:
text = text.replace("\n\n", "\n")
if not userid:
userid = "@all"
req_json = {
"touser": userid,
"msgtype": "news",
"agentid": self._appid,
"news": {
"articles": [
{
"title": title,
"description": text,
"picurl": image_url,
"url": ''
}
]
}
}
return self.__post_request(message_url, req_json)
def send_msg(self, title: str, text: str = "", image: str = "", userid: str = None):
"""
微信消息发送入口,支持文本、图片、链接跳转、指定发送对象
:param title: 消息标题
:param text: 消息内容
:param image: 图片地址
:param userid: 消息发送对象的ID为空则发给所有人
:return: 发送状态,错误信息
"""
if not self.__get_access_token():
logger.error("获取微信access_token失败请检查参数配置")
return None
if image:
ret_code, ret_msg = self.__send_image_message(title, text, image, userid)
else:
ret_code, ret_msg = self.__send_message(title, text, userid)
return ret_code, ret_msg
def send_medias_msg(self, medias: List[MediaInfo], userid: str = "") -> Optional[bool]:
"""
发送列表类消息
"""
if not self.__get_access_token():
logger.error("获取微信access_token失败请检查参数配置")
return None
message_url = self._send_msg_url % self.__get_access_token()
if not userid:
userid = "@all"
articles = []
index = 1
for media in medias:
if media.get_vote_string():
title = f"{index}. {media.get_title_string()}\n{media.get_type_string()}{media.get_vote_string()}"
else:
title = f"{index}. {media.get_title_string()}\n{media.get_type_string()}"
articles.append({
"title": title,
"description": "",
"picurl": media.get_message_image() if index == 1 else media.get_poster_image(),
"url": media.get_detail_url()
})
index += 1
req_json = {
"touser": userid,
"msgtype": "news",
"agentid": self._appid,
"news": {
"articles": articles
}
}
return self.__post_request(message_url, req_json)
def __post_request(self, message_url: str, req_json: dict) -> bool:
"""
向微信发送请求
"""
try:
res = RequestUtils(content_type='application/json').post(
message_url,
data=json.dumps(req_json, ensure_ascii=False).encode('utf-8')
)
if res and res.status_code == 200:
ret_json = res.json()
if ret_json.get('errcode') == 0:
return True
else:
if ret_json.get('errcode') == 42001:
self.__get_access_token(force=True)
logger.error(f"发送消息失败,错误信息:{ret_json.get('errmsg')}")
return False
elif res is not None:
logger.error(f"发送消息失败,错误码:{res.status_code},错误原因:{res.reason}")
return False
else:
logger.error(f"发送消息失败,未获取到返回信息")
return False
except Exception as err:
logger.error(f"发送消息失败,错误信息:{err}")
return False