init
This commit is contained in:
200
app/modules/__init__.py
Normal file
200
app/modules/__init__.py
Normal 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
|
BIN
app/modules/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
115
app/modules/douban/__init__.py
Normal file
115
app/modules/douban/__init__.py
Normal 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
|
BIN
app/modules/douban/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/douban/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/douban/__pycache__/apiv2.cpython-310.pyc
Normal file
BIN
app/modules/douban/__pycache__/apiv2.cpython-310.pyc
Normal file
Binary file not shown.
260
app/modules/douban/apiv2.py
Normal file
260
app/modules/douban/apiv2.py
Normal 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)
|
69
app/modules/emby/__init__.py
Normal file
69
app/modules/emby/__init__.py
Normal 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)
|
BIN
app/modules/emby/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/emby/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/emby/__pycache__/emby.cpython-310.pyc
Normal file
BIN
app/modules/emby/__pycache__/emby.cpython-310.pyc
Normal file
Binary file not shown.
484
app/modules/emby/emby.py
Normal file
484
app/modules/emby/emby.py
Normal 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
|
74
app/modules/fanart/__init__.py
Normal file
74
app/modules/fanart/__init__.py
Normal 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
|
BIN
app/modules/fanart/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/fanart/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
22
app/modules/filetransfer/__init__.py
Normal file
22
app/modules/filetransfer/__init__.py
Normal 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
|
BIN
app/modules/filetransfer/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/filetransfer/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
20
app/modules/filter/__init__.py
Normal file
20
app/modules/filter/__init__.py
Normal 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
|
BIN
app/modules/filter/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/filter/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
154
app/modules/indexer/__init__.py
Normal file
154
app/modules/indexer/__init__.py
Normal 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)
|
BIN
app/modules/indexer/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/indexer/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/indexer/__pycache__/spider.cpython-310.pyc
Normal file
BIN
app/modules/indexer/__pycache__/spider.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/indexer/__pycache__/tnode.cpython-310.pyc
Normal file
BIN
app/modules/indexer/__pycache__/tnode.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/indexer/__pycache__/torrentleech.cpython-310.pyc
Normal file
BIN
app/modules/indexer/__pycache__/torrentleech.cpython-310.pyc
Normal file
Binary file not shown.
640
app/modules/indexer/spider.py
Normal file
640
app/modules/indexer/spider.py
Normal 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
|
105
app/modules/indexer/tnode.py
Normal file
105
app/modules/indexer/tnode.py
Normal 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
|
64
app/modules/indexer/torrentleech.py
Normal file
64
app/modules/indexer/torrentleech.py
Normal 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
|
68
app/modules/jellyfin/__init__.py
Normal file
68
app/modules/jellyfin/__init__.py
Normal 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)
|
BIN
app/modules/jellyfin/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/jellyfin/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/jellyfin/__pycache__/jellyfin.cpython-310.pyc
Normal file
BIN
app/modules/jellyfin/__pycache__/jellyfin.cpython-310.pyc
Normal file
Binary file not shown.
337
app/modules/jellyfin/jellyfin.py
Normal file
337
app/modules/jellyfin/jellyfin.py
Normal 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
|
68
app/modules/plex/__init__.py
Normal file
68
app/modules/plex/__init__.py
Normal 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)
|
BIN
app/modules/plex/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/plex/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/plex/__pycache__/plex.cpython-310.pyc
Normal file
BIN
app/modules/plex/__pycache__/plex.cpython-310.pyc
Normal file
Binary file not shown.
293
app/modules/plex/plex.py
Normal file
293
app/modules/plex/plex.py
Normal 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
|
75
app/modules/qbittorrent/__init__.py
Normal file
75
app/modules/qbittorrent/__init__.py
Normal 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, "添加下载成功"
|
BIN
app/modules/qbittorrent/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/qbittorrent/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/qbittorrent/__pycache__/qbittorrent.cpython-310.pyc
Normal file
BIN
app/modules/qbittorrent/__pycache__/qbittorrent.cpython-310.pyc
Normal file
Binary file not shown.
325
app/modules/qbittorrent/qbittorrent.py
Normal file
325
app/modules/qbittorrent/qbittorrent.py
Normal 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
|
111
app/modules/telegram/__init__.py
Normal file
111
app/modules/telegram/__init__.py
Normal 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
|
BIN
app/modules/telegram/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/telegram/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/telegram/__pycache__/telegram.cpython-310.pyc
Normal file
BIN
app/modules/telegram/__pycache__/telegram.cpython-310.pyc
Normal file
Binary file not shown.
194
app/modules/telegram/telegram.py
Normal file
194
app/modules/telegram/telegram.py
Normal 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()
|
149
app/modules/themoviedb/__init__.py
Normal file
149
app/modules/themoviedb/__init__.py
Normal 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
|
BIN
app/modules/themoviedb/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/themoviedb/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/themoviedb/__pycache__/category.cpython-310.pyc
Normal file
BIN
app/modules/themoviedb/__pycache__/category.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/themoviedb/__pycache__/tmdb.cpython-310.pyc
Normal file
BIN
app/modules/themoviedb/__pycache__/tmdb.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/themoviedb/__pycache__/tmdb_cache.cpython-310.pyc
Normal file
BIN
app/modules/themoviedb/__pycache__/tmdb_cache.cpython-310.pyc
Normal file
Binary file not shown.
128
app/modules/themoviedb/category.py
Normal file
128
app/modules/themoviedb/category.py
Normal 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 ""
|
895
app/modules/themoviedb/tmdb.py
Normal file
895
app/modules/themoviedb/tmdb.py
Normal 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
|
235
app/modules/themoviedb/tmdb_cache.py
Normal file
235
app/modules/themoviedb/tmdb_cache.py
Normal 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()
|
70
app/modules/transmission/__init__.py
Normal file
70
app/modules/transmission/__init__.py
Normal 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, "添加下载任务成功"
|
BIN
app/modules/transmission/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/transmission/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
282
app/modules/transmission/transmission.py
Normal file
282
app/modules/transmission/transmission.py
Normal 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
|
300
app/modules/wechat/WXBizMsgCrypt3.py
Normal file
300
app/modules/wechat/WXBizMsgCrypt3.py
Normal 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:成功0,sEncryptMsg,失败返回对应的错误码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
|
144
app/modules/wechat/__init__.py
Normal file
144
app/modules/wechat/__init__.py
Normal 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
|
BIN
app/modules/wechat/__pycache__/WXBizMsgCrypt3.cpython-310.pyc
Normal file
BIN
app/modules/wechat/__pycache__/WXBizMsgCrypt3.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/wechat/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
app/modules/wechat/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
app/modules/wechat/__pycache__/wechat.cpython-310.pyc
Normal file
BIN
app/modules/wechat/__pycache__/wechat.cpython-310.pyc
Normal file
Binary file not shown.
216
app/modules/wechat/wechat.py
Normal file
216
app/modules/wechat/wechat.py
Normal 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
|
Reference in New Issue
Block a user