diff --git a/app/api/endpoints/rss.py b/app/api/endpoints/rss.py new file mode 100644 index 00000000..523591e9 --- /dev/null +++ b/app/api/endpoints/rss.py @@ -0,0 +1,68 @@ +from typing import List, Any + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app import schemas +from app.core.security import verify_token +from app.db import get_db +from app.db.models.rss import Rss + +router = APIRouter() + + +@router.get("/", summary="所有自定义订阅", response_model=List[schemas.Rss]) +def read_rsses( + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 查询所有自定义订阅 + """ + return Rss.list(db) + + +@router.post("/", summary="新增自定义订阅", response_model=schemas.Response) +def create_rss( + *, + db: Session = Depends(get_db), + rss_in: schemas.Rss, + _: schemas.TokenPayload = Depends(verify_token) +) -> Any: + """ + 新增自定义订阅 + """ + rss = Rss.get_by_tmdbid(db, tmdbid=rss_in.tmdbid, season=rss_in.season) + if rss: + return schemas.Response(success=False, message="自定义订阅已存在") + rss = Rss(**rss_in.dict()) + rss.create(db) + return schemas.Response(success=True) + + +@router.put("/", summary="更新自定义订阅", response_model=schemas.Response) +def update_rss( + *, + rss_in: schemas.Rss, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token) +) -> Any: + """ + 更新自定义订阅信息 + """ + rss = Rss.get(db, rss_in.id) + if not rss: + return schemas.Response(success=False, message="自定义订阅不存在") + + rss.update(db, rss_in.dict()) + return schemas.Response(success=True) + + +@router.get("/{rssid}", summary="查询订阅详情", response_model=schemas.Rss) +def read_rss( + rssid: int, + db: Session = Depends(get_db), + _: schemas.TokenPayload = Depends(verify_token)) -> Any: + """ + 根据ID查询自定义订阅详情 + """ + return Rss.get(db, rssid) diff --git a/app/chain/rss.py b/app/chain/rss.py new file mode 100644 index 00000000..49366d93 --- /dev/null +++ b/app/chain/rss.py @@ -0,0 +1,155 @@ +import re +import time + +from app.chain import ChainBase +from app.chain.download import DownloadChain +from app.core.config import settings +from app.core.context import Context, TorrentInfo, MediaInfo +from app.core.metainfo import MetaInfo +from app.db.rss_oper import RssOper +from app.db.systemconfig_oper import SystemConfigOper +from app.helper.rss import RssHelper +from app.helper.sites import SitesHelper +from app.log import logger +from app.schemas import Notification +from app.schemas.types import SystemConfigKey, MediaType, NotificationType +from app.utils.string import StringUtils + + +class RssChain(ChainBase): + """ + RSS处理链 + """ + + def __init__(self): + super().__init__() + self.rssoper = RssOper() + self.sites = SitesHelper() + self.systemconfig = SystemConfigOper() + self.downloadchain = DownloadChain() + + def refresh(self): + """ + 刷新RSS订阅数据 + """ + # 所有RSS订阅 + logger.info("开始刷新RSS订阅数据 ...") + rss_tasks = self.rssoper.list() or [] + for rss_task in rss_tasks: + if not rss_task.url: + continue + # 下载Rss报文 + items = RssHelper.parse(rss_task.url, True if rss_task.proxy else False) + if not items: + logger.error(f"RSS未下载到数据:{rss_task.url}") + logger.info(f"{rss_task.name} RSS下载到数据:{len(items)}") + # 检查站点 + domain = StringUtils.get_url_domain(rss_task.url) + site_info = self.sites.get_indexer(domain) + if not site_info: + logger.error(f"{rss_task.name} 没有维护对应站点") + continue + # 过滤规则 + if rss_task.best_version: + filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules2) + else: + filter_rule = self.systemconfig.get(SystemConfigKey.FilterRules) + # 处理RSS条目 + matched_contexts = [] + for item in items: + if not item.get("title"): + continue + # 基本要素匹配 + if rss_task.include \ + and not re.search(r"%s" % rss_task.include, item.get("title")): + logger.info(f"{item.get('title')} 未包含 {rss_task.include}") + continue + if rss_task.exclude \ + and re.search(r"%s" % rss_task.exclude, item.get("title")): + logger.info(f"{item.get('title')} 包含 {rss_task.exclude}") + continue + # 识别媒体信息 + meta = MetaInfo(title=item.get("title"), subtitle=item.get("description")) + if not meta.name: + logger.error(f"{item.get('title')} 未识别到有效信息") + continue + mediainfo = self.recognize_media(meta=meta) + if not mediainfo: + logger.error(f"{item.get('title')} 未识别到TMDB媒体信息") + continue + if mediainfo.tmdb_id != rss_task.tmdbid: + logger.error(f"{item.get('title')} 不匹配") + continue + # 种子 + torrentinfo = TorrentInfo( + site=site_info.get("id"), + site_name=site_info.get("name"), + site_cookie=site_info.get("cookie"), + site_ua=site_info.get("cookie") or settings.USER_AGENT, + site_proxy=site_info.get("proxy"), + site_order=site_info.get("pri"), + title=item.get("title"), + description=item.get("description"), + enclosure=item.get("enclosure"), + page_url=item.get("link"), + size=item.get("size"), + pubdate=time.strftime("%Y-%m-%d %H:%M:%S", item.get("pubdate")) if item.get("pubdate") else None, + ) + # 过滤种子 + result = self.filter_torrents( + rule_string=filter_rule, + torrent_list=[torrentinfo] + ) + if not result: + logger.info(f"{rss_task.name} 不匹配过滤规则") + continue + # 匹配 + mediainfo.clear() + matched_contexts.append(Context( + meta_info=meta, + media_info=mediainfo, + torrent_info=torrentinfo + )) + # 过滤规则 + if not matched_contexts: + logger.info(f"{rss_task.name} 未匹配到数据") + continue + logger.info(f"{rss_task.name} 匹配到 {len(matched_contexts)} 条数据") + # 查询本地存在情况 + if not rss_task.best_version: + # 查询缺失的媒体信息 + rss_meta = MetaInfo(title=rss_task.title) + rss_meta.type = MediaType(rss_task.type) + exist_flag, no_exists = self.downloadchain.get_no_exists_info( + meta=rss_meta, + mediainfo=MediaInfo( + title=rss_task.title, + year=rss_task.year, + tmdb_id=rss_task.tmdbid, + season=rss_task.season + ), + ) + if exist_flag: + logger.info(f'{rss_task.name} 媒体库中已存在,完成订阅') + self.rssoper.delete(rss_task.id) + # 发送通知 + self.post_message(Notification(mtype=NotificationType.Subscribe, + title=f'自定义订阅 {rss_task.name} 已完成', + image=rss_task.backdrop)) + continue + else: + no_exists = {} + # 开始下载 + downloads, lefts = self.downloadchain.batch_download(contexts=matched_contexts, + no_exists=no_exists) + if downloads and not lefts: + if not rss_task.best_version: + self.rssoper.delete(rss_task.id) + # 发送通知 + self.post_message(Notification(mtype=NotificationType.Subscribe, + title=f'自定义订阅 {rss_task.name} 已完成', + image=rss_task.backdrop)) + # 未完成下载 + logger.info(f'{rss_task.name} 未下载未完整,继续订阅 ...') + + logger.info("刷新RSS订阅数据完成") diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index 1c26ba71..7ca9da32 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -414,15 +414,15 @@ class SubscribeChain(ChainBase): else: logger.info(f'{indexer.get("name")} 获取到种子') # 从缓存中匹配订阅 - self.__match(torrents_cache) + self.match(torrents_cache) # 保存缓存到本地 self.save_cache(torrents_cache, self._cache_file) - def __match(self, torrents_cache: Dict[str, List[Context]]): + def match(self, torrents: Dict[str, List[Context]]): """ 从缓存中匹配订阅,并自动下载 """ - if not torrents_cache: + if not torrents: logger.warn('没有缓存资源,无法匹配订阅') return # 所有订阅 @@ -482,7 +482,7 @@ class SubscribeChain(ChainBase): no_exists = {} # 遍历缓存种子 _match_context = [] - for domain, contexts in torrents_cache.items(): + for domain, contexts in torrents.items(): for context in contexts: # 检查是否匹配 torrent_meta = context.meta_info diff --git a/app/db/models/rss.py b/app/db/models/rss.py new file mode 100644 index 00000000..48bdbd2b --- /dev/null +++ b/app/db/models/rss.py @@ -0,0 +1,60 @@ +from sqlalchemy import Column, Integer, String, Sequence +from sqlalchemy.orm import Session + +from app.db.models import Base + + +class Rss(Base): + """ + RSS订阅 + """ + id = Column(Integer, Sequence('id'), primary_key=True, index=True) + # 名称 + name = Column(String, nullable=False) + # RSS地址 + url = Column(String, nullable=False) + # 类型 + type = Column(String) + # 标题 + title = Column(String) + # 年份 + year = Column(String) + # TMDBID + tmdbid = Column(Integer, index=True) + # 季号 + season = Column(Integer) + # 海报 + poster = Column(String) + # 背景图 + backdrop = Column(String) + # 评分 + vote = Column(Integer) + # 简介 + description = Column(String) + # 包含 + include = Column(String) + # 排除 + exclude = Column(String) + # 洗版 + best_version = Column(Integer) + # 是否使用代理服务器 + proxy = Column(Integer) + # 保存路径 + save_path = Column(String) + # 附加信息,已处理数据 + note = Column(String) + # 最后更新时间 + last_update = Column(String) + # 状态 0-停用,1-启用 + state = Column(Integer) + + @staticmethod + def get_by_tmdbid(db: Session, tmdbid: int, season: int = None): + if season: + return db.query(Rss).filter(Rss.tmdbid == tmdbid, + Rss.season == season).all() + return db.query(Rss).filter(Rss.tmdbid == tmdbid).all() + + @staticmethod + def get_by_title(db: Session, title: str): + return db.query(Rss).filter(Rss.title == title).first() diff --git a/app/db/rss_oper.py b/app/db/rss_oper.py new file mode 100644 index 00000000..ed73b4e6 --- /dev/null +++ b/app/db/rss_oper.py @@ -0,0 +1,40 @@ +from typing import List + +from app.db import DbOper, SessionLocal +from app.db.models.rss import Rss + + +class RssOper(DbOper): + """ + RSS订阅数据管理 + """ + + def __init__(self, db=SessionLocal()): + super().__init__(db) + + def add(self, **kwargs) -> bool: + """ + 新增RSS订阅 + """ + item = Rss(**kwargs) + if not item.get_by_tmdbid(self._db, tmdbid=kwargs.get("tmdbid"), + season=kwargs.get("season")): + item.create(self._db) + return True + return False + + def list(self) -> List[Rss]: + """ + 查询所有RSS订阅 + """ + return Rss.list(self._db) + + def delete(self, rssid: int) -> bool: + """ + 删除RSS订阅 + """ + item = Rss.get(self._db, rssid) + if item: + item.delete(self._db) + return True + return False diff --git a/app/plugins/bestfilmversion/__init__.py b/app/plugins/bestfilmversion/__init__.py index 4307300e..96b2ba6c 100644 --- a/app/plugins/bestfilmversion/__init__.py +++ b/app/plugins/bestfilmversion/__init__.py @@ -1,4 +1,5 @@ import datetime +from functools import reduce from pathlib import Path from typing import Optional, Any, List, Dict, Tuple from xml.dom.minidom import parseString @@ -398,6 +399,8 @@ class BestFilmVersion(_PluginBase): "&Limit=20" \ "&apikey={APIKEY}" resp = self.get_items(Jellyfin().get_data(url)) + if not resp: + continue all_item.extend(resp) elif settings.MEDIASERVER == 'emby': # 获取所有user @@ -417,12 +420,21 @@ class BestFilmVersion(_PluginBase): "&EnableTotalRecordCount=false" \ "&Limit=20&api_key={APIKEY}" resp = self.get_items(Emby().get_data(url)) + if not resp: + continue all_item.extend(resp) else: resp = self.plex_get_watchlist() + if not resp: + return all_item.extend(resp) - for data in all_item: + def function(y, x): + return y if (x['Name'] in [i['Name'] for i in y]) else (lambda z, u: (z.append(u), z))(y, x)[1] + # all_item 根据电影名去重 + result = reduce(function, all_item, []) + + for data in result: # 检查缓存 if data.get('Name') in caches: continue @@ -520,6 +532,8 @@ class BestFilmVersion(_PluginBase): elem = dom.documentElement # 获取 指定元素 eles = elem.getElementsByTagName('Video') + if not eles: + return [] for ele in eles: data = {} # 获取标签中内容 @@ -539,20 +553,29 @@ class BestFilmVersion(_PluginBase): logger.error(f"连接Plex/Watchlist 出错:" + str(e)) return [] - def plex_get_iteminfo(self, itemid): + @staticmethod + def plex_get_iteminfo(itemid): url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \ f"?X-Plex-Token={settings.PLEX_TOKEN}" + ids = [] try: - resp = RequestUtils().get_res(url=url) + resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url) if resp: - dom = parseString(resp.text) - # 获取文档元素对象 - elem = dom.documentElement - # 获取 指定元素 - eles = elem.getElementsByTagName('Video') - for ele in eles: - # 获取标签中内容 - return {"ExternalUrls": "TheMovieDb", "Url": f"{self.ele_get_tmdbid(ele)}"} + metadata = resp.json().get('MediaContainer').get('Metadata') + for item in metadata: + _guid = item.get('Guid') + if not _guid: + continue + + id_list = [h.get('id') for h in _guid if h.get('id').__contains__("tmdb")] + if not id_list: + continue + + ids.append({'Name': 'TheMovieDb', 'Url': id_list[0]}) + + if not ids: + return [] + return {'ExternalUrls': ids} else: logger.error(f"Plex/Items 未获取到返回数据") return [] @@ -560,19 +583,6 @@ class BestFilmVersion(_PluginBase): logger.error(f"连接Plex/Items 出错:" + str(e)) return [] - @staticmethod - def ele_get_tmdbid(ele): - data = [] - for h in ele.getElementsByTagName('Guid'): - tmdbid = h.attributes['id'].nodeValue if h.attributes['id'].nodeValue.__contains__("tmdb") else "" - if not tmdbid: - continue - obj = {"Name": "TheMovieDb", "Url": f"{tmdbid}"} - data.append(obj) - return data - logger.warn(f"连接Plex/Guid 警告:" + "未获取到tmdbid数据") - return data - @eventmanager.register(EventType.WebhookMessage) def webhook_message_action(self, event): diff --git a/app/plugins/iyuuautoseed/__init__.py b/app/plugins/iyuuautoseed/__init__.py index 44564bcf..97451c80 100644 --- a/app/plugins/iyuuautoseed/__init__.py +++ b/app/plugins/iyuuautoseed/__init__.py @@ -16,6 +16,7 @@ from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase from app.plugins.iyuuautoseed.iyuu_helper import IyuuHelper +from app.schemas import NotificationType from app.utils.http import RequestUtils from app.utils.string import StringUtils @@ -462,6 +463,7 @@ class IYUUAutoSeed(_PluginBase): if self._notify: if self.success or self.fail: self.post_message( + mtype=NotificationType.SiteMessage, title="【IYUU自动辅种任务完成】", text=f"服务器返回可辅种总数:{self.total}\n" f"实际可辅种数:{self.realtotal}\n" diff --git a/app/plugins/torrentremover/__init__.py b/app/plugins/torrentremover/__init__.py index 7358176f..ed92a9cf 100644 --- a/app/plugins/torrentremover/__init__.py +++ b/app/plugins/torrentremover/__init__.py @@ -1,8 +1,22 @@ -from typing import List, Tuple, Dict, Any +import re +import threading +import time +from datetime import datetime, timedelta +from typing import List, Tuple, Dict, Any, Optional -from app.core.event import eventmanager +import pytz +from apscheduler.schedulers.background import BackgroundScheduler +from apscheduler.triggers.cron import CronTrigger + +from app.core.config import settings +from app.log import logger +from app.modules.qbittorrent import Qbittorrent +from app.modules.transmission import Transmission from app.plugins import _PluginBase -from app.schemas.types import EventType +from app.schemas import NotificationType +from app.utils.string import StringUtils + +lock = threading.Lock() class TorrentRemover(_PluginBase): @@ -28,15 +42,97 @@ class TorrentRemover(_PluginBase): auth_level = 2 # 私有属性 - downloader = None + qb = None + tr = None + _event = threading.Event() + _scheduler = None _enabled = False + _onlyonce = False + _notify = False + # pause/delete + _downloaders = [] + _action = "pause" + _cron = None + _samedata = False + _mponly = False + _size = None + _ratio = None + _time = None + _upspeed = None + _labels = None + _pathkeywords = None + _trackerkeywords = None + _errorkeywords = None + _torrentstates = None + _torrentcategorys = None def init_plugin(self, config: dict = None): if config: self._enabled = config.get("enabled") + self._onlyonce = config.get("onlyonce") + self._notify = config.get("notify") + self._downloaders = config.get("downloaders") or [] + self._action = config.get("action") + self._cron = config.get("cron") + self._samedata = config.get("samedata") + self._mponly = config.get("mponly") + self._size = config.get("size") or "" + self._ratio = config.get("ratio") + self._time = config.get("time") + self._upspeed = config.get("upspeed") + self._labels = config.get("labels") or "" + self._pathkeywords = config.get("pathkeywords") or "" + self._trackerkeywords = config.get("trackerkeywords") or "" + self._errorkeywords = config.get("errorkeywords") or "" + self._torrentstates = config.get("torrentstates") or "" + self._torrentcategorys = config.get("torrentcategorys") or "" + + self.stop_service() + + if self.get_state() or self._onlyonce: + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + self.qb = Qbittorrent() + self.tr = Transmission() + if self._cron: + try: + self._scheduler.add_job(self.delete_torrents, + CronTrigger.from_crontab(self._cron)) + logger.info(f"自动删种服务启动,周期:{self._cron}") + except Exception as err: + logger.error(f"自动删种服务启动失败:{str(err)}") + self.systemmessage.put(f"自动删种服务启动失败:{str(err)}") + if self._onlyonce: + logger.info(f"自动删种服务启动,立即运行一次") + self._scheduler.add_job(self.delete_torrents, 'date', + run_date=datetime.now( + tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) + ) + # 关闭一次性开关 + self._onlyonce = False + # 保存设置 + self.update_config({ + "enable": self._enabled, + "notify": self._notify, + "onlyonce": self._onlyonce, + "action": self._action, + "cron": self._cron, + "samedata": self._samedata, + "mponly": self._mponly, + "size": self._size, + "ratio": self._ratio, + "time": self._time, + "upspeed": self._upspeed, + "labels": self._labels, + "pathkeywords": self._pathkeywords, + "trackerkeywords": self._trackerkeywords, + "errorkeywords": self._errorkeywords, + "torrentstates": self._torrentstates, + "torrentcategorys": self._torrentcategorys + + }) def get_state(self) -> bool: - return self._enabled + return self._enabled and self._cron and self._downloaders @staticmethod def get_command() -> List[Dict[str, Any]]: @@ -46,23 +142,566 @@ class TorrentRemover(_PluginBase): pass def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - pass + return [ + { + 'component': 'VForm', + 'content': [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'enabled', + 'label': '启用插件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'notify', + 'label': '发送通知', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'cron', + 'label': '执行周期', + 'placeholder': '0 0 0 ? *' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 6 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'model': 'action', + 'label': '动作', + 'items': [ + {'title': '暂停', 'value': 'pause'}, + {'title': '删除种子', 'value': 'delete'}, + {'title': '删除种子和文件', 'value': 'deletefile'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'chips': True, + 'multiple': True, + 'model': 'downloaders', + 'label': '下载器', + 'items': [ + {'title': 'Qbittorrent', 'value': 'qbittorrent'}, + {'title': 'Transmission', 'value': 'transmission'} + ] + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'size', + 'label': '种子大小(GB)', + 'placeholder': '例如1-10' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'ratio', + 'label': '分享率', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'time', + 'label': '做种时间(小时)', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'upspeed', + 'label': '平均上传速度', + 'placeholder': '' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'labels', + 'label': '标签', + 'placeholder': '用,分隔多个标签' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'pathkeywords', + 'label': '保存路径关键词', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'trackerkeywords', + 'label': 'Tracker关键词', + 'placeholder': '支持正式表达式' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrentstates', + 'label': '任务状态', + 'placeholder': '用,分隔多个状态' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 6 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'torrentcategorys', + 'label': '任务分类', + 'placeholder': '用,分隔多个分类' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'samedata', + 'label': '处理辅种', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'mponly', + 'label': '仅MoviePilot任务', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'onlyonce', + 'label': '立即运行一次', + } + } + ] + } + ] + } + ] + } + ], { + "enable": False, + "notify": False, + "onlyonce": False, + "action": 'pause', + "cron": '0 0 0 ? *', + "samedata": False, + "mponly": False, + "size": "", + "ratio": "", + "time": "", + "upspeed": "", + "labels": "", + "pathkeywords": "", + "trackerkeywords": "", + "errorkeywords": "", + "torrentstates": "", + "torrentcategorys": "" + } def get_page(self) -> List[dict]: pass def stop_service(self): - pass - - @eventmanager.register(EventType.HistoryDeleted) - def deletetorrent(self, event): """ - 联动删除下载器中的下载任务 + 退出插件 """ - if not self._enabled: - return - event_info = event.event_data - if not event_info: - return + try: + if self._scheduler: + self._scheduler.remove_all_jobs() + if self._scheduler.running: + self._event.set() + self._scheduler.shutdown() + self._event.clear() + self._scheduler = None + except Exception as e: + print(str(e)) - # TODO 删除所有下载任务 + def __get_downloader(self, dtype: str): + """ + 根据类型返回下载器实例 + """ + if dtype == "qbittorrent": + return self.qb + elif dtype == "transmission": + return self.tr + else: + return None + + def delete_torrents(self): + """ + 定时删除下载器中的下载任务 + """ + for downloader in self._downloaders: + try: + with lock: + # 获取需删除种子列表 + torrents = self.get_remove_torrents(downloader) + logger.info(f"自动删种任务 获取符合处理条件种子数 {len(torrents)}") + # 下载器 + downlader_obj = self.__get_downloader(downloader) + if self._action == "pause": + message_text = f"{downloader.title()} 共暂停{len(torrents)}个种子" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" + # 暂停种子 + downlader_obj.stop_torrents(ids=[torrent.get("id")]) + logger.info(f"自动删种任务 暂停种子:{text_item}") + message_text = f"{message_text}\n{text_item}" + elif self._action == "delete": + message_text = f"{downloader.title()} 共删除{len(torrents)}个种子" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" + # 删除种子 + downlader_obj.delete_torrents(delete_file=False, + ids=[torrent.get("id")]) + logger.info(f"自动删种任务 删除种子:{text_item}") + message_text = f"{message_text}\n{text_item}" + elif self._action == "deletefile": + message_text = f"{downloader.title()} 共删除{len(torrents)}个种子及文件" + for torrent in torrents: + if self._event.is_set(): + logger.info(f"自动删种服务停止") + return + text_item = f"{torrent.get('name')} " \ + f"来自站点:{torrent.get('site')} " \ + f"大小:{StringUtils.str_filesize(torrent.get('size'))} GB" + # 删除种子 + downlader_obj.delete_torrents(delete_file=True, + ids=[torrent.get("id")]) + logger.info(f"自动删种任务 删除种子及文件:{text_item}") + message_text = f"{message_text}\n{text_item}" + else: + continue + if torrents and message_text and self._notify: + self.post_message( + mtype=NotificationType.SiteMessage, + title=f"【自动删种任务执行完成】", + text=message_text + ) + except Exception as e: + logger.error(f"自动删种任务异常:{str(e)}") + + def __get_qb_torrent(self, torrent: Any) -> Optional[dict]: + """ + 检查QB下载任务是否符合条件 + """ + # 完成时间 + date_done = torrent.completion_on if torrent.completion_on > 0 else torrent.added_on + # 现在时间 + date_now = int(time.mktime(datetime.now().timetuple())) + # 做种时间 + torrent_seeding_time = date_now - date_done if date_done else 0 + # 平均上传速度 + torrent_upload_avs = torrent.uploaded / torrent_seeding_time if torrent_seeding_time else 0 + # 大小 单位:GB + sizes = self._size.split(',') if self._size else [] + minsize = sizes[0] * 1024 * 1024 * 1024 if sizes else 0 + maxsize = sizes[-1] * 1024 * 1024 * 1024 if sizes else 0 + # 分享率 + if self._ratio and torrent.ratio <= float(self._ratio): + return None + # 做种时间 单位:小时 + if self._time and torrent_seeding_time <= float(self._time) * 3600: + return None + # 文件大小 + if self._size and (torrent.size >= maxsize or torrent.size <= minsize): + return None + if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: + return None + if self._pathkeywords and not re.findall(self._pathkeywords, torrent.save_path, re.I): + return None + if self._trackerkeywords and not re.findall(self._trackerkeywords, torrent.tracker, re.I): + return None + if self._torrentstates and torrent.state not in self._torrentstates: + return None + if self._torrentcategorys and torrent.category not in self._torrentcategorys: + return None + return { + "id": torrent.hash, + "name": torrent.name, + "site": StringUtils.get_url_sld(torrent.tracker), + "size": torrent.size + } + + def __get_tr_torrent(self, torrent: Any) -> Optional[dict]: + """ + 检查TR下载任务是否符合条件 + """ + # 完成时间 + date_done = torrent.date_done or torrent.date_added + # 现在时间 + date_now = int(time.mktime(datetime.now().timetuple())) + # 做种时间 + torrent_seeding_time = date_now - int(time.mktime(date_done.timetuple())) if date_done else 0 + # 上传量 + torrent_uploaded = torrent.ratio * torrent.total_size + # 平均上传速茺 + torrent_upload_avs = torrent_uploaded / torrent_seeding_time if torrent_seeding_time else 0 + # 大小 单位:GB + sizes = self._size.split(',') if self._size else [] + minsize = sizes[0] * 1024 * 1024 * 1024 if sizes else 0 + maxsize = sizes[-1] * 1024 * 1024 * 1024 if sizes else 0 + # 分享率 + if self._ratio and torrent.ratio <= float(self._ratio): + return None + if self._time and torrent_seeding_time <= float(self._time) * 3600: + return None + if self._size and (torrent.total_size >= maxsize or torrent.total_size <= minsize): + return None + if self._upspeed and torrent_upload_avs >= float(self._upspeed) * 1024: + return None + if self._pathkeywords and not re.findall(self._pathkeywords, torrent.download_dir, re.I): + return None + if self._trackerkeywords: + if not torrent.trackers: + return None + else: + tacker_key_flag = False + for tracker in torrent.trackers: + if re.findall(self._trackerkeywords, tracker.get("announce", ""), re.I): + tacker_key_flag = True + break + if not tacker_key_flag: + return None + if self._errorkeywords and not re.findall(self._errorkeywords, torrent.error_string, re.I): + return None + return { + "id": torrent.hashString, + "name": torrent.name, + "site": torrent.trackers[0].get("sitename") if torrent.trackers else "", + "size": torrent.total_size + } + + def get_remove_torrents(self, downloader: str): + """ + 获取自动删种任务种子 + """ + remove_torrents = [] + # 下载器对象 + downloader_obj = self.__get_downloader(downloader) + # 标题 + if self._labels: + tags = self._labels.split(',') + else: + tags = [] + if self._mponly: + tags.extend(settings.TORRENT_TAG) + # 查询种子 + torrents, error_flag = downloader_obj.get_torrents(tags=tags or None) + if error_flag: + return [] + # 处理种子 + for torrent in torrents: + if downloader == "qbittorrent": + item = self.__get_qb_torrent(torrent) + else: + item = self.__get_tr_torrent(torrent) + if not item: + continue + remove_torrents.append(item) + # 处理辅种 + if self._samedata and remove_torrents: + remove_torrents_plus = [] + for remove_torrent in remove_torrents: + name = remove_torrent.get("name") + size = remove_torrent.get("size") + for torrent in torrents: + if torrent.name == name \ + and torrent.size == size \ + and torrent.hash not in [t.get("id") for t in remove_torrents]: + remove_torrents_plus.append({ + "id": torrent.hash, + "name": torrent.name, + "site": StringUtils.get_url_sld(torrent.tracker), + "size": torrent.size + }) + remove_torrents.extend(remove_torrents_plus) + return remove_torrents diff --git a/app/plugins/torrenttransfer/__init__.py b/app/plugins/torrenttransfer/__init__.py index fa26d4be..79af207d 100644 --- a/app/plugins/torrenttransfer/__init__.py +++ b/app/plugins/torrenttransfer/__init__.py @@ -16,6 +16,7 @@ from app.log import logger from app.modules.qbittorrent import Qbittorrent from app.modules.transmission import Transmission from app.plugins import _PluginBase +from app.schemas import NotificationType from app.utils.string import StringUtils @@ -324,8 +325,7 @@ class TorrentTransfer(_PluginBase): { 'component': 'VCol', 'props': { - 'cols': 12, - 'md': 12 + 'cols': 12 }, 'content': [ { @@ -644,6 +644,7 @@ class TorrentTransfer(_PluginBase): # 发送通知 if self._notify: self.post_message( + mtype=NotificationType.SiteMessage, title="【移转做种任务执行完成】", text=f"总数:{total},成功:{success},失败:{fail}" ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 5dc38971..200b3d92 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -12,3 +12,4 @@ from .mediaserver import * from .message import * from .tmdb import * from .transfer import * +from .rss import * diff --git a/app/schemas/rss.py b/app/schemas/rss.py new file mode 100644 index 00000000..57cdc595 --- /dev/null +++ b/app/schemas/rss.py @@ -0,0 +1,48 @@ +from typing import Optional + +from pydantic import BaseModel + + +class Rss(BaseModel): + id: Optional[int] + # 名称 + name: Optional[str] + # RSS地址 + url: Optional[str] + # 类型 + type: Optional[str] + # 标题 + title: Optional[str] + # 年份 + year: Optional[str] + # TMDBID + tmdbid: Optional[int] + # 季号 + season: Optional[int] + # 海报 + poster: Optional[str] + # 背景图 + backdrop: Optional[str] + # 评分 + vote: Optional[float] + # 简介 + description: Optional[str] + # 包含 + include: Optional[str] + # 排除 + exclude: Optional[str] + # 洗版 + best_version: Optional[int] + # 是否使用代理服务器 + proxy: Optional[int] + # 保存路径 + save_path: Optional[str] + # 附加信息 + note: Optional[str] + # 最后更新时间 + last_update: Optional[str] + # 状态 0-停用,1-启用 + state: Optional[int] + + class Config: + orm_mode = True