diff --git a/package.v2.json b/package.v2.json index 9ab6200..5a23f55 100644 --- a/package.v2.json +++ b/package.v2.json @@ -162,5 +162,17 @@ "v2.0": "兼容MoviePilot V2 版本", "v1.2": "增加不限速路径配置,以应对网盘直链播放的情况" } + }, + "AutoClean": { + "name": "定时清理媒体库", + "description": "定时清理用户下载的种子、源文件、媒体库文件。", + "labels": "媒体库", + "version": "2.0", + "icon": "clean.png", + "author": "thsrite", + "level": 2, + "history": { + "v2.0": "兼容MoviePilot V2 版本" + } } } \ No newline at end of file diff --git a/plugins.v2/autoclean/__init__.py b/plugins.v2/autoclean/__init__.py index d7ba6c5..7d909ac 100644 --- a/plugins.v2/autoclean/__init__.py +++ b/plugins.v2/autoclean/__init__.py @@ -1,20 +1,20 @@ import time from collections import defaultdict from datetime import datetime, timedelta -from pathlib import Path +from typing import Any, List, Dict, Tuple, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from app.chain.transfer import TransferChain +from app import schemas +from app.chain.storage import StorageChain from app.core.config import settings from app.core.event import eventmanager from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.transferhistory_oper import TransferHistoryOper -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple, Optional from app.log import logger +from app.plugins import _PluginBase from app.schemas import NotificationType, DownloadHistory from app.schemas.types import EventType @@ -27,7 +27,7 @@ class AutoClean(_PluginBase): # 插件图标 plugin_icon = "clean.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "2.0" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -205,12 +205,14 @@ class AutoClean(_PluginBase): for history in transferhis_list: # 册除媒体库文件 if clean_type in ["dest", "all"]: - TransferChain().delete_files(Path(history.dest)) + dest_fileitem = schemas.FileItem(**history.dest_fileitem) + StorageChain().delete_file(dest_fileitem) # 删除记录 self._transferhis.delete(history.id) # 删除源文件 if clean_type in ["src", "all"]: - TransferChain().delete_files(Path(history.src)) + src_fileitem = schemas.FileItem(**history.src_fileitem) + StorageChain().delete_file(src_fileitem) # 发送事件 eventmanager.send_event( EventType.DownloadFileDeleted, diff --git a/plugins.v2/bestfilmversion/__init__.py b/plugins.v2/bestfilmversion/__init__.py deleted file mode 100644 index ce0b5f8..0000000 --- a/plugins.v2/bestfilmversion/__init__.py +++ /dev/null @@ -1,708 +0,0 @@ -from datetime import datetime, timedelta -from functools import reduce -from pathlib import Path -from threading import RLock -from typing import Optional, Any, List, Dict, Tuple -from xml.dom.minidom import parseString - -import pytz -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from requests import Response - -from app.chain.subscribe import SubscribeChain -from app.core.config import settings -from app.core.context import MediaInfo -from app.core.event import eventmanager -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.plugins import _PluginBase -from app.schemas import WebhookEventInfo -from app.schemas.types import MediaType, EventType -from app.utils.http import RequestUtils - -lock = RLock() - - -class BestFilmVersion(_PluginBase): - # 插件名称 - plugin_name = "收藏洗版" - # 插件描述 - plugin_desc = "Jellyfin/Emby/Plex点击收藏电影后,自动订阅洗版。" - # 插件图标 - plugin_icon = "like.jpg" - # 插件版本 - plugin_version = "2.3" - # 插件作者 - plugin_author = "wlj" - # 作者主页 - author_url = "https://github.com/developer-wlj" - # 插件配置项ID前缀 - plugin_config_prefix = "bestfilmversion_" - # 加载顺序 - plugin_order = 13 - # 可使用的用户级别 - auth_level = 2 - - # 私有变量 - _scheduler: Optional[BackgroundScheduler] = None - _cache_path: Optional[Path] = None - subscribechain = None - - # 配置属性 - _enabled: bool = False - _cron: str = "" - _notify: bool = False - _webhook_enabled: bool = False - _only_once: bool = False - - def init_plugin(self, config: dict = None): - self._cache_path = settings.TEMP_PATH / "__best_film_version_cache__" - self.subscribechain = SubscribeChain() - - # 停止现有任务 - self.stop_service() - - # 配置 - if config: - self._enabled = config.get("enabled") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._webhook_enabled = config.get("webhook_enabled") - self._only_once = config.get("only_once") - - if self._only_once: - self._only_once = False - self.update_config({ - "enabled": self._enabled, - "cron": self._cron, - "notify": self._notify, - "webhook_enabled": self._webhook_enabled, - "only_once": self._only_once - }) - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self._scheduler.add_job(self.sync, 'date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="立即运行收藏洗版") - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - """ - 获取插件API - [{ - "path": "/xx", - "endpoint": self.xxx, - "methods": ["GET", "POST"], - "summary": "API说明" - }] - """ - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and not self._webhook_enabled: - if self._cron: - return [{ - "id": "BestFilmVersion", - "name": "收藏洗版定时服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync, - "kwargs": {} - }] - return [ - { - "id": "BestFilmVersion", - "name": "收藏洗版定时服务", - "trigger": "interval", - "func": self.sync, - "kwargs": { - "minutes": 30 - } - } - ] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'only_once', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'webhook_enabled', - 'label': 'Webhook', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '5位cron表达式,留空自动' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '支持主动定时获取媒体库数据和Webhook实时触发两种方式,两者只能选其一,' - 'Webhook需要在媒体服务器设置发送Webhook报文。' - 'Plex使用主动获取时,建议执行周期设置大于1小时,' - '收藏Api调用Plex官网接口,有频率限制。' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "notify": False, - "cron": "*/30 * * * *", - "webhook_enabled": False, - "only_once": False - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - title = history.get("title") - poster = history.get("poster") - mtype = history.get("type") - time_str = history.get("time") - tmdbid = history.get("tmdbid") - contents.append( - { - 'component': 'VCard', - 'content': [ - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': poster, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': [ - { - 'component': 'VCardTitle', - 'props': { - 'class': 'ps-1 pe-5 break-words whitespace-break-spaces' - }, - 'content': [ - { - 'component': 'a', - 'props': { - 'href': f"https://www.themoviedb.org/movie/{tmdbid}", - 'target': '_blank' - }, - 'text': title - } - ] - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{mtype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{time_str}' - } - ] - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) - - def sync(self): - """ - 通过流媒体管理工具收藏,自动洗版 - """ - # 获取锁 - _is_lock: bool = lock.acquire(timeout=60) - if not _is_lock: - return - try: - # 读取缓存 - caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] - # 读取历史记录 - history = self.get_data('history') or [] - - # 媒体服务器类型,多个以,分隔 - if not settings.MEDIASERVER: - return - media_servers = settings.MEDIASERVER.split(',') - - # 读取收藏 - all_items = {} - for media_server in media_servers: - if media_server == 'jellyfin': - all_items['jellyfin'] = self.jellyfin_get_items() - elif media_server == 'emby': - all_items['emby'] = self.emby_get_items() - else: - all_items['plex'] = self.plex_get_watchlist() - - 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] - - # 处理所有结果 - for server, all_item in all_items.items(): - # all_item 根据电影名去重 - result = reduce(function, all_item, []) - for data in result: - # 检查缓存 - if data.get('Name') in caches: - continue - - # 获取详情 - if server == 'jellyfin': - item_info_resp = Jellyfin().get_iteminfo(itemid=data.get('Id')) - elif server == 'emby': - item_info_resp = Emby().get_iteminfo(itemid=data.get('Id')) - else: - item_info_resp = self.plex_get_iteminfo(itemid=data.get('Id')) - logger.debug(f'BestFilmVersion插件 item打印 {item_info_resp}') - if not item_info_resp: - continue - - # 只接受Movie类型 - if data.get('Type') != 'Movie': - continue - - # 获取tmdb_id - tmdb_id = item_info_resp.get("tmdbid") if server == 'plex' else item_info_resp.tmdbid - if not tmdb_id: - continue - # 识别媒体信息 - mediainfo: MediaInfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{data.get("Name")},tmdbid:{tmdb_id}') - continue - # 添加订阅 - self.subscribechain.add(mtype=MediaType.MOVIE, - title=mediainfo.title, - year=mediainfo.year, - tmdbid=mediainfo.tmdb_id, - best_version=True, - username="收藏洗版", - exist_ok=True) - # 加入缓存 - caches.append(data.get('Name')) - # 存储历史记录 - if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: - history.append({ - "title": mediainfo.title, - "type": mediainfo.type.value, - "year": mediainfo.year, - "poster": mediainfo.get_poster_image(), - "overview": mediainfo.overview, - "tmdbid": mediainfo.tmdb_id, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - # 保存历史记录 - self.save_data('history', history) - # 保存缓存 - self._cache_path.write_text("\n".join(caches)) - finally: - lock.release() - - def jellyfin_get_items(self) -> List[dict]: - # 获取所有user - users_url = "[HOST]Users?&apikey=[APIKEY]" - users = self.get_users(Jellyfin().get_data(users_url)) - if not users: - logger.info(f"bestfilmversion/users_url: {users_url}") - return [] - all_items = [] - for user in users: - # 根据加入日期 降序排序 - url = "[HOST]Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ - "&SortOrder=Descending" \ - "&Filters=IsFavorite" \ - "&Recursive=true" \ - "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ - "&CollapseBoxSetItems=false" \ - "&ExcludeLocationTypes=Virtual" \ - "&EnableTotalRecordCount=false" \ - "&Limit=20" \ - "&apikey=[APIKEY]" - resp = self.get_items(Jellyfin().get_data(url)) - if not resp: - continue - all_items.extend(resp) - return all_items - - def emby_get_items(self) -> List[dict]: - # 获取所有user - get_users_url = "[HOST]Users?&api_key=[APIKEY]" - users = self.get_users(Emby().get_data(get_users_url)) - if not users: - return [] - all_items = [] - for user in users: - # 根据加入日期 降序排序 - url = "[HOST]emby/Users/" + user + "/Items?SortBy=DateCreated%2CSortName" \ - "&SortOrder=Descending" \ - "&Filters=IsFavorite" \ - "&Recursive=true" \ - "&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo" \ - "&CollapseBoxSetItems=false" \ - "&ExcludeLocationTypes=Virtual" \ - "&EnableTotalRecordCount=false" \ - "&Limit=20&api_key=[APIKEY]" - resp = self.get_items(Emby().get_data(url)) - if not resp: - continue - all_items.extend(resp) - return all_items - - @staticmethod - def get_items(resp: Response): - try: - if resp: - return resp.json().get("Items") or [] - else: - return [] - except Exception as e: - print(str(e)) - return [] - - @staticmethod - def get_users(resp: Response): - try: - if resp: - return [data['Id'] for data in resp.json()] - else: - logger.error(f"BestFilmVersion/Users 未获取到返回数据") - return [] - except Exception as e: - logger.error(f"连接BestFilmVersion/Users 出错:" + str(e)) - return [] - - @staticmethod - def plex_get_watchlist() -> List[dict]: - # 根据加入日期 降序排序 - url = f"https://metadata.provider.plex.tv/library/sections/watchlist/all?type=1&sort=addedAt%3Adesc" \ - f"&X-Plex-Container-Start=0&X-Plex-Container-Size=50" \ - f"&X-Plex-Token={settings.PLEX_TOKEN}" - res = [] - try: - resp = RequestUtils().get_res(url=url) - if resp: - dom = parseString(resp.text) - # 获取文档元素对象 - elem = dom.documentElement - # 获取 指定元素 - eles = elem.getElementsByTagName('Video') - if not eles: - return [] - for ele in eles: - data = {} - # 获取标签中内容 - ele_id = ele.attributes['ratingKey'].nodeValue - ele_title = ele.attributes['title'].nodeValue - ele_type = ele.attributes['type'].nodeValue - _type = "Movie" if ele_type == "movie" else "" - data['Id'] = ele_id - data['Name'] = ele_title - data['Type'] = _type - res.append(data) - return res - else: - logger.error(f"Plex/Watchlist 未获取到返回数据") - return [] - except Exception as e: - logger.error(f"连接Plex/Watchlist 出错:" + str(e)) - return [] - - @staticmethod - def plex_get_iteminfo(itemid) -> dict: - url = f"https://metadata.provider.plex.tv/library/metadata/{itemid}" \ - f"?X-Plex-Token={settings.PLEX_TOKEN}" - try: - resp = RequestUtils(accept_type="application/json, text/plain, */*").get_res(url=url) - if resp: - 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 - - return {'tmdbid': id_list[0].split("/")[-1]} - - return {} - else: - logger.error(f"Plex/Items 未获取到返回数据") - return {} - except Exception as e: - logger.error(f"连接Plex/Items 出错:" + str(e)) - return {} - - @eventmanager.register(EventType.WebhookMessage) - def webhook_message_action(self, event): - - if not self._enabled: - return - if not self._webhook_enabled: - return - - data: WebhookEventInfo = event.event_data - # 排除不是收藏调用 - if data.channel not in ['jellyfin', 'emby', 'plex']: - return - if data.channel in ['emby', 'plex'] and data.event != 'item.rate': - return - if data.channel == 'jellyfin' and data.save_reason != 'UpdateUserRating': - return - logger.info(f'BestFilmVersion/webhook_message_action WebhookEventInfo打印:{data}') - - # 获取锁 - _is_lock: bool = lock.acquire(timeout=60) - if not _is_lock: - return - try: - if not data.tmdb_id: - info = None - if (data.channel == 'jellyfin' - and data.save_reason == 'UpdateUserRating' - and data.item_favorite): - info = Jellyfin().get_iteminfo(itemid=data.item_id) - elif data.channel == 'emby' and data.event == 'item.rate': - info = Emby().get_iteminfo(itemid=data.item_id) - elif data.channel == 'plex' and data.event == 'item.rate': - info = Plex().get_iteminfo(itemid=data.item_id) - logger.debug(f'BestFilmVersion/webhook_message_action item打印:{info}') - if not info: - return - if info.item_type not in ['Movie', 'MOV', 'movie']: - return - # 获取tmdb_id - tmdb_id = info.tmdbid - else: - tmdb_id = data.tmdb_id - if (data.channel == 'jellyfin' - and (data.save_reason != 'UpdateUserRating' or not data.item_favorite)): - return - if data.item_type not in ['Movie', 'MOV', 'movie']: - return - # 识别媒体信息 - mediainfo = self.chain.recognize_media(tmdbid=tmdb_id, mtype=MediaType.MOVIE) - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{data.item_name},tmdbID:{tmdb_id}') - return - # 读取缓存 - caches = self._cache_path.read_text().split("\n") if self._cache_path.exists() else [] - # 检查缓存 - if data.item_name in caches: - return - # 读取历史记录 - history = self.get_data('history') or [] - # 添加订阅 - self.subscribechain.add(mtype=MediaType.MOVIE, - title=mediainfo.title, - year=mediainfo.year, - tmdbid=mediainfo.tmdb_id, - best_version=True, - username="收藏洗版", - exist_ok=True) - # 加入缓存 - caches.append(data.item_name) - # 存储历史记录 - if mediainfo.tmdb_id not in [h.get("tmdbid") for h in history]: - history.append({ - "title": mediainfo.title, - "type": mediainfo.type.value, - "year": mediainfo.year, - "poster": mediainfo.get_poster_image(), - "overview": mediainfo.overview, - "tmdbid": mediainfo.tmdb_id, - "time": datetime.now().strftime("%Y-%m-%d %H:%M:%S") - }) - # 保存历史记录 - self.save_data('history', history) - # 保存缓存 - self._cache_path.write_text("\n".join(caches)) - finally: - lock.release() diff --git a/plugins.v2/cleaninvalidseed/__init__.py b/plugins.v2/cleaninvalidseed/__init__.py deleted file mode 100644 index d041065..0000000 --- a/plugins.v2/cleaninvalidseed/__init__.py +++ /dev/null @@ -1,918 +0,0 @@ -import glob -import os -import shutil -import time -from datetime import datetime, timedelta -from pathlib import Path - -import pytz -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from app.modules.qbittorrent import Qbittorrent -from app.utils.string import StringUtils -from app.schemas.types import EventType -from app.core.event import eventmanager, Event - -from app.core.config import settings -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple, Optional -from app.log import logger -from app.schemas import NotificationType - - -class CleanInvalidSeed(_PluginBase): - # 插件名称 - plugin_name = "清理QB无效做种" - # 插件描述 - plugin_desc = "清理已经被站点删除的种子及源文件,仅支持QB" - # 插件图标 - plugin_icon = "clean_a.png" - # 插件版本 - plugin_version = "2.2" - # 插件作者 - plugin_author = "DzAvril" - # 作者主页 - author_url = "https://github.com/DzAvril" - # 插件配置项ID前缀 - plugin_config_prefix = "cleaninvalidseed" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - _cron = None - _notify = False - _onlyonce = False - _qb = None - _detect_invalid_files = False - _delete_invalid_files = False - _delete_invalid_torrents = False - _notify_all = False - _label_only = False - _label = "" - _download_dirs = "" - _exclude_keywords = "" - _exclude_categories = "" - _exclude_labels = "" - _more_logs = False - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - _error_msg = [ - "torrent not registered with this tracker", - "Torrent not registered with this tracker", - "torrent banned", - "err torrent banned", - ] - _custom_error_msg = "" - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - if config: - self._enabled = config.get("enabled") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._onlyonce = config.get("onlyonce") - self._delete_invalid_torrents = config.get("delete_invalid_torrents") - self._delete_invalid_files = config.get("delete_invalid_files") - self._detect_invalid_files = config.get("detect_invalid_files") - self._notify_all = config.get("notify_all") - self._label_only = config.get("label_only") - self._label = config.get("label") - self._download_dirs = config.get("download_dirs") - self._exclude_keywords = config.get("exclude_keywords") - self._exclude_categories = config.get("exclude_categories") - self._exclude_labels = config.get("exclude_labels") - self._custom_error_msg = config.get("custom_error_msg") - self._more_logs = config.get("more_logs") - self._qb = Qbittorrent() - - # 加载模块 - if self._onlyonce: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"清理无效种子服务启动,立即运行一次") - self._scheduler.add_job( - func=self.clean_invalid_seed, - trigger="date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - name="清理无效种子", - ) - # 关闭一次性开关 - self._onlyonce = False - self._update_config() - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - def _update_config(self): - self.update_config( - { - "onlyonce": False, - "cron": self._cron, - "enabled": self._enabled, - "notify": self._notify, - "delete_invalid_torrents": self._delete_invalid_torrents, - "delete_invalid_files": self._delete_invalid_files, - "detect_invalid_files": self._detect_invalid_files, - "notify_all": self._notify_all, - "label_only": self._label_only, - "label": self._label, - "download_dirs": self._download_dirs, - "exclude_keywords": self._exclude_keywords, - "exclude_categories": self._exclude_categories, - "exclude_labels": self._exclude_labels, - "custom_error_msg": self._custom_error_msg, - "more_logs": self._more_logs, - } - ) - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/detect_invalid_torrents", - "event": EventType.PluginAction, - "desc": "检测无效做种", - "category": "QB", - "data": {"action": "detect_invalid_torrents"}, - }, - { - "cmd": "/delete_invalid_torrents", - "event": EventType.PluginAction, - "desc": "清理无效做种", - "category": "QB", - "data": {"action": "delete_invalid_torrents"}, - }, - { - "cmd": "/detect_invalid_files", - "event": EventType.PluginAction, - "desc": "检测无效源文件", - "category": "QB", - "data": {"action": "detect_invalid_files"}, - }, - { - "cmd": "/delete_invalid_files", - "event": EventType.PluginAction, - "desc": "清理无效源文件", - "category": "QB", - "data": {"action": "delete_invalid_files"}, - }, - { - "cmd": "/toggle_notify_all", - "event": EventType.PluginAction, - "desc": "QB清理插件切换全量通知", - "category": "QB", - "data": {"action": "toggle_notify_all"}, - }, - ] - - @eventmanager.register(EventType.PluginAction) - def handle_commands(self, event: Event): - if event: - event_data = event.event_data - if event_data: - if not ( - event_data.get("action") == "detect_invalid_torrents" - or event_data.get("action") == "delete_invalid_torrents" - or event_data.get("action") == "detect_invalid_files" - or event_data.get("action") == "delete_invalid_files" - or event_data.get("action") == "toggle_notify_all" - ): - return - self.post_message( - channel=event.event_data.get("channel"), - title="开始执行远程命令...", - userid=event.event_data.get("user"), - ) - old_delete_invalid_torrents = self._delete_invalid_torrents - old_detect_invalid_files = self._detect_invalid_files - old_delete_invalid_files = self._delete_invalid_files - if event_data.get("action") == "detect_invalid_torrents": - logger.info("收到远程命令,开始检测无效做种") - self._delete_invalid_torrents = False - self._detect_invalid_files = False - self._delete_invalid_files = False - self.clean_invalid_seed() - elif event_data.get("action") == "delete_invalid_torrents": - logger.info("收到远程命令,开始清理无效做种") - self._delete_invalid_torrents = True - self._detect_invalid_files = False - self._delete_invalid_files = False - self.clean_invalid_seed() - elif event_data.get("action") == "detect_invalid_files": - logger.info("收到远程命令,开始检测无效源文件") - self._delete_invalid_files = False - self.detect_invalid_files() - elif event_data.get("action") == "delete_invalid_files": - logger.info("收到远程命令,开始清理无效源文件") - self._delete_invalid_files = True - self.detect_invalid_files() - elif event_data.get("action") == "toggle_notify_all": - self._notify_all = not self._notify_all - self._update_config() - if self._notify_all: - self.post_message( - channel=event.event_data.get("channel"), - title="已开启全量通知", - userid=event.event_data.get("user"), - ) - else: - self.post_message( - channel=event.event_data.get("channel"), - title="已关闭全量通知", - userid=event.event_data.get("user"), - ) - return - else: - logger.error("收到未知远程命令") - return - self._delete_invalid_torrents = old_delete_invalid_torrents - self._detect_invalid_files = old_detect_invalid_files - self._delete_invalid_files = old_delete_invalid_files - self.post_message( - channel=event.event_data.get("channel"), - title="远程命令执行完成!", - userid=event.event_data.get("user"), - ) - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and self._cron: - return [ - { - "id": "CleanInvalidSeed", - "name": "清理QB无效做种", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.clean_invalid_seed, - "kwargs": {}, - } - ] - - def get_all_torrents(self): - all_torrents, error = self._qb.get_torrents() - if error: - logger.error(f"获取QB种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理QB无效做种】", - text=f"获取QB种子失败,请检查QB配置", - ) - return [] - - if not all_torrents: - logger.warning("QB没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理QB无效做种】", - text=f"QB中没有种子", - ) - return [] - return all_torrents - - def clean_invalid_seed(self): - logger.info("开始清理QB无效做种") - all_torrents = self.get_all_torrents() - temp_invalid_torrents = [] - # tracker未工作,但暂时不能判定为失效做种,需人工判断 - tracker_not_working_torrents = [] - working_tracker_set = set() - exclude_categories = ( - self._exclude_categories.split("\n") if self._exclude_categories else [] - ) - exclude_labels = ( - self._exclude_labels.split("\n") if self._exclude_labels else [] - ) - custom_msgs = ( - self._custom_error_msg.split("\n") if self._custom_error_msg else [] - ) - error_msgs = self._error_msg + custom_msgs - # 第一轮筛选出所有未工作的种子 - for torrent in all_torrents: - trackers = torrent.trackers - is_invalid = True - is_tracker_working = False - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - # 有一个tracker工作即为有效做种 - if (tracker.get("status") == 2) or (tracker.get("status") == 3): - is_tracker_working = True - - if not ( - (tracker.get("status") == 4) and (tracker.get("msg") in error_msgs) - ): - is_invalid = False - working_tracker_set.add(tracker_domian) - - if self._more_logs: - logger.info(f"处理 [{torrent.name}] tracker [{tracker_domian}]: 分类: [{torrent.category}], 标签: [{torrent.tags}], 状态: [{tracker.get('status')}], msg: [{tracker.get('msg')}], is_invalid: [{is_invalid}], is_working: [{is_tracker_working}]") - if is_invalid: - temp_invalid_torrents.append(torrent) - elif not is_tracker_working: - # 排除已暂停的种子 - if not torrent.state_enum.is_paused: - tracker_not_working_torrents.append(torrent) - - logger.info(f"初筛共有{len(temp_invalid_torrents)}个无效做种") - # 第二轮筛选出tracker有正常工作种子而当前种子未工作的,避免因临时关站或tracker失效导致误删的问题 - # 失效做种但通过种子分类排除的种子 - invalid_torrents_exclude_categories = [] - # 失效做种但通过种子标签排除的种子 - invalid_torrents_exclude_labels = [] - # 将invalid_torrents基本信息保存起来,在种子被删除后依然可以打印这些信息 - invalid_torrent_tuple_list = [] - deleted_torrent_tuple_list = [] - for torrent in temp_invalid_torrents: - trackers = torrent.trackers - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - if tracker_domian in working_tracker_set: - # tracker是正常的,说明该种子是无效的 - invalid_torrent_tuple_list.append( - ( - torrent.name, - torrent.category, - torrent.tags, - torrent.size, - tracker_domian, - tracker.msg, - ) - ) - if self._delete_invalid_torrents or self._label_only: - # 检查种子分类和标签是否排除 - is_excluded = False - if torrent.category in exclude_categories: - is_excluded = True - invalid_torrents_exclude_categories.append(torrent) - torrent_labels = [ - tag.strip() for tag in torrent.tags.split(",") - ] - for label in torrent_labels: - if label in exclude_labels: - is_excluded = True - invalid_torrents_exclude_labels.append(torrent) - if not is_excluded: - if self._label_only: - # 仅标记 - self._qb.set_torrents_tag(ids=torrent.get("hash"), tags=[self._label if self._label != "" else "无效做种"]) - else: - # 只删除种子不删除文件,以防其它站点辅种 - self._qb.delete_torrents(False, torrent.get("hash")) - # 标记已处理种子信息 - deleted_torrent_tuple_list.append( - ( - torrent.name, - torrent.category, - torrent.tags, - torrent.size, - tracker_domian, - tracker.msg, - ) - ) - break - invalid_msg = f"检测到{len(invalid_torrent_tuple_list)}个失效做种\n" - tracker_not_working_msg = f"检测到{len(tracker_not_working_torrents)}个tracker未工作做种,请检查种子状态\n" - - if self._label_only or self._delete_invalid_torrents: - if self._label_only: - deleted_msg = f"标记了{len(deleted_torrent_tuple_list)}个失效种子\n" - else: - deleted_msg = f"删除了{len(deleted_torrent_tuple_list)}个失效种子\n" - if len(exclude_categories) != 0: - exclude_categories_msg = f"分类排除{len(invalid_torrents_exclude_categories)}个失效种子未删除,请手动处理\n" - if len(exclude_labels) != 0: - exclude_labels_msg = f"标签排除{len(invalid_torrents_exclude_labels)}个失效种子未删除,请手动处理\n" - for index in range(len(invalid_torrent_tuple_list)): - torrent = invalid_torrent_tuple_list[index] - invalid_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" - - for index in range(len(tracker_not_working_torrents)): - torrent = tracker_not_working_torrents[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - tracker_not_working_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(invalid_torrents_exclude_categories)): - torrent = invalid_torrents_exclude_categories[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - exclude_categories_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(invalid_torrents_exclude_labels)): - torrent = invalid_torrents_exclude_labels[index] - trackers = torrent.trackers - tracker_msg = "" - for tracker in trackers: - if tracker.get("tier") == -1: - continue - tracker_domian = StringUtils.get_url_netloc((tracker.get("url")))[1] - tracker_msg += f" {tracker_domian}:{tracker.msg} " - exclude_labels_msg += f"{index + 1}. {torrent.name},分类:{torrent.category},标签:{torrent.tags}, 大小:{StringUtils.str_filesize(torrent.size)},Trackers: {tracker_msg}\n" - - for index in range(len(deleted_torrent_tuple_list)): - torrent = deleted_torrent_tuple_list[index] - deleted_msg += f"{index + 1}. {torrent[0]},分类:{torrent[1]},标签:{torrent[2]}, 大小:{StringUtils.str_filesize(torrent[3])},Trackers: {torrent[4]}:{torrent[5]}\n" - - # 日志 - logger.info(invalid_msg) - logger.info(tracker_not_working_msg) - if self._delete_invalid_torrents: - logger.info(deleted_msg) - if len(exclude_categories) != 0: - logger.info(exclude_categories_msg) - if len(exclude_labels) != 0: - logger.info(exclude_labels_msg) - # 通知 - if self._notify: - invalid_msg = invalid_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=invalid_msg, - ) - if self._notify_all: - tracker_not_working_msg = tracker_not_working_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=tracker_not_working_msg, - ) - if self._label_only or self._delete_invalid_torrents: - deleted_msg = deleted_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=deleted_msg, - ) - if self._notify_all: - exclude_categories_msg = exclude_categories_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=exclude_categories_msg, - ) - exclude_labels_msg = exclude_labels_msg.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=exclude_labels_msg, - ) - logger.info("检测无效做种任务结束") - if self._detect_invalid_files: - self.detect_invalid_files() - - def detect_invalid_files(self): - logger.info("开始检测未做种的无效源文件") - all_torrents = self.get_all_torrents() - source_path_map = {} - source_paths = [] - total_size = 0 - deleted_file_cnt = 0 - exclude_key_words = ( - self._exclude_keywords.split("\n") if self._exclude_keywords else [] - ) - if not self._download_dirs: - logger.error("未配置下载目录,无法检测未做种无效源文件") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【检测无效源文件】", - text="未配置下载目录,无法检测未做种无效源文件", - ) - return - for path in self._download_dirs.split("\n"): - mp_path, qb_path = path.split(":") - source_path_map[mp_path] = qb_path - source_paths.append(mp_path) - # 所有做种源文件路径 - content_path_set = set() - for torrent in all_torrents: - content_path_set.add(torrent.content_path) - - message = "检测未做种无效源文件:\n" - for source_path_str in source_paths: - source_path = Path(source_path_str) - # 判断source_path是否存在 - if not source_path.exists(): - logger.error(f"{source_path} 不存在,无法检测未做种无效源文件") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【检测无效源文件】", - text=f"{source_path} 不存在,无法检测未做种无效源文件", - ) - continue - source_files = [] - # 获取source_path下的所有文件包括文件夹 - for file in source_path.iterdir(): - source_files.append(file) - for source_file in source_files: - skip = False - for key_word in exclude_key_words: - if key_word in source_file.name: - logger.info(f"{str(source_file)}命中关键字{key_word},不做处理") - skip = True - break - if skip: - continue - # 将mp_path替换成 qb_path - qb_path = (str(source_file)).replace( - source_path_str, source_path_map[source_path_str] - ) - # todo: 优化性能 - is_exist = False - for content_path in content_path_set: - if qb_path in content_path: - is_exist = True - break - - if not is_exist: - deleted_file_cnt += 1 - message += f"{deleted_file_cnt}. {str(source_file)}\n" - total_size += self.get_size(source_file) - if self._delete_invalid_files: - if source_file.is_file(): - source_file.unlink() - elif source_file.is_dir(): - shutil.rmtree(source_file) - - message += f"检测到{deleted_file_cnt}个未做种的无效源文件,共占用{StringUtils.str_filesize(total_size)}空间。\n" - if self._delete_invalid_files: - message += f"***已删除无效源文件,释放{StringUtils.str_filesize(total_size)}空间!***\n" - logger.info(message) - if self._notify: - message = message.replace("_", "\_") - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【清理无效做种】", - text=message, - ) - logger.info("检测无效源文件任务结束") - - def get_size(self, path: Path): - total_size = 0 - if path.is_file(): - return path.stat().st_size - # rglob 方法用于递归遍历所有文件和目录 - for entry in path.rglob("*"): - if entry.is_file(): - total_size += entry.stat().st_size - return total_size - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - "component": "VForm", - "content": [ - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enabled", - "label": "启用插件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify", - "label": "开启通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyonce", - "label": "立即运行一次", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "delete_invalid_torrents", - "label": "删除无效种子(确认无误后再开启)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "detect_invalid_files", - "label": "检测无效源文件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "delete_invalid_files", - "label": "删除无效源文件(确认无误后再开启)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "notify_all", - "label": "全量通知", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "label_only", - "label": "仅标记模式(开启后不会删种)", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "more_logs", - "label": "打印更多日志", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { "cols": 12, "md": 6 }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "cron", - "label": "执行周期", - }, - } - ], - }, - { - "component": "VCol", - "props": { "cols": 12, "md": 6 }, - "content": [ - { - "component": "VTextField", - "props": { - "model": "label", - "label": "增加标签", - "placeholder": "仅标记模式下生效,给待处理的种子打标签", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "download_dirs", - "label": "下载目录映射", - "rows": 2, - "placeholder": "填写要监控的源文件目录,并设置MP和QB的目录映射关系,如/mp/download:/qb/download,多个目录请换行", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "props": {"style": {"margin-top": "0px"}}, - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_keywords", - "label": "过滤删源文件关键字", - "rows": 2, - "placeholder": "多个关键字请换行,仅针对删除源文件", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_categories", - "label": "过滤删种分类", - "rows": 2, - "placeholder": "多个分类请换行,仅针对删除种子", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_labels", - "label": "过滤删种标签", - "rows": 2, - "placeholder": "多个标签请换行,仅针对删除种子", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "custom_error_msg", - "label": "自定义无效做种tracker错误信息", - "rows": 5, - "placeholder": "填入想要清理的种子的tracker错误信息,如'skipping tracker announce (unreachable)',多个信息请换行", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "error", - "variant": "tonal", - "text": "谨慎起见删除种子/源文件功能做了开关,请确认无误后再开启删除功能", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "下载目录映射填入源文件根目录,并设置MP和QB的目录映射关系。如某种子下载的源文件A存放路径为/qb/download/A,则目录映射填入/mp/download:/qb/download,多个目录请换行。注意映射目录不要有多余的'/'", - }, - } - ], - }, - ], - }, - ], - } - ], { - "enabled": False, - "notify": False, - "download_dirs": "", - "delete_invalid_torrents": False, - "delete_invalid_files": False, - "detect_invalid_files": False, - "notify_all": False, - "onlyonce": False, - "cron": "0 0 * * *", - "label_only": False, - "label": "", - "more_logs": False, - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/clouddiskdel/__init__.py b/plugins.v2/clouddiskdel/__init__.py deleted file mode 100644 index 7769171..0000000 --- a/plugins.v2/clouddiskdel/__init__.py +++ /dev/null @@ -1,540 +0,0 @@ -import json -import os -import shutil -import time -from pathlib import Path - -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.log import logger -from app.plugins import _PluginBase -from typing import Any, List, Dict, Tuple - -from app.schemas.types import EventType, MediaImageType, NotificationType, MediaType -from app.utils.system import SystemUtils - - -class CloudDiskDel(_PluginBase): - # 插件名称 - plugin_name = "云盘文件删除" - # 插件描述 - plugin_desc = "媒体库删除strm文件后同步删除云盘资源。" - # 插件图标 - plugin_icon = "clouddisk.png" - # 插件版本 - plugin_version = "1.3" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "clouddiskdel_" - # 加载顺序 - plugin_order = 26 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - # 任务执行间隔 - _paths = {} - _notify = False - _del_history = False - - _video_formats = ('.mp4', '.avi', '.rmvb', '.wmv', '.mov', '.mkv', '.flv', '.ts', '.webm', '.iso', '.mpg') - - def init_plugin(self, config: dict = None): - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._del_history = config.get("del_history") - for path in str(config.get("path")).split("\n"): - paths = path.split(":") - self._paths[paths[0]] = paths[1] - - # 清理插件历史 - if self._del_history: - self.del_data(key="history") - self.update_config({ - "enabled": self._enabled, - "notify": self._notify, - "path": config.get("path"), - "del_history": False - }) - - @eventmanager.register(EventType.PluginAction) - def clouddisk_del(self, event: Event): - if not self._enabled: - return - if not event: - return - - event_data = event.event_data - if not event_data or event_data.get("action") != "networkdisk_del": - return - - logger.info(f"获取到云盘删除请求 {event_data}") - - media_path = event_data.get("media_path") - if not media_path: - logger.error("未获取到删除路径") - return - - media_name = event_data.get("media_name") - tmdb_id = event_data.get("tmdb_id") - media_type = event_data.get("media_type") - season_num = event_data.get("season_num") - episode_num = event_data.get("episode_num") - - # 不是网盘监控路径,直接排除 - cloud_file_flag = False - - # 判断删除媒体路径是否与配置的媒体库路径相符,相符则继续删除,不符则跳过 - for library_path in list(self._paths.keys()): - if str(media_path).startswith(library_path): - cloud_file_flag = True - # 替换网盘路径 - media_path = str(media_path).replace(library_path, self._paths.get(library_path)) - logger.info(f"获取到moviepilot本地云盘挂载路径 {media_path}") - path = Path(media_path) - if path.is_file() or media_path.endswith(".strm"): - # 删除文件、nfo、jpg等同名文件 - pattern = path.stem.replace('[', '?').replace(']', '?') - logger.info(f"开始筛选同名文件 {pattern}") - files = path.parent.glob(f"{pattern}.*") - - remove_flag = False - for file in files: - Path(file).unlink() - logger.info(f"云盘文件 {file} 已删除") - self.__remove_json(file) - remove_flag = True - - if not remove_flag: - for ext in self._video_formats: - file = path.stem + ext - if Path(file).exists(): - Path(file).unlink() - logger.info(f"云盘文件 {file} 已删除") - self.__remove_json(file) - else: - # 非根目录,才删除目录 - shutil.rmtree(path) - # 删除目录 - logger.warn(f"云盘目录 {path} 已删除") - self.__remove_json(path) - - # 判断当前媒体父路径下是否有媒体文件,如有则无需遍历父级 - if not SystemUtils.exits_files(path.parent, settings.RMT_MEDIAEXT): - # 判断父目录是否为空, 为空则删除 - for parent_path in path.parents: - if str(parent_path.parent) != str(path.root): - # 父目录非根目录,才删除父目录 - if not SystemUtils.exits_files(parent_path, settings.RMT_MEDIAEXT): - # 当前路径下没有媒体文件则删除 - shutil.rmtree(parent_path) - logger.warn(f"云盘目录 {parent_path} 已删除") - self.__remove_json(parent_path) - break - - if cloud_file_flag: - # 发送消息 - image = 'https://emby.media/notificationicon.png' - media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - if self._notify: - backrop_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Backdrop, - season=season_num, - episode=episode_num - ) or image - - # 类型 - if media_type == MediaType.MOVIE: - msg = f'电影 {media_name} {tmdb_id}' - # 删除电视剧 - elif media_type == MediaType.TV and not season_num and not episode_num: - msg = f'剧集 {media_name} {tmdb_id}' - # 删除季 S02 - elif media_type == MediaType.TV and season_num and not episode_num: - msg = f'剧集 {media_name} S{season_num} {tmdb_id}' - # 删除剧集S02E02 - elif media_type == MediaType.TV and season_num and episode_num: - msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' - else: - msg = media_name - - # 发送通知 - self.post_message( - mtype=NotificationType.MediaServer, - title="云盘同步删除任务完成", - image=backrop_image, - text=f"{msg}\n" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - ) - - # 读取历史记录 - history = self.get_data('history') or [] - - # 获取poster - poster_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Poster, - ) or image - history.append({ - "type": media_type.value, - "title": media_name, - "path": media_path, - "season": season_num if season_num and str(season_num).isdigit() else None, - "episode": episode_num if episode_num and str(episode_num).isdigit() else None, - "image": poster_image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - }) - - # 保存历史 - self.save_data("history", history) - - def __remove_json(self, path): - """ - 删除json中的文件内容 - """ - try: - # 删除本地缓存文件 - cloud_files_json = os.path.join(settings.PLUGIN_DATA_PATH, "CloudStrm", "cloud_files.json") - if Path(cloud_files_json).exists(): - # 删除json文件中已删除部分文件 - # 尝试加载本地 - with open(cloud_files_json, 'r') as file: - content = file.read() - if content: - __cloud_files = json.loads(content) - if __cloud_files: - if not isinstance(__cloud_files, list): - __cloud_files = [__cloud_files] - if str(path) in __cloud_files: - # 删除已删除文件 - __cloud_files.remove(str(path)) - # 重新写入本地 - file = open(cloud_files_json, 'w') - file.write(json.dumps(__cloud_files)) - file.close() - else: - remove_flag = False - # 删除目录下文件 - for cloud_file in __cloud_files: - if str(cloud_file).startswith(str(path)): - __cloud_files.remove(cloud_file) - remove_flag = True - if remove_flag: - # 重新写入本地 - file = open(cloud_files_json, 'w') - file.write(json.dumps(__cloud_files)) - file.close() - except Exception as e: - print(str(e)) - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [{ - "cmd": "/networkdisk_del", - "event": EventType.PluginAction, - "desc": "云盘文件删除", - "category": "", - "data": { - "action": "networkdisk_del" - } - }] - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '开启通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_history', - 'label': '删除历史', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'path', - 'rows': '2', - 'label': '媒体库路径映射', - 'placeholder': '媒体服务器路径:moviepilot内云盘挂载路径(一行一个)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '需要开启媒体库删除插件且正确配置排除路径。' - '主要针对于strm文件删除后同步删除云盘资源。' - '如遇删除失败,请检查文件权限问题。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '关于路径映射:' - 'emby:/data/series/A.mp4,' - 'moviepilot内云盘挂载路径:/mnt/link/series/A.mp4。' - '路径映射填/data:/mnt/link' - } - } - ] - } - ] - }, - ] - } - ], { - "enabled": False, - "path": "", - "notify": False, - "del_history": False - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - htype = history.get("type") - title = history.get("title") - season = history.get("season") - episode = history.get("episode") - image = history.get("image") - del_time = history.get("del_time") - - if season: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'季:{season}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'集:{episode}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - else: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - - contents.append( - { - 'component': 'VCard', - 'content': [ - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': image, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': sub_contents - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - def stop_service(self): - """ - 退出插件 - """ - pass diff --git a/plugins.v2/crossseed/__init__.py b/plugins.v2/crossseed/__init__.py deleted file mode 100644 index 82fb138..0000000 --- a/plugins.v2/crossseed/__init__.py +++ /dev/null @@ -1,1232 +0,0 @@ -import hashlib -import os -import re -import time -from datetime import datetime, timedelta -from pathlib import Path -from threading import Event -from typing import Any, Dict, List, Optional, Tuple, Union - -import pytz -import requests -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from bencode import bdecode, bencode - -from app.core.config import settings -from app.core.event import eventmanager -from app.db.site_oper import SiteOper -from app.helper.sites import SitesHelper -from app.helper.torrent import TorrentHelper -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.schemas.types import EventType -from app.utils.string import StringUtils -from app.utils.timer import TimerUtils - - -class CSSiteConfig(object): - """ - 站点辅种配置类 - """ - - def __init__( - self, - name: str = None, - url: str = None, - passkey: str = None, - id: int = None, - cookie: str = None, - ua: str = None, - proxy: bool = None, - query_gap: int = 1, - ) -> None: - self.name = name - self.url = url - self.passkey = passkey - self.id = id - self.cookie = cookie - self.ua = ua - self.proxy = proxy - self.query_gap = query_gap - - def get_api_url(self): - if self.name == "憨憨": - return f"{self.url}nexusapi/pieces-hash" - return f"{self.url}api/pieces-hash" - - def get_torrent_url(self, torrent_id: str): - return f"{self.url}download.php?id={torrent_id}&passkey={self.passkey}" - - -class TorInfo: - - def __init__( - self, - site_name: str = None, - torrent_path: str = None, - file_path: str = None, - info_hash: str = None, - pieces_hash: str = None, - torrent_id: str = None, - ) -> None: - self.site_name = site_name - self.torrent_path = torrent_path - self.file_path = file_path - self.info_hash = info_hash - self.pieces_hash = pieces_hash - self.torrent_id = torrent_id - self.torrent_announce = None - - @staticmethod - def local(torrent_path: str, info_hash: str, pieces_hash: str): - - return TorInfo( - torrent_path=torrent_path, info_hash=info_hash, pieces_hash=pieces_hash - ) - - @staticmethod - def remote(site_name: str, pieces_hash: str, torrent_id: str): - return TorInfo( - site_name=site_name, pieces_hash=pieces_hash, torrent_id=torrent_id - ) - - @staticmethod - def from_data(data: bytes) -> Tuple[Optional[Any], Optional[str]]: - try: - torrent = bdecode(data) - info = torrent["info"] - pieces = info["pieces"] - info_hash = hashlib.sha1(bencode(info)).hexdigest() - pieces_hash = hashlib.sha1(pieces).hexdigest() - local_tor = TorInfo(info_hash=info_hash, pieces_hash=pieces_hash) - # 从种子中获取 announce, qb可能存在获取不到的情况,会存在于fastresume文件中 - if "announce" in torrent: - local_tor.torrent_announce = torrent["announce"] - return local_tor, None - except Exception as err: - return None, str(err) - - def get_name_id_tag(self): - return f"{self.site_name}:{self.torrent_id}" - - def get_name_pieces_tag(self): - return f"{self.site_name}:{self.pieces_hash}" - - -class CrossSeedHelper(object): - _version = "0.2.0" - - @staticmethod - def get_local_torrent_info(torrent_path: Path | str) -> Tuple[Optional[TorInfo], str]: - try: - if isinstance(torrent_path, Path): - torrent_data = torrent_path.read_bytes() - else: - with open(torrent_path, "rb") as f: - torrent_data = f.read() - local_tor, err = TorInfo.from_data(torrent_data) - if not local_tor: - return None, err - local_tor.torrent_path = str(torrent_path) - return local_tor, "" - except Exception as err: - return None, str(err) - - @staticmethod - def get_target_torrent( - site: CSSiteConfig, - pieces_hash_set: List[str] - ) -> Tuple[Optional[List[TorInfo]], Optional[str]]: - """ - 返回pieces_hash对应的种子信息,包括站点id,pieces_hash,种子id - """ - headers = { - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": "CrossSeedHelper", - } - data = {"passkey": site.passkey, "pieces_hash": pieces_hash_set} - remote_torrent_infos = [] - try: - response = requests.post( - site.get_api_url(), - headers=headers, - json=data, - timeout=10, - proxies=settings.PROXY if site.proxy else None, - ) - response.raise_for_status() - rsp_body = response.json() - if isinstance(rsp_body["data"], dict): - for pieces_hash, torrent_id in rsp_body["data"].items(): - remote_torrent_infos.append( - TorInfo.remote(site.name, pieces_hash, torrent_id) - ) - time.sleep(site.query_gap) - except requests.exceptions.RequestException as e: - return None, f"站点{site.name}请求失败:{e}" - return remote_torrent_infos, None - - -class CrossSeed(_PluginBase): - # 插件名称 - plugin_name = "青蛙辅种助手" - # 插件描述 - plugin_desc = "参考ReseedPuppy和IYUU辅种插件实现自动辅种,支持站点:青蛙、AGSVPT、麒麟、UBits、聆音、憨憨等。" - # 插件图标 - plugin_icon = "qingwa.png" - # 插件版本 - plugin_version = "2.3" - # 插件作者 - plugin_author = "233@qingwa" - # 作者主页 - author_url = "https://qingwapt.com/" - # 插件配置项ID前缀 - plugin_config_prefix = "cross_seed_" - # 加载顺序 - plugin_order = 17 - # 可使用的用户级别 - auth_level = 2 - - # 私有属性 - _scheduler = None - cross_helper = None - qb = None - tr = None - sites = None - siteoper = None - torrent = None - # 开关 - _enabled = False - _cron = None - _onlyonce = False - _token = None - _downloaders = [] - _sites = [] - _torrentpath = None - _notify = False - _nolabels = None - _nopaths = None - _clearcache = False - # 退出事件 - _event = Event() - _torrent_tags = ["已整理", "辅种"] - # 待校全种子hash清单 - _recheck_torrents = {} - _is_recheck_running = False - # 辅种缓存,出错的种子不再重复辅种,可清除 - _error_caches = [] - # 辅种缓存,辅种成功的种子,可清除 - _success_caches = [] - # 辅种缓存,出错的种子不再重复辅种,且无法清除。种子被删除404等情况 - _permanent_error_caches = [] - _torrentpaths = [] - _site_cs_infos = [] - # 辅种计数 - total = 0 - realtotal = 0 - success = 0 - exist = 0 - fail = 0 - cached = 0 - - def init_plugin(self, config: dict = None): - self.sites = SitesHelper() - self.siteoper = SiteOper() - self.torrent = TorrentHelper() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._onlyonce = config.get("onlyonce") - self._cron = config.get("cron") - self._token = config.get("token") # passkey格式 青蛙:xxxxxx,站点名称:xxxxxxx - - self._downloaders = config.get("downloaders") - self._torrentpath = config.get("torrentpath") # 种子路径和下载器对应 /qb,/tr - self._torrentpaths = self._torrentpath.strip().split(",") - self._sites = config.get("sites") or [] - self._notify = config.get("notify") - self._nolabels = config.get("nolabels") - self._nopaths = config.get("nopaths") - self._clearcache = config.get("clearcache") - self._permanent_error_caches = [] if self._clearcache else config.get("permanent_error_caches") or [] - self._error_caches = [] if self._clearcache else config.get("error_caches") or [] - self._success_caches = [] if self._clearcache else config.get("success_caches") or [] - - # 过滤掉已删除的站点 - inner_site_list = self.siteoper.list_order_by_pri() - all_sites = [(site.id, site.name) for site in inner_site_list] + [ - (site.get("id"), site.get("name")) for site in self.__custom_sites() - ] - self._sites = [site_id for site_id, site_name in all_sites if site_id in self._sites] - - # 整理所有可用内部站点信息 - all_site_cs_info_map: dict[str, CSSiteConfig] = dict() - for site in inner_site_list: - if site.is_active: - all_site_cs_info_map[site.name] = CSSiteConfig( - name=site.name, - url=site.url, - id=site.id, - cookie=site.cookie, - ua=site.ua, - proxy=True if site.proxy else False, - ) - for site in self.__custom_sites(): - all_site_cs_info_map[site.get("name")] = CSSiteConfig( - name=site.get("name"), - url=site.get("url"), - id=site.get("id"), - cookie=site.get("cookie"), - ua=site.get("ua"), - proxy=site.get("proxy"), - ) - self._sites = [site.id for site in all_site_cs_info_map.values() if site.id in self._sites] - site_names = [site.name for site in all_site_cs_info_map.values() if site.id in self._sites] - - # 整理passkey映射关系 - site_name_key_map = dict() - site_name_gap_map = dict() - for site_key in self._token.strip().split("\n"): - site_key_arr = re.split(r"[\s::]+", site_key.strip()) - site_name = site_key_arr[0] - if len(site_key_arr) > 1: - site_name_key_map[site_name] = site_key_arr[1] - if len(site_key_arr) > 2: - if str.isdigit(site_key_arr[2]): - site_name_gap_map[site_name] = int(site_key_arr[2]) - else: - logger.warn( - f"站点{site_name}配置的查询请求间隔时间不为整数,不能生效, 请修改 {site_key_arr[2]}" - ) - - # 只给选中的站点补全站点配置 - self._site_cs_infos: List[CSSiteConfig] = [] - # 根据配置来补充passkey - for site_name in site_names: - site_key = site_name_key_map.get(site_name) - if not site_key: - logger.warning( - f"未找到站点{site_name}的passkey, 请检查passkey配置是否有误,站点{site_name}将跳过辅种") - continue - site_cs_info = all_site_cs_info_map.get(site_name) - site_cs_info.passkey = site_key - # 追加设置的请求间隔时间 - site_query_gap = site_name_gap_map.get(site_name) - if site_query_gap: - site_cs_info.query_gap = site_query_gap - self._site_cs_infos.append(site_cs_info) - - self.__update_config() - - # 停止现有任务 - self.stop_service() - - # 启动定时任务 & 立即运行一次 - if self.get_state() or self._onlyonce: - self.cross_helper = CrossSeedHelper() - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - self.qb = Qbittorrent() - self.tr = Transmission() - - if self._onlyonce: - logger.info(f"辅种服务启动,立即运行一次") - self._scheduler.add_job(self.auto_seed, 'date', - run_date=datetime.now( - tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3) - ) - - # 关闭一次性开关 - self._onlyonce = False - if self._scheduler.get_jobs(): - # 追加种子校验服务 - self._scheduler.add_job(self.check_recheck, 'interval', minutes=3) - # 启动服务 - self._scheduler.print_jobs() - self._scheduler.start() - - if self._clearcache: - # 关闭清除缓存开关 - self._clearcache = False - - if self._clearcache or self._onlyonce: - # 保存配置 - self.__update_config() - - def get_state(self) -> bool: - return True if self._enabled and self._token and self._downloaders and self._torrentpath else False - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self.get_state(): - # 如果开启了定时任务,并且参数齐全 - if self._cron: - return [{ - "id": "CrossSeed", - "name": "青蛙辅种助手", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.auto_seed, - "kwargs": {} - }] - else: - # 随机时间 - triggers = TimerUtils.random_scheduler(num_executions=1, - begin_hour=2, - end_hour=7, - max_interval=290, - min_interval=0) - ret_jobs = [] - for trigger in triggers: - ret_jobs.append({ - "id": f"CrossSeed|{trigger.hour}:{trigger.minute}", - "name": "青蛙辅种助手", - "trigger": "cron", - "func": self.auto_seed, - "kwargs": { - "hour": trigger.hour, - "minute": trigger.minute - } - }) - return ret_jobs - elif self._enabled: - logger.warn(f"青蛙辅种助手插件参数不全,定时任务未正常启动") - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - # 站点的可选项(内置站点 + 自定义站点) - customSites = self.__custom_sites() - - # 站点的可选项 - site_options = ([{"title": site.name, "value": site.id} - for site in self.siteoper.list_order_by_pri()] - + [{"title": site.get("name"), "value": site.get("id")} - for site in customSites]) - # 测试版本,只支持青蛙 - # site_options = [s for s in site_options if s["title"]=="青蛙"] - - 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 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'sites', - 'label': '辅种站点', - 'items': site_options - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'token', - 'label': '站点Passkey', - 'rows': 3, - 'placeholder': '每行一个, 格式为 站点名称:Passkey ,站点名称为上面选择的名称,例如青蛙为 青蛙:xxxxxx 其中xxxxxx替换为你的Passkey' - } - } - ] - }, - - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'chips': True, - 'multiple': True, - 'model': 'downloaders', - 'label': '辅种下载器', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '执行周期', - 'placeholder': '0 0 0 ? *' - } - } - ] - }, - - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 12 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'torrentpath', - 'label': '种子文件目录', - 'placeholder': '多个目录逗号分隔,按下载器顺序对应填写,每个下载器只能有一个种子目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'nolabels', - 'label': '不辅种标签', - 'placeholder': '使用,分隔多个标签' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'nopaths', - 'label': '不辅种数据文件目录', - 'rows': 3, - 'placeholder': '每一行一个目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'clearcache', - 'label': '清除缓存后运行', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '1. 定时任务周期建议每次辅种间隔时间大于1天,不填写每天上午2点到7点随机辅种一次; ' - '2. 支持辅种站点列表:青蛙、AGSVPT、红豆饭、麒麟、UBits、聆音等,配置passkey时,站点名称需严格和上面选项一致,只有选中的站点会辅种,passkey可保存多个; ' - '3. 请勿与IYUU辅种插件同时添加相同站点,可能会有冲突,且意义不大;' - '4. 测试站点是否支持的方法:【站点域名/api/pieces-hash】接口访问返回405则大概率支持 ' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '【进阶设置】如果辅种过程中访问/api/pieces-hash接口偶尔会失败,可以设置请求间隔时间。 ' - '可以在passkey后增加 :3 来将某个站点的请求间隔调整为3秒,3可以改为其他数字,只能为整数,默认请求间隔为1秒。 ' - '示例配置 站点名称:Passkey:3' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "onlyonce": False, - "notify": False, - "clearcache": False, - "cron": "", - "token": "", - "downloaders": [], - "torrentpath": "", - "sites": [], - "nopaths": "", - "nolabels": "" - } - - def get_page(self) -> List[dict]: - pass - - def __update_config(self): - self.update_config({ - "enabled": self._enabled, - "onlyonce": self._onlyonce, - "clearcache": self._clearcache, - "cron": self._cron, - "token": self._token, - "downloaders": self._downloaders, - "torrentpath": self._torrentpath, - "sites": self._sites, - "notify": self._notify, - "nolabels": self._nolabels, - "nopaths": self._nopaths, - "success_caches": self._success_caches, - "error_caches": self._error_caches, - "permanent_error_caches": self._permanent_error_caches - }) - - def __get_downloader(self, dtype: str): - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None - - def auto_seed(self): - """ - 开始辅种 - """ - logger.info("开始辅种任务 ...") - - # 计数器初始化 - self.total = 0 - self.realtotal = 0 - self.success = 0 - self.exist = 0 - self.fail = 0 - self.cached = 0 - # 扫描下载器辅种 - for idx, downloader in enumerate(self._downloaders): - logger.info(f"开始扫描下载器 {downloader} ...") - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中已完成的种子 - torrents = downloader_obj.get_completed_torrents() - if torrents: - logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") - else: - logger.info(f"下载器 {downloader} 没有已完成种子") - continue - hash_strs = [] - for torrent in torrents: - if self._event.is_set(): - logger.info(f"辅种服务停止") - return - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - if hash_str in self._error_caches or hash_str in self._permanent_error_caches: - logger.info(f"种子 {hash_str} 辅种失败且已缓存,跳过 ...") - continue - save_path = self.__get_save_path(torrent, downloader) - # 获取种子文件路径 - torrent_path = Path(self._torrentpaths[idx]) / f"{hash_str}.torrent" - torrent_info = None - if not torrent_path.exists(): - if False and downloader == "qbittorrent": - # qb开启SQLite功能后将不再以hash命名的方式保存torrent文件 - # TODO 导出功能需要qb4.5.0以上版本才支持 - logger.warn(f"QB种子文件不存在:{torrent_path} 尝试远程导出种子") - try: - torrent_data = torrent.export() - torrent_info, err = TorInfo.from_data(torrent_data) - except Exception as e: - err = str(e) - if not torrent_info: - logger.error(f"尝试远程导出种子 {hash_str} 出错 {err}") - continue - else: - logger.error(f"种子文件不存在:{torrent_path}") - continue - - # 读取种子文件具体信息 - if not torrent_info: - torrent_info, err = self.cross_helper.get_local_torrent_info(torrent_path) - if not torrent_info: - logger.error(f"未能读取到种子文件具体信息:{torrent_path} {err}") - continue - - # 用站点+pieces_hash记录该站点是否已经在该下载器中,需要从tracker补充站点名字 - tracker_urls = set() - try: - if downloader == "qbittorrent": - for i in torrent.trackers: - if "https" in i.get("url"): - tracker_urls.add(i.get("url")) - elif downloader == "transmission": - if torrent_info and torrent_info.torrent_announce: - if "https" in torrent_info.torrent_announce: - tracker_urls.add(torrent_info.torrent_announce) - except Exception as err: - logger.warn(f"尝试获取 {downloader} 的tracker出错 {err}") - # 根据tracker补充站点信息 - for tracker in tracker_urls: - # 优先通过passkey获取站点名 - for site_config in self._site_cs_infos: - if site_config.passkey in tracker: - torrent_info.site_name = site_config.name - break - if not torrent_info.site_name: - # 尝试通过域名获取站点信息 - tracker_domain = StringUtils.get_url_domain(tracker) - site_info = self.sites.get_indexer(tracker_domain) - if site_info: - torrent_info.site_name = site_info.get("name") - - if self._nopaths and save_path: - # 过滤不需要转移的路径 - nopath_skip = False - for nopath in self._nopaths.split('\n'): - if os.path.normpath(save_path).startswith(os.path.normpath(nopath)): - logger.info(f"种子 {hash_str} 保存路径 {save_path} 不需要辅种,跳过 ...") - nopath_skip = True - break - if nopath_skip: - continue - - # 获取种子标签 - torrent_labels = self.__get_label(torrent, downloader) - if torrent_labels and self._nolabels: - is_skip = False - for label in self._nolabels.split(','): - if label in torrent_labels: - logger.info(f"种子 {hash_str} 含有不辅种标签 {label},跳过 ...") - is_skip = True - break - if is_skip: - continue - hash_strs.append({ - "hash": hash_str, - "save_path": save_path, - "torrent_info": torrent_info - }) - if hash_strs: - self.__seed_torrents(hash_strs=hash_strs, downloader=downloader) - # 触发校验检查 - self.check_recheck() - else: - logger.info(f"没有需要辅种的种子") - # 保存缓存 - self.__update_config() - # 发送消息 - if self._notify: - if self.success or self.fail: - self.post_message( - mtype=NotificationType.SiteMessage, - title="【青蛙辅种助手辅种任务完成】", - text=f"服务器返回可辅种总数:{self.total}\n" - f"实际可辅种数:{self.realtotal}\n" - f"已存在:{self.exist}\n" - f"成功:{self.success}\n" - f"失败:{self.fail}\n" - f"{self.cached} 条失败记录已加入缓存" - ) - logger.info("辅种任务执行完成") - - def check_recheck(self): - """ - 定时检查下载器中种子是否校验完成,校验完成且完整的自动开始辅种 - """ - if not self._recheck_torrents: - return - if self._is_recheck_running: - return - self._is_recheck_running = True - for downloader in self._downloaders: - # 需要检查的种子 - recheck_torrents = self._recheck_torrents.get(downloader) or [] - if not recheck_torrents: - continue - logger.info(f"开始检查下载器 {downloader} 的校验任务 ...") - # 下载器 - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中的种子状态 - torrents, _ = downloader_obj.get_torrents(ids=recheck_torrents) - if torrents: - can_seeding_torrents = [] - for torrent in torrents: - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - if self.__can_seeding(torrent, downloader): - can_seeding_torrents.append(hash_str) - if can_seeding_torrents: - logger.info(f"共 {len(can_seeding_torrents)} 个任务校验完成,开始辅种 ...") - # 开始任务 - downloader_obj.start_torrents(ids=can_seeding_torrents) - # 去除已经处理过的种子 - self._recheck_torrents[downloader] = list( - set(recheck_torrents).difference(set(can_seeding_torrents))) - elif torrents is None: - logger.info(f"下载器 {downloader} 查询校验任务失败,将在下次继续查询 ...") - continue - else: - logger.info(f"下载器 {downloader} 中没有需要检查的校验任务,清空待处理列表 ...") - self._recheck_torrents[downloader] = [] - self._is_recheck_running = False - - def __seed_torrents(self, hash_strs: list, downloader: str): - """ - 执行所有种子的辅种 - """ - if not hash_strs: - return - logger.info(f"下载器 {downloader} 开始查询辅种,种子总数量:{len(hash_strs)} ...") - - # 每个Hash的保存目录 - save_paths = {} - pieces_hash_set = set() - site_pieces_hash_set = set() - for item in hash_strs: - tor_info: TorInfo = item.get("torrent_info") - save_paths[tor_info.pieces_hash] = item.get("save_path") - pieces_hash_set.add(tor_info.pieces_hash) - if tor_info.site_name: - site_pieces_hash_set.add(tor_info.get_name_pieces_tag()) - - logger.info(f"去重后,总共需要辅种查询的种子数:{len(pieces_hash_set)}") - pieces_hashes = list(pieces_hash_set) - - # 分站点逐个批次辅种 - # 逐个站点查询可辅种数据 - chunk_size = 100 - for site_config in self._site_cs_infos: - # 检查站点是否已经停用 - db_site = self.siteoper.get(site_config.id) - if db_site and not db_site.is_active: - logger.info(f"站点{site_config.name}已停用,跳过辅种") - continue - remote_tors: List[TorInfo] = [] - total_size = len(pieces_hashes) - for i in range(0, len(pieces_hashes), chunk_size): - if self._event.is_set(): - logger.info(f"辅种服务停止") - return - # 切片操作 - chunk = pieces_hashes[i:i + chunk_size] - # 处理分组 - chunk_tors, err_msg = self.cross_helper.get_target_torrent(site_config, chunk) - if not chunk_tors and err_msg: - logger.info( - f"查询站点{site_config.name}可辅种的信息出错 {err_msg},进度={i + 1}/{total_size}" - ) - else: - logger.info( - f"站点{site_config.name}本批次的可辅种/查询数={len(chunk_tors)}/{len(chunk)},进度={i + 1}/{total_size}" - ) - remote_tors = remote_tors + chunk_tors - - logger.info(f"站点{site_config.name}返回可以辅种的种子总数为{len(remote_tors)}") - - # 去除已经下载过的种子 - local_cnt = 0 - not_local_tors = [] - for tor_info in remote_tors: - if ( - tor_info - and tor_info.site_name - and tor_info.pieces_hash - and tor_info.get_name_pieces_tag() in site_pieces_hash_set - ): - local_cnt = local_cnt + 1 - else: - not_local_tors.append(tor_info) - logger.info(f"站点{site_config.name}正在做种或已经辅种过的种子数为{local_cnt}") - - for tor_info in not_local_tors: - if self._event.is_set(): - logger.info(f"辅种服务停止") - return - if not tor_info: - continue - if not tor_info.torrent_id or not tor_info.pieces_hash: - continue - if tor_info.get_name_id_tag() in self._success_caches: - logger.info(f"{tor_info.get_name_id_tag()} 已处理过辅种,跳过 ...") - continue - if tor_info.get_name_id_tag() in self._error_caches or tor_info.get_name_id_tag() in self._permanent_error_caches: - logger.info(f"种子 {tor_info.get_name_id_tag()} 辅种失败且已缓存,跳过 ...") - continue - # 添加任务 - self.__download_torrent(tor=tor_info, site_config=site_config, - downloader=downloader, - save_path=save_paths.get(tor_info.pieces_hash)) - - logger.info(f"下载器 {downloader} 辅种完成") - - def __download(self, downloader: str, content: Union[bytes, str], - save_path: str) -> Optional[str]: - """ - 添加下载任务 - """ - if downloader == "qbittorrent": - # 生成随机Tag - tag = StringUtils.generate_random_str(10) - state = self.qb.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - tag=["已整理", "辅种", tag]) - if not state: - return None - else: - # 获取种子Hash - torrent_hash = self.qb.get_torrent_id_by_tag(tags=tag) - if not torrent_hash: - logger.error(f"{downloader} 下载任务添加成功,但获取任务信息失败!") - return None - return torrent_hash - elif downloader == "transmission": - # 添加任务 - torrent = self.tr.add_torrent(content=content, - download_dir=save_path, - is_paused=True, - labels=["已整理", "辅种"]) - if not torrent: - return None - else: - return torrent.hashString - - logger.error(f"不支持的下载器:{downloader}") - return None - - def __download_torrent( - self, - tor: TorInfo, - site_config: CSSiteConfig, - downloader: str, - save_path: str, - ): - """ - 下载种子 - - """ - self.total += 1 - self.realtotal += 1 - - # 下载种子 - torrent_url = site_config.get_torrent_url(tor.torrent_id) - - # 下载种子文件 - _, content, _, _, error_msg = self.torrent.download_torrent( - url=torrent_url, - cookie=site_config.cookie, - ua=site_config.ua or settings.USER_AGENT, - proxy=True if site_config.proxy else False) - - # 兼容种子无法访问的情况 - if not content or (isinstance(content, bytes) and "你没有该权限".encode(encoding="utf-8") in content): - # 下载失败 - self.fail += 1 - self.cached += 1 - # 加入失败缓存 - if error_msg and ('无法打开链接' in error_msg or '触发站点流控' in error_msg): - self._error_caches.append(tor.get_name_id_tag()) - else: - # 种子不存在的情况 - self._permanent_error_caches.append(tor.get_name_id_tag()) - logger.error(f"下载种子文件失败:{tor.get_name_id_tag()}") - return False - - # 添加任务前查询校验一次,避免重复添加,导致暂停的任务被重新开始 - tmp_tor_info, err_msg = TorInfo.from_data(content) - if tmp_tor_info and tmp_tor_info.info_hash: - tors, msg = self.__get_downloader(downloader).get_torrents(ids=[tmp_tor_info.info_hash]) - if tors: - self.exist += 1 - self._success_caches.append(tor.get_name_id_tag()) - logger.info(f"下载的种子{tor.get_name_id_tag()}已存在, 跳过") - return True - else: - logger.warn(f"获取下载种子的信息出错{err_msg},不能检查该种子是否已暂停") - - # 添加下载,辅种任务默认暂停 - logger.info(f"添加下载任务:{tor.get_name_id_tag()} ...") - download_id = self.__download(downloader=downloader, - content=content, - save_path=save_path) - if not download_id: - # 下载失败 - self.fail += 1 - self.cached += 1 - # 加入失败缓存 - self._error_caches.append(tor.get_name_id_tag()) - return False - else: - self.success += 1 - # 追加校验任务 - logger.info(f"添加校验检查任务:{download_id} ...") - if not self._recheck_torrents.get(downloader): - self._recheck_torrents[downloader] = [] - self._recheck_torrents[downloader].append(download_id) - # 下载成功 - logger.info(f"成功添加辅种下载,站点种子:{tor.get_name_id_tag()}") - # TR会自动校验 - if downloader == "qbittorrent": - # 开始校验种子 - self.__get_downloader(downloader).recheck_torrents(ids=[download_id]) - # 成功也加入缓存,有一些改了路径校验不通过的,手动删除后,下一次又会辅上 - self._success_caches.append(tor.get_name_id_tag()) - return True - - @staticmethod - def __get_hash(torrent: Any, dl_type: str): - """ - 获取种子hash - """ - try: - return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_label(torrent: Any, dl_type: str): - """ - 获取种子标签 - """ - try: - return [str(tag).strip() for tag in torrent.get("tags").split(',')] \ - if dl_type == "qbittorrent" else torrent.labels or [] - except Exception as e: - print(str(e)) - return [] - - @staticmethod - def __can_seeding(torrent: Any, dl_type: str): - """ - 判断种子是否可以做种并处于暂停状态 - """ - try: - return torrent.get("state") == "pausedUP" if dl_type == "qbittorrent" \ - else (torrent.status.stopped and torrent.percent_done == 1) - except Exception as e: - print(str(e)) - return False - - @staticmethod - def __get_save_path(torrent: Any, dl_type: str): - """ - 获取种子保存路径 - """ - try: - return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir - except Exception as e: - print(str(e)) - return "" - - def stop_service(self): - """ - 退出插件 - """ - 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)) - - def __custom_sites(self) -> List[Any]: - custom_sites = [] - custom_sites_config = self.get_config("CustomSites") - if custom_sites_config and custom_sites_config.get("enabled"): - custom_sites = custom_sites_config.get("sites") - return custom_sites - - @eventmanager.register(EventType.SiteDeleted) - def site_deleted(self, event): - """ - 删除对应站点选中 - """ - site_id = event.event_data.get("site_id") - config = self.get_config() - if config: - sites = config.get("sites") - if sites: - if isinstance(sites, str): - sites = [sites] - - # 删除对应站点 - if site_id: - sites = [site for site in sites if int(site) != int(site_id)] - else: - # 清空 - sites = [] - - # 若无站点,则停止 - if len(sites) == 0: - self._enabled = False - - self._sites = sites - # 保存配置 - self.__update_config() diff --git a/plugins.v2/diagparamadjust/__init__.py b/plugins.v2/diagparamadjust/__init__.py deleted file mode 100644 index 791504f..0000000 --- a/plugins.v2/diagparamadjust/__init__.py +++ /dev/null @@ -1,456 +0,0 @@ -import json -import re -from datetime import datetime, timedelta - -from app.modules.emby import Emby -from app.core.config import settings -from app.plugins import _PluginBase -from app.log import logger -from typing import List, Tuple, Dict, Any, Optional -import pytz -from app.schemas import WebhookEventInfo -from app.schemas.types import EventType -from app.core.event import eventmanager, Event - -from apscheduler.triggers.cron import CronTrigger -from apscheduler.schedulers.background import BackgroundScheduler - - -class DiagParamAdjust(_PluginBase): - # 插件名称 - plugin_name = "诊断参数调整" - # 插件描述 - plugin_desc = "Emby专用插件|暂时性解决emby字幕偏移问题,需要emby安装Diagnostics插件。" - # 插件图标 - plugin_icon = "Gatus_A.png" - # 插件版本 - plugin_version = "1.3" - # 插件作者 - plugin_author = "jeblove" - # 作者主页 - author_url = "https://github.com/jeblove" - # 插件配置项ID前缀 - plugin_config_prefix = "dpa_" - # 加载顺序 - plugin_order = 14 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled: bool = False - # 修正字幕偏移用途(播放时执行) - _offset_play = True - _onlyonce = False - _base_url = None - _endpoint = None - _api_key = None - _search_text = None - _replace_text = None - _cron = None - _cron_switch = False - - # 请求接口 - _url = "[HOST]emby/EncodingDiagnostics/DiagnosticOptions?api_key=[APIKEY]" - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - - # 目标消息 - _webhook_actions = { - "playback.start": "开始播放", - } - - # 分辨率标识 - _resolution = None - # 分辨率改动 - _last_resolution = None - # 目标参数 - _target_search_text = None - _target_replace_text = None - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - if config: - self._enabled = config.get("enabled") - self._offset_play = config.get("offset_play") - self._onlyonce = config.get("onlyonce") - self._search_text = config.get("search") - self._replace_text = config.get("replace") - self._cron = config.get("cron") - self._cron_switch = config.get("cron_switch") - - if self._onlyonce: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"诊断参数调整服务启动,立刻运行一次") - self._scheduler.add_job(func=self.run, trigger='date', - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), - name="诊断参数调整") - - # 关闭一次性开关 - self._onlyonce = False - self.update_config({ - "enabled": self._enabled, - "offset_play": self._offset_play, - "onlyonce": False, - "search": self._search_text, - "replace": self._replace_text, - "cron": self._cron, - "cron_switch": self._cron_switch, - }) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and self._cron and self._cron_switch: - return [{ - "id": "DiagParamAdjust", - "name": "诊断参数调整定时服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.run, - "kwargs": {} - }] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'offset_play', - 'label': '修正字幕偏移(播放时执行)', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'search', - 'label': '搜索文本' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'replace', - 'label': '替换文本' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '检测执行周期', - 'placeholder': '*/5 * * * *' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'cron_switch', - 'label': '周期模式', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '- 暂时性解决emby字幕偏移问题,如默认参数不合适请在基础上修改【替换文本】x、y至适合(4K视频情况下!),如[x=W/4:y=h/5]。\n - 【修正字幕偏移(播放时执行)】需要emby配置webhooks消息通知:勾选[播放-开始](具体可参考【媒体库服务器通知】插件)', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '- 播放视频分辨率与上次视频分辨率不一致时,在通知延迟和已加载旧位置字幕影响下,需要片刻后才会加载到新位置字幕,或关闭视频再次打开(建议)。\n - 此替换文本参数应用于emby-Diagnostics-Parameter Adjustment。\n - 默认参数用于修改ffmpeg中字幕覆盖在视频上的位置。\n - 方案来源于https://opve.cn/archives/983.html', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "offset_play": True, - "onlyonce": False, - "search": "x=(W-w)/2:y=(H-h):repeatlast=0", - "replace": "x=W/4:y=h/4:repeatlast=0", - "cron": "*/5 * * * *", - "cron_switch": False, - } - - def detect(self): - """ - 检测是否存在目标参数(修正字幕偏移用途) - - :return True: 存在; False: 不存在 - """ - logger.info('字幕偏移修正,检测目标参数') - try: - res = Emby().get_data(self._url) - result = res.json() - data = result['Object']['CommandLineOptions'] - searchText = data['SearchText'] - replaceText = data['ReplaceText'] - except json.JSONDecodeError: - logger.error('服务停止,Emby请安装【Diagnostics】插件') - return None - except KeyError: - # 已装插件,未设置过该参数 - # logger.info('目标参数为空') - return False - - # 符合所有情况 - if (('repeatlast' in replaceText - and 'x=(W-w)/2:y=(H-h):repeatlast=0' in searchText - and result['Object']['TranscodingOptions']['DisableHardwareSubtitleOverlay'] is True) - or (searchText == "" and replaceText == "")) \ - and self._resolution == self._last_resolution: - # (A or B) and C - return True - - return False - - def set_options(self): - """ - 向Emby发送请求设置参数 - """ - - # 根据分辨率情况而选择是否替换 - if self._resolution == 0 and self._offset_play is True: - # 1080p,不替换(清空文本) - self._target_search_text = "" - self._target_replace_text = "" - logger.info('清空替换参数') - else: - # >1080p or 非字幕偏移用途 - self._target_search_text = self._search_text - self._target_replace_text = self._replace_text - logger.info("替换值为:{}".format(self._target_replace_text)) - - data = { - "CommandLineOptions": { - "SearchText": self._target_search_text, - "ReplaceText": self._target_replace_text - }, - "TranscodingOptions": { - "DisableHardwareSubtitleOverlay": True - } - } - data = json.dumps(data) - headers = { - 'Content-Type': 'application/octet-stream' - } - res = Emby().post_data(self._url, data, headers) - if res.status_code // 100 == 2: - logger.info('参数设置成功') - return True - else: - logger.error('参数设置失败 {}'.format(res.status_code)) - return False - - @eventmanager.register(EventType.WebhookMessage) - def get_msg(self, event: Event): - # 消息方式开关 - if not self._enabled or not self._offset_play: - return - - # 消息获取 - event_info: WebhookEventInfo = event.event_data - if not event_info: - return - - # 非目标消息 - if not self._webhook_actions.get(event_info.event): - return - - # 根据视频名获得分辨率信息 - item_path = event_info.item_path - video_resolution = re.findall(r"\d{3,4}p", item_path) - video_width = int(video_resolution[0][:-1]) - logger.info('视频分辨率:{}'.format(video_width)) - - self._last_resolution = self._resolution - # 分辨率变化情况 - if video_width > 1080: - # 2160p/4k - self._resolution = 1 - else: - self._resolution = 0 - self.run() - - def run(self): - # 字幕偏移修正,则带检测 - if self._offset_play: - state = self.detect() - if state: - logger.info('参数正常,无需修正') - return True - elif state is None: - logger.info('插件退出') - return None - - self.set_options() - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/episodegroupmeta/__init__.py b/plugins.v2/episodegroupmeta/__init__.py deleted file mode 100644 index 7a3be26..0000000 --- a/plugins.v2/episodegroupmeta/__init__.py +++ /dev/null @@ -1,872 +0,0 @@ -import base64 -import json -import threading -import time -from pathlib import Path -from typing import Any, List, Dict, Tuple, Optional, Union - -from pydantic import BaseModel -from requests import RequestException - -from app import schemas -from app.chain.mediaserver import MediaServerChain -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.core.meta import MetaBase -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.modules.plex import Plex -from app.modules.themoviedb.tmdbv3api import TV -from app.plugins import _PluginBase -from app.schemas.types import EventType -from app.utils.common import retry -from app.utils.http import RequestUtils - - -class ExistMediaInfo(BaseModel): - # 类型 电影、电视剧 - type: Optional[schemas.MediaType] - # 季, 集 - groupep: Optional[Dict[int, list]] = {} - # 集在媒体服务器的ID - groupid: Optional[Dict[int, List[list]]] = {} - # 媒体服务器 - server: Optional[str] = None - # 媒体ID - itemid: Optional[Union[str, int]] = None - - -class EpisodeGroupMeta(_PluginBase): - # 插件名称 - plugin_name = "TMDB剧集组刮削" - # 插件描述 - plugin_desc = "从TMDB剧集组刮削季集的实际顺序。" - # 插件图标 - plugin_icon = "Element_A.png" - # 主题色 - plugin_color = "#098663" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "叮叮当" - # 作者主页 - author_url = "https://github.com/cikezhu" - # 插件配置项ID前缀 - plugin_config_prefix = "EpisodeGroupMeta_" - # 加载顺序 - plugin_order = 29 - # 可使用的用户级别 - auth_level = 1 - - # 退出事件 - _event = threading.Event() - - # 私有属性 - mschain = None - tv = None - emby = None - plex = None - jellyfin = None - - _enabled = False - _ignorelock = False - _delay = 0 - _allowlist = [] - - def init_plugin(self, config: dict = None): - self.mschain = MediaServerChain() - self.tv = TV() - self.emby = Emby() - self.plex = Plex() - self.jellyfin = Jellyfin() - if config: - self._enabled = config.get("enabled") - self._ignorelock = config.get("ignorelock") - self._delay = config.get("delay") or 120 - self._allowlist = [] - for s in str(config.get("allowlist", "")).split(","): - s = s.strip() - if s and s not in self._allowlist: - self._allowlist.append(s) - self.log_info(f"白名单数量: {len(self._allowlist)} > {self._allowlist}") - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - 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': 'ignorelock', - 'label': '媒体信息锁定时也进行刮削', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'delay', - 'label': '入库延迟时间(秒)', - 'placeholder': '120' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'allowlist', - 'label': '刮削白名单', - 'rows': 6, - 'placeholder': '使用,分隔电视剧名称' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '注意:刮削白名单(留空), 则全部刮削. 否则仅刮削白名单.' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '注意:如需刮削已经入库的项目, 可通过mp重新整理单集即可.' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "ignorelock": False, - "allowlist": "", - "delay": 120 - } - - def get_page(self) -> List[dict]: - pass - - @eventmanager.register(EventType.TransferComplete) - def scrap_rt(self, event: Event): - """ - 根据事件实时刮削剧集组信息 - """ - if not self.get_state(): - return - # 事件数据 - mediainfo: schemas.MediaInfo = event.event_data.get("mediainfo") - meta: MetaBase = event.event_data.get("meta") - # self.log_error(f"{event.event_data}") - if not mediainfo or not meta: - return - # 非TV类型不处理 - if mediainfo.type != schemas.MediaType.TV: - self.log_warn(f"{mediainfo.title} 非TV类型, 无需处理") - return - # 没有tmdbID不处理 - if not mediainfo.tmdb_id: - self.log_warn(f"{mediainfo.title} 没有tmdbID, 无需处理") - return - if len(self._allowlist) != 0 \ - and mediainfo.title not in self._allowlist: - self.log_warn(f"{mediainfo.title} 不在白名单, 无需处理") - return - # 获取剧集组信息 - try: - episode_groups = self.tv.episode_groups(mediainfo.tmdb_id) - if not episode_groups: - self.log_warn(f"{mediainfo.title} 没有剧集组, 无需处理") - return - self.log_info(f"{mediainfo.title_year} 剧集组数量: {len(episode_groups)} - {episode_groups}") - # episodegroup = self.tv.group_episodes(episode_groups[0].get('id')) - except Exception as e: - self.log_error(f"{mediainfo.title} {str(e)}") - return - # 延迟 - if self._delay: - self.log_warn(f"{mediainfo.title} 将在 {self._delay} 秒后开始处理..") - time.sleep(int(self._delay)) - # 获取可用的媒体服务器 - _existsinfo = self.chain.media_exists(mediainfo=mediainfo) - existsinfo: ExistMediaInfo = self.__media_exists(server=_existsinfo.server, mediainfo=mediainfo, - existsinfo=_existsinfo) - if not existsinfo or not existsinfo.itemid: - self.log_warn(f"{mediainfo.title_year} 在媒体库中不存在") - return - # 新增需要的属性 - existsinfo.server = _existsinfo.server - existsinfo.type = _existsinfo.type - self.log_info(f"{mediainfo.title_year} 存在于媒体服务器: {_existsinfo.server}") - # 获取全部剧集组信息 - copy_keys = ['Id', 'Name', 'ChannelNumber', 'OriginalTitle', 'ForcedSortName', 'SortName', 'CommunityRating', - 'CriticRating', 'IndexNumber', 'ParentIndexNumber', 'SortParentIndexNumber', 'SortIndexNumber', - 'DisplayOrder', 'Album', 'AlbumArtists', 'ArtistItems', 'Overview', 'Status', 'Genres', 'Tags', - 'TagItems', 'Studios', 'PremiereDate', 'DateCreated', 'ProductionYear', 'Video3DFormat', - 'OfficialRating', 'CustomRating', 'People', 'LockData', 'LockedFields', 'ProviderIds', - 'PreferredMetadataLanguage', 'PreferredMetadataCountryCode', 'Taglines'] - for episode_group in episode_groups: - if not bool(existsinfo.groupep): - break - try: - id = episode_group.get('id') - name = episode_group.get('name') - if not id: - continue - # 处理 - self.log_info(f"正在匹配剧集组: {id}") - groups_meta = self.tv.group_episodes(id) - if not groups_meta: - continue - for groups in groups_meta: - if not bool(existsinfo.groupep): - break - # 剧集组中的季 - order = groups.get("order") - # 剧集组中的集列表 - episodes = groups.get("episodes") - if not order or not episodes or len(episodes) == 0: - continue - # 进行集数匹配, 确定剧集组信息 - ep = existsinfo.groupep.get(order) - if not ep or len(ep) != len(episodes): - continue - self.log_info(f"已匹配剧集组: {name}, {id}, 第 {order} 季") - # 遍历全部媒体项并更新 - for _index, _ids in enumerate(existsinfo.groupid.get(order)): - # 提取出媒体库中集id对应的集数index - ep_num = ep[_index] - for _id in _ids: - # 获取媒体服务器媒体项 - iteminfo = self.get_iteminfo(server=existsinfo.server, itemid=_id) - if not iteminfo: - self.log_info(f"未找到媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - continue - # 是否无视项目锁定 - if not self._ignorelock: - if iteminfo.get("LockData") or ( - "Name" in iteminfo.get("LockedFields", []) - and "Overview" in iteminfo.get("LockedFields", [])): - self.log_warn(f"已锁定媒体项 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - continue - # 替换项目数据 - episode = episodes[ep_num - 1] - new_dict = {} - new_dict.update({k: v for k, v in iteminfo.items() if k in copy_keys}) - new_dict["Name"] = episode["name"] - new_dict["Overview"] = episode["overview"] - new_dict["ParentIndexNumber"] = str(order) - new_dict["IndexNumber"] = str(ep_num) - new_dict["LockData"] = True - if episode.get("vote_average"): - new_dict["CommunityRating"] = episode.get("vote_average") - if not new_dict["LockedFields"]: - new_dict["LockedFields"] = [] - self.__append_to_list(new_dict["LockedFields"], "Name") - self.__append_to_list(new_dict["LockedFields"], "Overview") - # 更新数据 - self.set_iteminfo(server=existsinfo.server, itemid=_id, iteminfo=new_dict) - # still_path 图片 - if episode.get("still_path"): - self.set_item_image(server=existsinfo.server, itemid=_id, - imageurl=f"https://{settings.TMDB_IMAGE_DOMAIN}/t/p/original{episode['still_path']}") - self.log_info(f"已修改剧集 - itemid: {_id}, 第 {order} 季, 第 {ep_num} 集") - # 移除已经处理成功的季 - existsinfo.groupep.pop(order, 0) - existsinfo.groupid.pop(order, 0) - continue - except Exception as e: - self.log_warn(f"错误忽略: {str(e)}") - continue - - self.log_info(f"{mediainfo.title_year} 已经运行完毕了..") - - @staticmethod - def __append_to_list(list, item): - if item not in list: - list.append(item) - - def __media_exists(self, server: str, mediainfo: schemas.MediaInfo, - existsinfo: schemas.ExistMediaInfo) -> ExistMediaInfo: - """ - 根据媒体信息,返回剧集列表与剧集ID列表 - :param mediainfo: 媒体信息 - :return: 剧集列表与剧集ID列表 - """ - - def __emby_media_exists(): - # 获取系列id - item_id = None - try: - res = self.emby.get_data(("[HOST]emby/Items?" - "IncludeItemTypes=Series" - "&Fields=ProductionYear" - "&StartIndex=0" - "&Recursive=true" - "&SearchTerm=%s" - "&Limit=10" - "&IncludeSearchTypes=false" - "&api_key=[APIKEY]") % mediainfo.title) - res_items = res.json().get("Items") - if res_items: - for res_item in res_items: - if res_item.get('Name') == mediainfo.title and ( - not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): - item_id = res_item.get('Id') - except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) - if not item_id: - return None - # 验证tmdbid是否相同 - item_info = self.emby.get_iteminfo(item_id) - if item_info: - if mediainfo.tmdb_id and item_info.tmdbid: - if str(mediainfo.tmdb_id) != str(item_info.tmdbid): - self.log_error(f"tmdbid不匹配或不存在") - return None - try: - res_json = self.emby.get_data( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) - if res_json: - tv_item = res_json.json() - res_items = tv_item.get("Items") - group_ep = {} - group_id = {} - for res_item in res_items: - season_index = res_item.get("ParentIndexNumber") - if not season_index: - continue - episode_index = res_item.get("IndexNumber") - if not episode_index: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if res_item.get("Id") not in group_id[season_index][_index]: - group_id[season_index][_index].append(res_item.get("Id")) - # 返回 - return ExistMediaInfo( - itemid=item_id, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __jellyfin_media_exists(): - # 获取系列id - item_id = None - try: - res = self.jellyfin.get_data(url=f"[HOST]Users/[USER]/Items?api_key=[APIKEY]" - f"&searchTerm={mediainfo.title}" - f"&IncludeItemTypes=Series" - f"&Limit=10&Recursive=true") - res_items = res.json().get("Items") - if res_items: - for res_item in res_items: - if res_item.get('Name') == mediainfo.title and ( - not mediainfo.year or str(res_item.get('ProductionYear')) == str(mediainfo.year)): - item_id = res_item.get('Id') - except Exception as e: - self.log_error(f"连接Items出错:" + str(e)) - if not item_id: - return None - # 验证tmdbid是否相同 - item_info = self.jellyfin.get_iteminfo(item_id) - if item_info: - if mediainfo.tmdb_id and item_info.tmdbid: - if str(mediainfo.tmdb_id) != str(item_info.tmdbid): - self.log_error(f"tmdbid不匹配或不存在") - return None - try: - res_json = self.jellyfin.get_data( - "[HOST]emby/Shows/%s/Episodes?Season=&IsMissing=false&api_key=[APIKEY]" % item_id) - if res_json: - tv_item = res_json.json() - res_items = tv_item.get("Items") - group_ep = {} - group_id = {} - for res_item in res_items: - season_index = res_item.get("ParentIndexNumber") - if not season_index: - continue - episode_index = res_item.get("IndexNumber") - if not episode_index: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if res_item.get("Id") not in group_id[season_index][_index]: - group_id[season_index][_index].append(res_item.get("Id")) - # 返回 - return ExistMediaInfo( - itemid=item_id, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __plex_media_exists(): - try: - _plex = self.plex.get_plex() - if not _plex: - return None - if existsinfo.itemid: - videos = _plex.fetchItem(existsinfo.itemid) - else: - # 根据标题和年份模糊搜索,该结果不够准确 - videos = _plex.library.search(title=mediainfo.title, - year=mediainfo.year, - libtype="show") - if (not videos - and mediainfo.original_title - and str(mediainfo.original_title) != str(mediainfo.title)): - videos = _plex.library.search(title=mediainfo.original_title, - year=mediainfo.year, - libtype="show") - if not videos: - return None - if isinstance(videos, list): - videos = videos[0] - video_tmdbid = __get_ids(videos.guids).get('tmdb_id') - if mediainfo.tmdb_id and video_tmdbid: - if str(video_tmdbid) != str(mediainfo.tmdb_id): - self.log_error(f"tmdbid不匹配或不存在") - return None - episodes = videos.episodes() - group_ep = {} - group_id = {} - for episode in episodes: - season_index = episode.seasonNumber - if not season_index: - continue - episode_index = episode.index - if not episode_index: - continue - episode_id = episode.key - if not episode_id: - continue - if season_index not in group_ep: - group_ep[season_index] = [] - group_id[season_index] = [] - if episode_index not in group_ep[season_index]: - group_ep[season_index].append(episode_index) - group_id[season_index].append([]) - # 找到准确的插入索引 - _index = group_ep[season_index].index(episode_index) - if episode_id not in group_id[season_index][_index]: - group_id[season_index][_index].append(episode_id) - # 返回 - return ExistMediaInfo( - itemid=videos.key, - groupep=group_ep, - groupid=group_id - ) - except Exception as e: - self.log_error(f"连接Shows/Id/Episodes出错:{str(e)}") - return None - - def __get_ids(guids: List[Any]) -> 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 - - if server == "emby": - return __emby_media_exists() - elif server == "jellyfin": - return __jellyfin_media_exists() - else: - return __plex_media_exists() - - def get_iteminfo(self, server: str, itemid: str) -> dict: - """ - 获得媒体项详情 - """ - - def __get_emby_iteminfo() -> dict: - """ - 获得Emby媒体项详情 - """ - try: - url = f'[HOST]emby/Users/[USER]/Items/{itemid}?' \ - f'Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.emby.get_data(url=url) - if res: - return res.json() - except Exception as err: - self.log_error(f"获取Emby媒体项详情失败:{str(err)}") - return {} - - def __get_jellyfin_iteminfo() -> dict: - """ - 获得Jellyfin媒体项详情 - """ - try: - url = f'[HOST]Users/[USER]/Items/{itemid}?Fields=ChannelMappingInfo&api_key=[APIKEY]' - res = self.jellyfin.get_data(url=url) - if res: - result = res.json() - if result: - result['FileName'] = Path(result['Path']).name - return result - except Exception as err: - self.log_error(f"获取Jellyfin媒体项详情失败:{str(err)}") - return {} - - def __get_plex_iteminfo() -> dict: - """ - 获得Plex媒体项详情 - """ - iteminfo = {} - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - if 'movie' in plexitem.METADATA_TYPE: - iteminfo['Type'] = 'Movie' - iteminfo['IsFolder'] = False - elif 'episode' in plexitem.METADATA_TYPE: - iteminfo['Type'] = 'Series' - iteminfo['IsFolder'] = False - if 'show' in plexitem.TYPE: - iteminfo['ChildCount'] = plexitem.childCount - iteminfo['Name'] = plexitem.title - iteminfo['Id'] = plexitem.key - iteminfo['ProductionYear'] = plexitem.year - iteminfo['ProviderIds'] = {} - for guid in plexitem.guids: - idlist = str(guid.id).split(sep='://') - if len(idlist) < 2: - continue - iteminfo['ProviderIds'][idlist[0]] = idlist[1] - for location in plexitem.locations: - iteminfo['Path'] = location - iteminfo['FileName'] = Path(location).name - iteminfo['Overview'] = plexitem.summary - iteminfo['CommunityRating'] = plexitem.audienceRating - # 增加锁定属性列表 - iteminfo['LockedFields'] = [] - try: - if plexitem.title.locked: - iteminfo['LockedFields'].append('Name') - except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") - pass - try: - if plexitem.summary.locked: - iteminfo['LockedFields'].append('Overview') - except Exception as err: - logger.warn(f"获取Plex媒体项详情失败:{str(err)}") - pass - return iteminfo - except Exception as err: - self.log_error(f"获取Plex媒体项详情失败:{str(err)}") - return {} - - if server == "emby": - return __get_emby_iteminfo() - elif server == "jellyfin": - return __get_jellyfin_iteminfo() - else: - return __get_plex_iteminfo() - - def set_iteminfo(self, server: str, itemid: str, iteminfo: dict): - """ - 更新媒体项详情 - """ - - def __set_emby_iteminfo(): - """ - 更新Emby媒体项详情 - """ - try: - res = self.emby.post_data( - url=f'[HOST]emby/Items/{itemid}?api_key=[APIKEY]&reqformat=json', - data=json.dumps(iteminfo), - headers={ - "Content-Type": "application/json" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Emby媒体项详情失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Emby媒体项详情失败:{str(err)}") - return False - - def __set_jellyfin_iteminfo(): - """ - 更新Jellyfin媒体项详情 - """ - try: - res = self.jellyfin.post_data( - url=f'[HOST]Items/{itemid}?api_key=[APIKEY]', - data=json.dumps(iteminfo), - headers={ - "Content-Type": "application/json" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Jellyfin媒体项详情失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Jellyfin媒体项详情失败:{str(err)}") - return False - - def __set_plex_iteminfo(): - """ - 更新Plex媒体项详情 - """ - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - if 'CommunityRating' in iteminfo and iteminfo['CommunityRating']: - edits = { - 'audienceRating.value': iteminfo['CommunityRating'], - 'audienceRating.locked': 1 - } - plexitem.edit(**edits) - plexitem.editTitle(iteminfo['Name']).editSummary(iteminfo['Overview']).reload() - return True - except Exception as err: - self.log_error(f"更新Plex媒体项详情失败:{str(err)}") - return False - - if server == "emby": - return __set_emby_iteminfo() - elif server == "jellyfin": - return __set_jellyfin_iteminfo() - else: - return __set_plex_iteminfo() - - @retry(RequestException, logger=logger) - def set_item_image(self, server: str, itemid: str, imageurl: str): - """ - 更新媒体项图片 - """ - - def __download_image(): - """ - 下载图片 - """ - try: - if "doubanio.com" in imageurl: - r = RequestUtils(headers={ - 'Referer': "https://movie.douban.com/" - }, ua=settings.USER_AGENT).get_res(url=imageurl, raise_exception=True) - else: - r = RequestUtils().get_res(url=imageurl, raise_exception=True) - if r: - return base64.b64encode(r.content).decode() - else: - self.log_error(f"{imageurl} 图片下载失败,请检查网络连通性") - except Exception as err: - self.log_error(f"下载图片失败:{str(err)}") - return None - - def __set_emby_item_image(_base64: str): - """ - 更新Emby媒体项图片 - """ - try: - url = f'[HOST]emby/Items/{itemid}/Images/Primary?api_key=[APIKEY]' - res = self.emby.post_data( - url=url, - data=_base64, - headers={ - "Content-Type": "image/png" - } - ) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Emby媒体项图片失败,错误码:{res.status_code}") - return False - except Exception as result: - self.log_error(f"更新Emby媒体项图片失败:{result}") - return False - - def __set_jellyfin_item_image(): - """ - 更新Jellyfin媒体项图片 - # FIXME 改为预下载图片 - """ - try: - url = f'[HOST]Items/{itemid}/RemoteImages/Download?' \ - f'Type=Primary&ImageUrl={imageurl}&ProviderName=TheMovieDb&api_key=[APIKEY]' - res = self.jellyfin.post_data(url=url) - if res and res.status_code in [200, 204]: - return True - else: - self.log_error(f"更新Jellyfin媒体项图片失败,错误码:{res.status_code}") - return False - except Exception as err: - self.log_error(f"更新Jellyfin媒体项图片失败:{err}") - return False - - def __set_plex_item_image(): - """ - 更新Plex媒体项图片 - # FIXME 改为预下载图片 - """ - try: - plexitem = self.plex.get_plex().library.fetchItem(ekey=itemid) - plexitem.uploadPoster(url=imageurl) - return True - except Exception as err: - self.log_error(f"更新Plex媒体项图片失败:{err}") - return False - - if server == "emby": - # 下载图片获取base64 - image_base64 = __download_image() - if image_base64: - return __set_emby_item_image(image_base64) - elif server == "jellyfin": - return __set_jellyfin_item_image() - else: - return __set_plex_item_image() - return None - - def log_error(self, ss: str): - logger.error(f"<{self.plugin_name}> {str(ss)}") - - def log_warn(self, ss: str): - logger.warn(f"<{self.plugin_name}> {str(ss)}") - - def log_info(self, ss: str): - logger.info(f"<{self.plugin_name}> {str(ss)}") - - def stop_service(self): - """ - 停止服务 - """ - pass diff --git a/plugins.v2/mediasyncdel/__init__.py b/plugins.v2/mediasyncdel/__init__.py deleted file mode 100644 index 41cb858..0000000 --- a/plugins.v2/mediasyncdel/__init__.py +++ /dev/null @@ -1,1589 +0,0 @@ -import datetime -import json -import os -import re -import time -from pathlib import Path -from typing import List, Tuple, Dict, Any, Optional - -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger - -from app import schemas -from app.chain.transfer import TransferChain -from app.core.config import settings -from app.core.event import eventmanager, Event -from app.db.models.transferhistory import TransferHistory -from app.log import logger -from app.modules.emby import Emby -from app.modules.jellyfin import Jellyfin -from app.plugins import _PluginBase -from app.schemas.types import NotificationType, EventType, MediaType, MediaImageType - - -class MediaSyncDel(_PluginBase): - # 插件名称 - plugin_name = "媒体文件同步删除" - # 插件描述 - plugin_desc = "同步删除历史记录、源文件和下载任务。" - # 插件图标 - plugin_icon = "mediasyncdel.png" - # 插件版本 - plugin_version = "1.7" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "mediasyncdel_" - # 加载顺序 - plugin_order = 9 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _scheduler: Optional[BackgroundScheduler] = None - _enabled = False - _sync_type: str = "" - _cron: str = "" - _notify = False - _del_source = False - _del_history = False - _exclude_path = None - _library_path = None - _transferchain = None - _transferhis = None - _downloadhis = None - - def init_plugin(self, config: dict = None): - self._transferchain = TransferChain() - self._transferhis = self._transferchain.transferhis - self._downloadhis = self._transferchain.downloadhis - - # 停止现有任务 - self.stop_service() - - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._sync_type = config.get("sync_type") - self._cron = config.get("cron") - self._notify = config.get("notify") - self._del_source = config.get("del_source") - self._del_history = config.get("del_history") - self._exclude_path = config.get("exclude_path") - self._library_path = config.get("library_path") - - # 清理插件历史 - if self._del_history: - self.del_data(key="history") - self.update_config({ - "enabled": self._enabled, - "sync_type": self._sync_type, - "cron": self._cron, - "notify": self._notify, - "del_source": self._del_source, - "del_history": False, - "exclude_path": self._exclude_path, - "library_path": self._library_path - }) - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - pass - - def get_api(self) -> List[Dict[str, Any]]: - return [ - { - "path": "/delete_history", - "endpoint": self.delete_history, - "methods": ["GET"], - "summary": "删除订阅历史记录" - } - ] - - def delete_history(self, key: str, apikey: str): - """ - 删除历史记录 - """ - if apikey != settings.API_TOKEN: - return schemas.Response(success=False, message="API密钥错误") - # 历史记录 - historys = self.get_data('history') - if not historys: - return schemas.Response(success=False, message="未找到历史记录") - # 删除指定记录 - historys = [h for h in historys if h.get("unique") != key] - self.save_data('history', historys) - return schemas.Response(success=True, message="删除成功") - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and str(self._sync_type) == "log": - # 媒体库同步删除日志方式 - if self._cron: - return [{ - "id": "MediaSyncDel", - "name": "媒体库同步删除服务", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync_del_by_log, - "kwargs": {} - }] - else: - return [{ - "id": "MediaSyncDel", - "name": "媒体库同步删除服务", - "trigger": "interval", - "func": self.sync_del_by_log, - "kwargs": {"minutes": 30} - }] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_source', - 'label': '删除源文件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 3 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'del_history', - 'label': '删除历史', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'sync_type', - 'label': '媒体库同步方式', - 'items': [ - {'title': 'Webhook', 'value': 'webhook'}, - {'title': '日志', 'value': 'log'}, - {'title': 'Scripter X', 'value': 'plugin'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '日志检查周期', - 'placeholder': '5位cron表达式,留空自动' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'exclude_path', - 'label': '排除路径' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'library_path', - 'rows': '2', - 'label': '媒体库路径映射', - 'placeholder': '媒体服务器路径:MoviePilot路径(一行一个)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '媒体库同步方式分为Webhook、日志同步和Scripter X:' - '1、Webhook需要Emby4.8.0.45及以上开启媒体删除的Webhook。' - '2、日志同步需要配置检查周期,默认30分钟执行一次。' - '3、Scripter X方式需要emby安装并配置Scripter X插件,无需配置执行周期。' - '4、启用该插件后,非媒体服务器触发的源文件删除,也会同步处理下载器中的下载任务。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '关于路径映射(转移后文件路径):' - 'emby:/data/A.mp4,' - 'moviepilot:/mnt/link/A.mp4。' - '路径映射填/data:/mnt/link。' - '不正确配置会导致查询不到转移记录!(路径一样可不填)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '排除路径:命中排除路径后请求云盘删除插件删除云盘资源。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': 'Scripter X配置文档:' - 'https://github.com/thsrite/' - 'MediaSyncDel/blob/main/MoviePilot/MoviePilot.md' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '路径映射配置文档:' - 'https://github.com/thsrite/MediaSyncDel/blob/main/path.md' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "notify": True, - "del_source": False, - "del_history": False, - "library_path": "", - "sync_type": "webhook", - "cron": "*/30 * * * *", - "exclude_path": "", - } - - def get_page(self) -> List[dict]: - """ - 拼装插件详情页面,需要返回页面配置,同时附带数据 - """ - # 查询同步详情 - historys = self.get_data('history') - if not historys: - return [ - { - 'component': 'div', - 'text': '暂无数据', - 'props': { - 'class': 'text-center', - } - } - ] - # 数据按时间降序排序 - historys = sorted(historys, key=lambda x: x.get('del_time'), reverse=True) - # 拼装页面 - contents = [] - for history in historys: - htype = history.get("type") - title = history.get("title") - unique = history.get("unique") - year = history.get("year") - season = history.get("season") - episode = history.get("episode") - image = history.get("image") - del_time = history.get("del_time") - - if season: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'年份:{year}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'季:{season}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'集:{episode}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - else: - sub_contents = [ - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'类型:{htype}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'标题:{title}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'年份:{year}' - }, - { - 'component': 'VCardText', - 'props': { - 'class': 'pa-0 px-2' - }, - 'text': f'时间:{del_time}' - } - ] - - contents.append( - { - 'component': 'VCard', - 'content': [ - { - "component": "VDialogCloseBtn", - "props": { - 'innerClass': 'absolute top-0 right-0', - }, - 'events': { - 'click': { - 'api': 'plugin/MediaSyncDel/delete_history', - 'method': 'get', - 'params': { - 'key': unique, - 'apikey': settings.API_TOKEN - } - } - }, - }, - { - 'component': 'div', - 'props': { - 'class': 'd-flex justify-space-start flex-nowrap flex-row', - }, - 'content': [ - { - 'component': 'div', - 'content': [ - { - 'component': 'VImg', - 'props': { - 'src': image, - 'height': 120, - 'width': 80, - 'aspect-ratio': '2/3', - 'class': 'object-cover shadow ring-gray-500', - 'cover': True - } - } - ] - }, - { - 'component': 'div', - 'content': sub_contents - } - ] - } - ] - } - ) - - return [ - { - 'component': 'div', - 'props': { - 'class': 'grid gap-3 grid-info-card', - }, - 'content': contents - } - ] - - @eventmanager.register(EventType.WebhookMessage) - def sync_del_by_webhook(self, event: Event): - """ - emby删除媒体库同步删除历史记录 - webhook - """ - if not self._enabled or str(self._sync_type) != "webhook": - return - - event_data = event.event_data - event_type = event_data.event - - # Emby Webhook event_type = library.deleted - if not event_type or str(event_type) != 'library.deleted': - return - - # 媒体类型 - media_type = event_data.media_type - # 媒体名称 - media_name = event_data.item_name - # 媒体路径 - media_path = event_data.item_path - # tmdb_id - tmdb_id = event_data.tmdb_id - # 季数 - season_num = event_data.season_id - # 集数 - episode_num = event_data.episode_id - - """ - 执行删除逻辑 - """ - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - # 发送消息通知网盘删除插件删除网盘资源 - self.eventmanager.send_event(EventType.PluginAction, - { - "action": "networkdisk_del", - "media_path": media_path, - "media_name": media_name, - "tmdb_id": tmdb_id, - "media_type": media_type, - "season_num": season_num, - "episode_num": episode_num, - }) - return - - # 兼容emby webhook season删除没有发送tmdbid - if not tmdb_id and str(media_type) != 'Season': - logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") - return - - self.__sync_del(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - @eventmanager.register(EventType.WebhookMessage) - def sync_del_by_plugin(self, event): - """ - emby删除媒体库同步删除历史记录 - Scripter X插件 - """ - if not self._enabled or str(self._sync_type) != "plugin": - return - - event_data = event.event_data - event_type = event_data.event - - # Scripter X插件 event_type = media_del - if not event_type or str(event_type) != 'media_del': - return - - # Scripter X插件 需要是否虚拟标识 - item_isvirtual = event_data.item_isvirtual - if not item_isvirtual: - logger.error("Scripter X插件方式,item_isvirtual参数未配置,为防止误删除,暂停插件运行") - self.update_config({ - "enabled": False, - "del_source": self._del_source, - "exclude_path": self._exclude_path, - "library_path": self._library_path, - "notify": self._notify, - "cron": self._cron, - "sync_type": self._sync_type, - }) - return - - # 如果是虚拟item,则直接return,不进行删除 - if item_isvirtual == 'True': - return - - # 媒体类型 - media_type = event_data.item_type - # 媒体名称 - media_name = event_data.item_name - # 媒体路径 - media_path = event_data.item_path - # tmdb_id - tmdb_id = event_data.tmdb_id - # 季数 - season_num = event_data.season_id - # 集数 - episode_num = event_data.episode_id - - """ - 执行删除逻辑 - """ - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - # 发送消息通知网盘删除插件删除网盘资源 - self.eventmanager.send_event(EventType.PluginAction, - { - "action": "networkdisk_del", - "media_path": media_path, - "media_name": media_name, - "tmdb_id": tmdb_id, - "media_type": media_type, - "season_num": season_num, - "episode_num": episode_num, - }) - return - - if not tmdb_id or not str(tmdb_id).isdigit(): - logger.error(f"{media_name} 同步删除失败,未获取到TMDB ID,请检查媒体库媒体是否刮削") - return - - self.__sync_del(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - def __sync_del(self, media_type: str, media_name: str, media_path: str, - tmdb_id: int, season_num: str, episode_num: str): - if not media_type: - logger.error(f"{media_name} 同步删除失败,未获取到媒体类型,请检查媒体是否刮削") - return - - # 处理路径映射 (处理同一媒体多分辨率的情况) - if self._library_path: - paths = self._library_path.split("\n") - for path in paths: - sub_paths = path.split(":") - if len(sub_paths) < 2: - continue - media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 兼容重新整理的场景 - if Path(media_path).exists(): - logger.warn(f"转移路径 {media_path} 未被删除或重新生成,跳过处理") - return - - # 查询转移记录 - msg, transfer_history = self.__get_transfer_his(media_type=media_type, - media_name=media_name, - media_path=media_path, - tmdb_id=tmdb_id, - season_num=season_num, - episode_num=episode_num) - - logger.info(f"正在同步删除{msg}") - - if not transfer_history: - logger.warn( - f"{media_type} {media_name} 未获取到可删除数据,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") - return - - # 开始删除 - year = None - del_torrent_hashs = [] - stop_torrent_hashs = [] - error_cnt = 0 - image = 'https://emby.media/notificationicon.png' - for transferhis in transfer_history: - title = transferhis.title - if title not in media_name: - logger.warn( - f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") - continue - image = transferhis.image or image - year = transferhis.year - - # 0、删除转移记录 - self._transferhis.delete(transferhis.id) - - # 删除种子任务 - if self._del_source: - # 1、直接删除源文件 - if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: - self._transferchain.delete_files(Path(transferhis.src)) - if transferhis.download_hash: - try: - # 2、判断种子是否被删除完 - delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( - type=transferhis.type, - src=transferhis.src, - torrent_hash=transferhis.download_hash) - if not success_flag: - error_cnt += 1 - else: - if delete_flag: - del_torrent_hashs += handle_torrent_hashs - else: - stop_torrent_hashs += handle_torrent_hashs - except Exception as e: - logger.error("删除种子失败:%s" % str(e)) - - logger.info(f"同步删除 {msg} 完成!") - - media_type = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - - # 发送消息 - if self._notify: - backrop_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Backdrop, - season=season_num, - episode=episode_num - ) or image - - torrent_cnt_msg = "" - if del_torrent_hashs: - torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" - if stop_torrent_hashs: - stop_cnt = 0 - # 排除已删除 - for stop_hash in set(stop_torrent_hashs): - if stop_hash not in set(del_torrent_hashs): - stop_cnt += 1 - if stop_cnt > 0: - torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" - if error_cnt: - torrent_cnt_msg += f"删种失败{error_cnt}个\n" - # 发送通知 - self.post_message( - mtype=NotificationType.MediaServer, - title="媒体库同步删除任务完成", - image=backrop_image, - text=f"{msg}\n" - f"删除记录{len(transfer_history)}个\n" - f"{torrent_cnt_msg}" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - ) - - # 读取历史记录 - history = self.get_data('history') or [] - - # 获取poster - poster_image = self.chain.obtain_specific_image( - mediaid=tmdb_id, - mtype=media_type, - image_type=MediaImageType.Poster, - ) or image - history.append({ - "type": media_type.value, - "title": media_name, - "year": year, - "path": media_path, - "season": season_num if season_num and str(season_num).isdigit() else None, - "episode": episode_num if episode_num and str(episode_num).isdigit() else None, - "image": poster_image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())), - "unique": f"{media_name}:{tmdb_id}:{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}" - }) - - # 保存历史 - self.save_data("history", history) - - def __get_transfer_his(self, media_type: str, media_name: str, media_path: str, - tmdb_id: int, season_num: str, episode_num: str): - """ - 查询转移记录 - """ - # 季数 - if season_num and str(season_num).isdigit(): - season_num = str(season_num).rjust(2, '0') - else: - season_num = None - # 集数 - if episode_num and str(episode_num).isdigit(): - episode_num = str(episode_num).rjust(2, '0') - else: - episode_num = None - - # 类型 - mtype = MediaType.MOVIE if media_type in ["Movie", "MOV"] else MediaType.TV - - # 删除电影 - if mtype == MediaType.MOVIE: - msg = f'电影 {media_name} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - dest=media_path) - # 删除电视剧 - elif mtype == MediaType.TV and not season_num and not episode_num: - msg = f'剧集 {media_name} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value) - # 删除季 S02 - elif mtype == MediaType.TV and season_num and not episode_num: - if not season_num or not str(season_num).isdigit(): - logger.error(f"{media_name} 季同步删除失败,未获取到具体季") - return - msg = f'剧集 {media_name} S{season_num} {tmdb_id}' - if tmdb_id and str(tmdb_id).isdigit(): - # 根据tmdb_id查询转移记录 - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - season=f'S{season_num}') - else: - # 兼容emby webhook不发送tmdb场景 - transfer_history: List[TransferHistory] = self._transferhis.get_by(mtype=mtype.value, - season=f'S{season_num}', - dest=media_path) - # 删除剧集S02E02 - elif mtype == MediaType.TV and season_num and episode_num: - if not season_num or not str(season_num).isdigit() or not episode_num or not str(episode_num).isdigit(): - logger.error(f"{media_name} 集同步删除失败,未获取到具体集") - return - msg = f'剧集 {media_name} S{season_num}E{episode_num} {tmdb_id}' - transfer_history: List[TransferHistory] = self._transferhis.get_by(tmdbid=tmdb_id, - mtype=mtype.value, - season=f'S{season_num}', - episode=f'E{episode_num}', - dest=media_path) - else: - return "", [] - - return msg, transfer_history - - def sync_del_by_log(self): - """ - emby删除媒体库同步删除历史记录 - 日志方式 - """ - # 读取历史记录 - history = self.get_data('history') or [] - last_time = self.get_data("last_time") or None - del_medias = [] - - # 媒体服务器类型,多个以,分隔 - if not settings.MEDIASERVER: - return - media_servers = settings.MEDIASERVER.split(',') - for media_server in media_servers: - if media_server == 'emby': - del_medias.extend(self.parse_emby_log(last_time)) - elif media_server == 'jellyfin': - del_medias.extend(self.parse_jellyfin_log(last_time)) - elif media_server == 'plex': - # TODO plex解析日志 - return - - if not del_medias: - logger.error("未解析到已删除媒体信息") - return - - # 遍历删除 - last_del_time = None - for del_media in del_medias: - # 删除时间 - del_time = del_media.get("time") - last_del_time = del_time or datetime.datetime.now() - # 媒体类型 Movie|Series|Season|Episode - media_type = del_media.get("type") - # 媒体名称 蜀山战纪 - media_name = del_media.get("name") - # 媒体年份 2015 - media_year = del_media.get("year") - # 媒体路径 /data/series/国产剧/蜀山战纪 (2015)/Season 2/蜀山战纪 - S02E01 - 第1集.mp4 - media_path = del_media.get("path") - # 季数 S02 - media_season = del_media.get("season") - # 集数 E02 - media_episode = del_media.get("episode") - - # 排除路径不处理 - if self._exclude_path and media_path and any( - os.path.abspath(media_path).startswith(os.path.abspath(path)) for path in - self._exclude_path.split(",")): - logger.info(f"媒体路径 {media_path} 已被排除,暂不处理") - self.save_data("last_time", last_del_time) - return - - # 处理路径映射 (处理同一媒体多分辨率的情况) - if self._library_path: - paths = self._library_path.split("\n") - for path in paths: - sub_paths = path.split(":") - if len(sub_paths) < 2: - continue - media_path = media_path.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 获取删除的记录 - # 删除电影 - if media_type == "Movie": - msg = f'电影 {media_name}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - dest=media_path) - # 删除电视剧 - elif media_type == "Series": - msg = f'剧集 {media_name}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year) - # 删除季 S02 - elif media_type == "Season": - msg = f'剧集 {media_name} {media_season}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - season=media_season) - # 删除剧集S02E02 - elif media_type == "Episode": - msg = f'剧集 {media_name} {media_season}{media_episode}' - transfer_history: List[TransferHistory] = self._transferhis.get_by( - title=media_name, - year=media_year, - season=media_season, - episode=media_episode, - dest=media_path) - else: - self.save_data("last_time", last_del_time) - continue - - logger.info(f"正在同步删除 {msg}") - - if not transfer_history: - logger.info(f"未获取到 {msg} 转移记录,请检查路径映射是否配置错误,请检查tmdbid获取是否正确") - self.save_data("last_time", last_del_time) - continue - - logger.info(f"获取到删除历史记录数量 {len(transfer_history)}") - - # 开始删除 - image = 'https://emby.media/notificationicon.png' - del_torrent_hashs = [] - stop_torrent_hashs = [] - error_cnt = 0 - for transferhis in transfer_history: - title = transferhis.title - if title not in media_name: - logger.warn( - f"当前转移记录 {transferhis.id} {title} {transferhis.tmdbid} 与删除媒体{media_name}不符,防误删,暂不自动删除") - self.save_data("last_time", last_del_time) - continue - image = transferhis.image or image - # 0、删除转移记录 - self._transferhis.delete(transferhis.id) - - # 删除种子任务 - if self._del_source: - # 1、直接删除源文件 - if transferhis.src and Path(transferhis.src).suffix in settings.RMT_MEDIAEXT: - self._transferchain.delete_files(Path(transferhis.src)) - if transferhis.download_hash: - try: - # 2、判断种子是否被删除完 - delete_flag, success_flag, handle_torrent_hashs = self.handle_torrent( - type=transferhis.type, - src=transferhis.src, - torrent_hash=transferhis.download_hash) - if not success_flag: - error_cnt += 1 - else: - if delete_flag: - del_torrent_hashs += handle_torrent_hashs - else: - stop_torrent_hashs += handle_torrent_hashs - except Exception as e: - logger.error("删除种子失败:%s" % str(e)) - - logger.info(f"同步删除 {msg} 完成!") - - # 发送消息 - if self._notify: - torrent_cnt_msg = "" - if del_torrent_hashs: - torrent_cnt_msg += f"删除种子{len(set(del_torrent_hashs))}个\n" - if stop_torrent_hashs: - stop_cnt = 0 - # 排除已删除 - for stop_hash in set(stop_torrent_hashs): - if stop_hash not in set(del_torrent_hashs): - stop_cnt += 1 - if stop_cnt > 0: - torrent_cnt_msg += f"暂停种子{stop_cnt}个\n" - self.post_message( - mtype=NotificationType.MediaServer, - title="媒体库同步删除任务完成", - text=f"{msg}\n" - f"删除记录{len(transfer_history)}个\n" - f"{torrent_cnt_msg}" - f"时间 {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))}", - image=image) - - history.append({ - "type": "电影" if media_type == "Movie" else "电视剧", - "title": media_name, - "year": media_year, - "path": media_path, - "season": media_season, - "episode": media_episode, - "image": image, - "del_time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - }) - - # 保存历史 - self.save_data("history", history) - - self.save_data("last_time", last_del_time) - - def handle_torrent(self, type: str, src: str, torrent_hash: str): - """ - 判断种子是否局部删除 - 局部删除则暂停种子 - 全部删除则删除种子 - """ - download_id = torrent_hash - download = settings.DEFAULT_DOWNLOADER - history_key = "%s-%s" % (download, torrent_hash) - plugin_id = "TorrentTransfer" - transfer_history = self.get_data(key=history_key, - plugin_id=plugin_id) - logger.info(f"查询到 {history_key} 转种历史 {transfer_history}") - - handle_torrent_hashs = [] - try: - # 删除本次种子记录 - self._downloadhis.delete_file_by_fullpath(fullpath=src) - - # 根据种子hash查询所有下载器文件记录 - download_files = self._downloadhis.get_files_by_hash(download_hash=torrent_hash) - if not download_files: - logger.error( - f"未查询到种子任务 {torrent_hash} 存在文件记录,未执行下载器文件同步或该种子已被删除") - return False, False, 0 - - # 查询未删除数 - no_del_cnt = 0 - for download_file in download_files: - if download_file and download_file.state and int(download_file.state) == 1: - no_del_cnt += 1 - - if no_del_cnt > 0: - logger.info( - f"查询种子任务 {torrent_hash} 存在 {no_del_cnt} 个未删除文件,执行暂停种子操作") - delete_flag = False - else: - logger.info( - f"查询种子任务 {torrent_hash} 文件已全部删除,执行删除种子操作") - delete_flag = True - - # 如果有转种记录,则删除转种后的下载任务 - if transfer_history and isinstance(transfer_history, dict): - download = transfer_history['to_download'] - download_id = transfer_history['to_download_id'] - delete_source = transfer_history['delete_source'] - - # 删除种子 - if delete_flag: - # 删除转种记录 - self.del_data(key=history_key, plugin_id=plugin_id) - - # 转种后未删除源种时,同步删除源种 - if not delete_source: - logger.info(f"{history_key} 转种时未删除源下载任务,开始删除源下载任务…") - - # 删除源种子 - logger.info(f"删除源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") - self.chain.remove_torrents(torrent_hash) - handle_torrent_hashs.append(torrent_hash) - - # 删除转种后任务 - logger.info(f"删除转种后下载任务:{download} - {download_id}") - # 删除转种后下载任务 - self.chain.remove_torrents(hashs=torrent_hash, - downloader=download) - handle_torrent_hashs.append(download_id) - else: - # 暂停种子 - # 转种后未删除源种时,同步暂停源种 - if not delete_source: - logger.info(f"{history_key} 转种时未删除源下载任务,开始暂停源下载任务…") - - # 暂停源种子 - logger.info(f"暂停源下载器下载任务:{settings.DEFAULT_DOWNLOADER} - {torrent_hash}") - self.chain.stop_torrents(torrent_hash) - handle_torrent_hashs.append(torrent_hash) - - logger.info(f"暂停转种后下载任务:{download} - {download_id}") - # 删除转种后下载任务 - self.chain.stop_torrents(hashs=download_id, downloader=download) - handle_torrent_hashs.append(download_id) - else: - # 未转种de情况 - if delete_flag: - # 删除源种子 - logger.info(f"删除源下载器下载任务:{download} - {download_id}") - self.chain.remove_torrents(download_id) - else: - # 暂停源种子 - logger.info(f"暂停源下载器下载任务:{download} - {download_id}") - self.chain.stop_torrents(download_id) - handle_torrent_hashs.append(download_id) - - # 处理辅种 - handle_torrent_hashs = self.__del_seed(download_id=download_id, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - # 处理合集 - if str(type) == "电视剧": - handle_torrent_hashs = self.__del_collection(src=src, - delete_flag=delete_flag, - torrent_hash=torrent_hash, - download_files=download_files, - handle_torrent_hashs=handle_torrent_hashs) - return delete_flag, True, handle_torrent_hashs - except Exception as e: - logger.error(f"删种失败: {str(e)}") - return False, False, 0 - - def __del_collection(self, src: str, delete_flag: bool, torrent_hash: str, download_files: list, - handle_torrent_hashs: list): - """ - 处理合集 - """ - try: - src_download_files = self._downloadhis.get_files_by_fullpath(fullpath=src) - if src_download_files: - for download_file in src_download_files: - # src查询记录 判断download_hash是否不一致 - if download_file and download_file.download_hash and str(download_file.download_hash) != str( - torrent_hash): - # 查询新download_hash对应files数量 - hash_download_files = self._downloadhis.get_files_by_hash( - download_hash=download_file.download_hash) - # 新download_hash对应files数量 > 删种download_hash对应files数量 = 合集种子 - if hash_download_files \ - and len(hash_download_files) > len(download_files) \ - and hash_download_files[0].id > download_files[-1].id: - # 查询未删除数 - no_del_cnt = 0 - for hash_download_file in hash_download_files: - if hash_download_file and hash_download_file.state and int( - hash_download_file.state) == 1: - no_del_cnt += 1 - if no_del_cnt > 0: - logger.info(f"合集种子 {download_file.download_hash} 文件未完全删除,执行暂停种子操作") - delete_flag = False - - # 删除合集种子 - if delete_flag: - self.chain.remove_torrents(hashs=download_file.download_hash, - downloader=download_file.downloader) - logger.info(f"删除合集种子 {download_file.downloader} {download_file.download_hash}") - else: - # 暂停合集种子 - self.chain.stop_torrents(hashs=download_file.download_hash, - downloader=download_file.downloader) - logger.info(f"暂停合集种子 {download_file.downloader} {download_file.download_hash}") - # 已处理种子+1 - handle_torrent_hashs.append(download_file.download_hash) - - # 处理合集辅种 - handle_torrent_hashs = self.__del_seed(download_id=download_file.download_hash, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - except Exception as e: - logger.error(f"处理 {torrent_hash} 合集失败") - print(str(e)) - - return handle_torrent_hashs - - def __del_seed(self, download_id, delete_flag, handle_torrent_hashs): - """ - 删除辅种 - """ - # 查询是否有辅种记录 - history_key = download_id - plugin_id = "IYUUAutoSeed" - seed_history = self.get_data(key=history_key, - plugin_id=plugin_id) or [] - logger.info(f"查询到 {history_key} 辅种历史 {seed_history}") - - # 有辅种记录则处理辅种 - if seed_history and isinstance(seed_history, list): - for history in seed_history: - downloader = history.get("downloader") - torrents = history.get("torrents") - if not downloader or not torrents: - return - if not isinstance(torrents, list): - torrents = [torrents] - - # 删除辅种历史 - for torrent in torrents: - handle_torrent_hashs.append(torrent) - # 删除辅种 - if delete_flag: - logger.info(f"删除辅种:{downloader} - {torrent}") - self.chain.remove_torrents(hashs=torrent, - downloader=downloader) - # 暂停辅种 - else: - self.chain.stop_torrents(hashs=torrent, download=downloader) - logger.info(f"辅种:{downloader} - {torrent} 暂停") - - # 处理辅种的辅种 - handle_torrent_hashs = self.__del_seed(download_id=torrent, - delete_flag=delete_flag, - handle_torrent_hashs=handle_torrent_hashs) - - # 删除辅种历史 - if delete_flag: - self.del_data(key=history_key, - plugin_id=plugin_id) - return handle_torrent_hashs - - @staticmethod - def parse_emby_log(last_time): - """ - 获取emby日志列表、解析emby日志 - """ - - def __parse_log(file_name: str, del_list: list): - """ - 解析emby日志 - """ - log_url = f"[HOST]System/Logs/{file_name}?api_key=[APIKEY]" - log_res = Emby().get_data(log_url) - if not log_res or log_res.status_code != 200: - logger.error("获取emby日志失败,请检查服务器配置") - return del_list - - # 正则解析删除的媒体信息 - pattern = r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.\d{3}) Info App: Removing item from database, Type: (\w+), Name: (.*), Path: (.*), Id: (\d+)' - matches = re.findall(pattern, log_res.text) - - # 循环获取媒体信息 - for match in matches: - mtime = match[0] - # 排除已处理的媒体信息 - if last_time and mtime < last_time: - continue - - mtype = match[1] - name = match[2] - path = match[3] - - year = None - year_pattern = r'\(\d+\)' - year_match = re.search(year_pattern, path) - if year_match: - year = year_match.group()[1:-1] - - season = None - episode = None - if mtype == 'Episode' or mtype == 'Season': - name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" - season_pattern = r"Season\s*(\d+)" - episode_pattern = r"S\d+E(\d+)" - name_match = re.search(name_pattern, path) - season_match = re.search(season_pattern, path) - episode_match = re.search(episode_pattern, path) - - if name_match: - name = name_match.group(1) - - if season_match: - season = season_match.group(1) - if int(season) < 10: - season = f'S0{season}' - else: - season = f'S{season}' - else: - season = None - - if episode_match: - episode = episode_match.group(1) - episode = f'E{episode}' - else: - episode = None - - media = { - "time": mtime, - "type": mtype, - "name": name, - "year": year, - "path": path, - "season": season, - "episode": episode, - } - logger.debug(f"解析到删除媒体:{json.dumps(media)}") - del_list.append(media) - - return del_list - - log_files = [] - try: - # 获取所有emby日志 - log_list_url = "[HOST]System/Logs/Query?Limit=3&api_key=[APIKEY]" - log_list_res = Emby().get_data(log_list_url) - - if log_list_res and log_list_res.status_code == 200: - log_files_dict = json.loads(log_list_res.text) - for item in log_files_dict.get("Items"): - if str(item.get('Name')).startswith("embyserver"): - log_files.append(str(item.get('Name'))) - except Exception as e: - print(str(e)) - - if not log_files: - log_files.append("embyserver.txt") - - del_medias = [] - log_files.reverse() - for log_file in log_files: - del_medias = __parse_log(file_name=log_file, - del_list=del_medias) - - return del_medias - - @staticmethod - def parse_jellyfin_log(last_time: datetime): - """ - 获取jellyfin日志列表、解析jellyfin日志 - """ - - def __parse_log(file_name: str, del_list: list): - """ - 解析jellyfin日志 - """ - log_url = f"[HOST]System/Logs/Log?name={file_name}&api_key=[APIKEY]" - log_res = Jellyfin().get_data(log_url) - if not log_res or log_res.status_code != 200: - logger.error("获取jellyfin日志失败,请检查服务器配置") - return del_list - - # 正则解析删除的媒体信息 - pattern = r'\[(.*?)\].*?Removing item, Type: "(.*?)", Name: "(.*?)", Path: "(.*?)"' - matches = re.findall(pattern, log_res.text) - - # 循环获取媒体信息 - for match in matches: - mtime = match[0] - # 排除已处理的媒体信息 - if last_time and mtime < last_time: - continue - - mtype = match[1] - name = match[2] - path = match[3] - - year = None - year_pattern = r'\(\d+\)' - year_match = re.search(year_pattern, path) - if year_match: - year = year_match.group()[1:-1] - - season = None - episode = None - if mtype == 'Episode' or mtype == 'Season': - name_pattern = r"\/([\u4e00-\u9fa5]+)(?= \()" - season_pattern = r"Season\s*(\d+)" - episode_pattern = r"S\d+E(\d+)" - name_match = re.search(name_pattern, path) - season_match = re.search(season_pattern, path) - episode_match = re.search(episode_pattern, path) - - if name_match: - name = name_match.group(1) - - if season_match: - season = season_match.group(1) - if int(season) < 10: - season = f'S0{season}' - else: - season = f'S{season}' - else: - season = None - - if episode_match: - episode = episode_match.group(1) - episode = f'E{episode}' - else: - episode = None - - media = { - "time": mtime, - "type": mtype, - "name": name, - "year": year, - "path": path, - "season": season, - "episode": episode, - } - logger.debug(f"解析到删除媒体:{json.dumps(media)}") - del_list.append(media) - - return del_list - - log_files = [] - try: - # 获取所有jellyfin日志 - log_list_url = "[HOST]System/Logs?api_key=[APIKEY]" - log_list_res = Jellyfin().get_data(log_list_url) - - if log_list_res and log_list_res.status_code == 200: - log_files_dict = json.loads(log_list_res.text) - for item in log_files_dict: - if str(item.get('Name')).startswith("log_"): - log_files.append(str(item.get('Name'))) - except Exception as e: - print(str(e)) - - if not log_files: - log_files.append("log_%s.log" % datetime.date.today().strftime("%Y%m%d")) - - del_medias = [] - log_files.reverse() - for log_file in log_files: - del_medias = __parse_log(file_name=log_file, - del_list=del_medias) - - return del_medias - - def get_state(self): - return self._enabled - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) - - @eventmanager.register(EventType.DownloadFileDeleted) - def downloadfile_del_sync(self, event: Event): - """ - 下载文件删除处理事件 - """ - if not event: - return - event_data = event.event_data - src = event_data.get("src") - if not src: - return - # 查询下载hash - download_hash = self._downloadhis.get_hash_by_fullpath(src) - if download_hash: - download_history = self._downloadhis.get_by_hash(download_hash) - self.handle_torrent(type=download_history.type, src=src, torrent_hash=download_hash) - else: - logger.warn(f"未查询到文件 {src} 对应的下载记录") - - @staticmethod - def get_tmdbimage_url(path: str, prefix="w500"): - if not path: - return "" - tmdb_image_url = f"https://{settings.TMDB_IMAGE_DOMAIN}" - return tmdb_image_url + f"/t/p/{prefix}{path}" diff --git a/plugins.v2/messageforward/__init__.py b/plugins.v2/messageforward/__init__.py deleted file mode 100644 index 7a6b940..0000000 --- a/plugins.v2/messageforward/__init__.py +++ /dev/null @@ -1,430 +0,0 @@ -import json -import re -from datetime import datetime - -from app.core.config import settings -from app.plugins import _PluginBase -from app.core.event import eventmanager -from app.schemas.types import EventType, MessageChannel -from app.utils.http import RequestUtils -from typing import Any, List, Dict, Tuple, Optional -from app.log import logger - - -class MessageForward(_PluginBase): - # 插件名称 - plugin_name = "消息转发" - # 插件描述 - plugin_desc = "根据正则转发通知到其他WeChat应用。" - # 插件图标 - plugin_icon = "forward.png" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "messageforward_" - # 加载顺序 - plugin_order = 16 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - _wechat = None - _pattern = None - _pattern_token = {} - - # 企业微信发送消息URL - _send_msg_url = f"{settings.WECHAT_PROXY}/cgi-bin/message/send?access_token=%s" - # 企业微信获取TokenURL - _token_url = f"{settings.WECHAT_PROXY}/cgi-bin/gettoken?corpid=%s&corpsecret=%s" - - def init_plugin(self, config: dict = None): - if config: - self._enabled = config.get("enabled") - self._wechat = config.get("wechat") - self._pattern = config.get("pattern") - - # 获取token存库 - if self._enabled and self._wechat: - self.__save_wechat_token() - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '开启转发' - } - } - ] - }, - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'wechat', - 'rows': '5', - 'label': '应用配置', - 'placeholder': 'appid:corpid:appsecret(一行一个配置)' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'pattern', - 'rows': '6', - 'label': '正则配置', - 'placeholder': '对应上方应用配置,一行一个,一一对应' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '根据正则表达式,把MoviePilot的消息转发到多个微信应用。' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '应用配置可加注释:' - 'appid:corpid:appsecret#站点通知' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "wechat": "", - "pattern": "" - } - - def get_page(self) -> List[dict]: - pass - - @eventmanager.register(EventType.NoticeMessage) - def send(self, event): - """ - 消息转发 - """ - if not self._enabled: - return - - # 消息体 - data = event.event_data - channel = data['channel'] - if channel and channel != MessageChannel.Wechat: - return - - title = data['title'] - text = data['text'] - image = data['image'] - userid = data['userid'] - - # 正则匹配 - patterns = self._pattern.split("\n") - for index, pattern in enumerate(patterns): - msg_match = re.search(pattern, title) - if msg_match: - access_token, appid = self.__flush_access_token(index) - if not access_token: - logger.error("未获取到有效token,请检查配置") - continue - - # 发送消息 - if image: - self.__send_image_message(title, text, image, userid, access_token, appid, index) - else: - self.__send_message(title, text, userid, access_token, appid, index) - - def __save_wechat_token(self): - """ - 获取并存储wechat token - """ - # 解析配置 - wechats = self._wechat.split("\n") - for index, wechat in enumerate(wechats): - # 排除注释 - wechat = wechat.split("#")[0] - wechat_config = wechat.split(":") - if len(wechat_config) != 3: - logger.error(f"{wechat} 应用配置不正确") - continue - appid = wechat_config[0] - corpid = wechat_config[1] - appsecret = wechat_config[2] - - # 已过期,重新获取token - access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, - appsecret=appsecret) - if not access_token: - # 没有token,获取token - logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") - continue - - self._pattern_token[index] = { - "appid": appid, - "corpid": corpid, - "appsecret": appsecret, - "access_token": access_token, - "expires_in": expires_in, - "access_token_time": access_token_time, - } - - def __flush_access_token(self, index: int, force: bool = False): - """ - 获取第i个配置wechat token - """ - wechat_token = self._pattern_token[index] - if not wechat_token: - logger.error(f"未获取到第 {index} 条正则对应的wechat应用token,请检查配置") - return None - access_token = wechat_token['access_token'] - expires_in = wechat_token['expires_in'] - access_token_time = wechat_token['access_token_time'] - appid = wechat_token['appid'] - corpid = wechat_token['corpid'] - appsecret = wechat_token['appsecret'] - - # 判断token有效期 - if force or (datetime.now() - access_token_time).seconds >= expires_in: - # 重新获取token - access_token, expires_in, access_token_time = self.__get_access_token(corpid=corpid, - appsecret=appsecret) - if not access_token: - logger.error(f"wechat配置 appid = {appid} 获取token失败,请检查配置") - return None, None - - self._pattern_token[index] = { - "appid": appid, - "corpid": corpid, - "appsecret": appsecret, - "access_token": access_token, - "expires_in": expires_in, - "access_token_time": access_token_time, - } - return access_token, appid - - def __send_message(self, title: str, text: str = None, userid: str = None, access_token: str = None, - appid: str = None, index: int = None) -> Optional[bool]: - """ - 发送文本消息 - :param title: 消息标题 - :param text: 消息内容 - :param userid: 消息发送对象的ID,为空则发给所有人 - :return: 发送状态,错误信息 - """ - 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": appid, - "text": { - "content": conent - }, - "safe": 0, - "enable_id_trans": 0, - "enable_duplicate_check": 0 - } - return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) - - def __send_image_message(self, title: str, text: str, image_url: str, userid: str = None, - access_token: str = None, appid: str = None, index: int = None) -> Optional[bool]: - """ - 发送图文消息 - :param title: 消息标题 - :param text: 消息内容 - :param image_url: 图片地址 - :param userid: 消息发送对象的ID,为空则发给所有人 - :return: 发送状态,错误信息 - """ - if text: - text = text.replace("\n\n", "\n") - if not userid: - userid = "@all" - req_json = { - "touser": userid, - "msgtype": "news", - "agentid": appid, - "news": { - "articles": [ - { - "title": title, - "description": text, - "picurl": image_url, - "url": '' - } - ] - } - } - return self.__post_request(access_token=access_token, req_json=req_json, index=index, title=title) - - def __post_request(self, access_token: str, req_json: dict, index: int, title: str, retry: int = 0) -> bool: - message_url = self._send_msg_url % access_token - """ - 向微信发送请求 - """ - 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: - logger.info(f"转发消息 {title} 成功") - return True - else: - if ret_json.get('errcode') == 81013: - return False - - logger.error(f"转发消息 {title} 失败,错误信息:{ret_json}") - if ret_json.get('errcode') == 42001 or ret_json.get('errcode') == 40014: - logger.info("token已过期,正在重新刷新token重试") - # 重新获取token - access_token, appid = self.__flush_access_token(index=index, - force=True) - if access_token: - retry += 1 - # 重发请求 - if retry <= 3: - return self.__post_request(access_token=access_token, - req_json=req_json, - index=index, - title=title, - retry=retry) - return False - elif res is not None: - logger.error(f"转发消息 {title} 失败,错误码:{res.status_code},错误原因:{res.reason}") - return False - else: - logger.error(f"转发消息 {title} 失败,未获取到返回信息") - return False - except Exception as err: - logger.error(f"转发消息 {title} 异常,错误信息:{str(err)}") - return False - - def __get_access_token(self, corpid: str, appsecret: str): - """ - 获取微信Token - :return: 微信Token - """ - try: - token_url = self._token_url % (corpid, appsecret) - res = RequestUtils().get_res(token_url) - if res: - ret_json = res.json() - if ret_json.get('errcode') == 0: - access_token = ret_json.get('access_token') - expires_in = ret_json.get('expires_in') - access_token_time = datetime.now() - - return access_token, expires_in, access_token_time - else: - logger.error(f"{ret_json.get('errmsg')}") - return None, None, None - else: - logger.error(f"{corpid} {appsecret} 获取token失败") - return None, None, None - except Exception as e: - logger.error(f"获取微信access_token失败,错误信息:{str(e)}") - return None, None, None - - def stop_service(self): - """ - 退出插件 - """ - pass diff --git a/plugins.v2/qbcommand/__init__.py b/plugins.v2/qbcommand/__init__.py deleted file mode 100644 index 78f6916..0000000 --- a/plugins.v2/qbcommand/__init__.py +++ /dev/null @@ -1,1171 +0,0 @@ -from typing import List, Tuple, Dict, Any -from enum import Enum -from urllib.parse import urlparse -import urllib -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.plugins import _PluginBase -from app.schemas import NotificationType -from app.schemas.types import EventType -from apscheduler.triggers.cron import CronTrigger -from app.core.event import eventmanager, Event -from apscheduler.schedulers.background import BackgroundScheduler -from app.core.config import settings -from app.helper.sites import SitesHelper -from app.db.site_oper import SiteOper -from app.utils.string import StringUtils -from datetime import datetime, timedelta -import pytz -import time - - -class QbCommand(_PluginBase): - # 插件名称 - plugin_name = "QB远程操作" - # 插件描述 - plugin_desc = "通过定时任务或交互命令远程操作QB暂停/开始/限速等" - # 插件图标 - plugin_icon = "Qbittorrent_A.png" - # 插件版本 - plugin_version = "1.5" - # 插件作者 - plugin_author = "DzAvril" - # 作者主页 - author_url = "https://github.com/DzAvril" - # 插件配置项ID前缀 - plugin_config_prefix = "qbcommand_" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _sites = None - _siteoper = None - _qb = None - _enabled: bool = False - _notify: bool = False - _pause_cron = None - _resume_cron = None - _only_pause_once = False - _only_resume_once = False - _only_pause_upload = False - _only_pause_download = False - _only_pause_checking = False - _upload_limit = 0 - _enable_upload_limit = False - _download_limit = 0 - _enable_download_limit = False - _op_site_ids = [] - _op_sites = [] - _multi_level_root_domain = ["edu.cn", "com.cn", "net.cn", "org.cn"] - _scheduler = None - _exclude_dirs = "" - def init_plugin(self, config: dict = None): - self._sites = SitesHelper() - self._siteoper = SiteOper() - # 停止现有任务 - self.stop_service() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._pause_cron = config.get("pause_cron") - self._resume_cron = config.get("resume_cron") - self._only_pause_once = config.get("onlypauseonce") - self._only_resume_once = config.get("onlyresumeonce") - self._only_pause_upload = config.get("onlypauseupload") - self._only_pause_download = config.get("onlypausedownload") - self._only_pause_checking = config.get("onlypausechecking") - self._download_limit = config.get("download_limit") - self._upload_limit = config.get("upload_limit") - self._enable_download_limit = config.get("enable_download_limit") - self._enable_upload_limit = config.get("enable_upload_limit") - self._qb = Qbittorrent() - self._op_site_ids = config.get("op_site_ids") or [] - # 查询所有站点 - all_sites = [site for site in self._sites.get_indexers() if not site.get("public")] + self.__custom_sites() - # 过滤掉没有选中的站点 - self._op_sites = [site for site in all_sites if site.get("id") in self._op_site_ids] - self._exclude_dirs = config.get("exclude_dirs") or "" - - if self._only_pause_once or self._only_resume_once: - if self._only_pause_once and self._only_resume_once: - logger.warning("只能选择一个: 立即暂停或立即开始所有任务") - elif self._only_pause_once: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - ) - elif self._only_resume_once: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次开始所有任务") - self._scheduler.add_job( - self.resume_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - ) - - self._only_resume_once = False - self._only_pause_once = False - self.update_config( - { - "onlypauseonce": False, - "onlyresumeonce": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - "op_site_ids": self._op_site_ids, - "exclude_dirs": self._exclude_dirs, - } - ) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - if ( - self._only_pause_upload - or self._only_pause_download - or self._only_pause_checking - ): - if self._only_pause_upload: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有上传任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.UPLOADING - } - ) - if self._only_pause_download: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有下载任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.DOWNLOADING - } - ) - if self._only_pause_checking: - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - logger.info(f"立即运行一次暂停所有检查任务") - self._scheduler.add_job( - self.pause_torrent, - "date", - run_date=datetime.now(tz=pytz.timezone(settings.TZ)) - + timedelta(seconds=3), - kwargs={ - 'type': self.TorrentType.CHECKING - } - ) - - self._only_pause_upload = False - self._only_pause_download = False - self._only_pause_checking = False - self.update_config( - { - "onlypauseupload": False, - "onlypausedownload": False, - "onlypausechecking": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - "op_site_ids": self._op_site_ids, - } - ) - - # 启动任务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - self.set_limit(self._upload_limit, self._download_limit) - - def get_state(self) -> bool: - return self._enabled - - class TorrentType(Enum): - ALL = 1 - DOWNLOADING = 2 - UPLOADING = 3 - CHECKING = 4 - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/pause_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB所有任务", - "category": "QB", - "data": {"action": "pause_torrents"}, - }, - { - "cmd": "/pause_upload_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB上传任务", - "category": "QB", - "data": {"action": "pause_upload_torrents"}, - }, - { - "cmd": "/pause_download_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB下载任务", - "category": "QB", - "data": {"action": "pause_download_torrents"}, - }, - { - "cmd": "/pause_checking_torrents", - "event": EventType.PluginAction, - "desc": "暂停QB检查任务", - "category": "QB", - "data": {"action": "pause_checking_torrents"}, - }, - { - "cmd": "/resume_torrents", - "event": EventType.PluginAction, - "desc": "开始QB所有任务", - "category": "QB", - "data": {"action": "resume_torrents"}, - }, - { - "cmd": "/qb_status", - "event": EventType.PluginAction, - "desc": "QB当前任务状态", - "category": "QB", - "data": {"action": "qb_status"}, - }, - { - "cmd": "/toggle_upload_limit", - "event": EventType.PluginAction, - "desc": "QB切换上传限速状态", - "category": "QB", - "data": {"action": "toggle_upload_limit"}, - }, - { - "cmd": "/toggle_download_limit", - "event": EventType.PluginAction, - "desc": "QB切换下载限速状态", - "category": "QB", - "data": {"action": "toggle_download_limit"}, - }, - ] - - def __custom_sites(self) -> List[Any]: - custom_sites = [] - custom_sites_config = self.get_config("CustomSites") - if custom_sites_config and custom_sites_config.get("enabled"): - custom_sites = custom_sites_config.get("sites") - return custom_sites - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and self._pause_cron and self._resume_cron: - return [ - { - "id": "QbPause", - "name": "暂停QB所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - }, - { - "id": "QbResume", - "name": "开始QB所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - }, - ] - if self._enabled and self._pause_cron: - return [ - { - "id": "QbPause", - "name": "暂停QB所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - } - ] - if self._enabled and self._resume_cron: - return [ - { - "id": "QbResume", - "name": "开始QB所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - } - ] - return [] - - def get_all_torrents(self): - all_torrents, error = self._qb.get_torrents() - if error: - logger.error(f"获取QB种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"获取QB种子失败,请检查QB配置", - ) - return [] - - if not all_torrents: - logger.warning("QB没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"QB中没有种子", - ) - return [] - return all_torrents - - @staticmethod - def get_torrents_status(torrents): - downloading_torrents = [] - uploading_torrents = [] - paused_torrents = [] - checking_torrents = [] - error_torrents = [] - for torrent in torrents: - if torrent.state_enum.is_uploading and not torrent.state_enum.is_paused: - uploading_torrents.append(torrent.get("hash")) - elif ( - torrent.state_enum.is_downloading - and not torrent.state_enum.is_paused - and not torrent.state_enum.is_checking - ): - downloading_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_checking: - checking_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_paused: - paused_torrents.append(torrent.get("hash")) - elif torrent.state_enum.is_errored: - error_torrents.append(torrent.get("hash")) - - return ( - downloading_torrents, - uploading_torrents, - paused_torrents, - checking_torrents, - error_torrents, - ) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_torrents": - return - self.pause_torrent() - - @eventmanager.register(EventType.PluginAction) - def handle_pause_upload_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_upload_torrents": - return - self.pause_torrent(self.TorrentType.UPLOADING) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_download_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_download_torrents": - return - self.pause_torrent(self.TorrentType.DOWNLOADING) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_checking_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_checking_torrents": - return - self.pause_torrent(self.TorrentType.CHECKING) - - def pause_torrent(self, type: TorrentType = TorrentType.ALL): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - - logger.info( - f"暂定任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB暂停任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - pause_torrents = self.filter_pause_torrents(all_torrents) - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(pause_torrents) - ) - if type == self.TorrentType.DOWNLOADING: - to_be_paused = hash_downloading - elif type == self.TorrentType.UPLOADING: - to_be_paused = hash_uploading - elif type == self.TorrentType.CHECKING: - to_be_paused = hash_checking - else: - to_be_paused = hash_downloading + hash_uploading + hash_checking - - if len(to_be_paused) > 0: - if self._qb.stop_torrents(ids=to_be_paused): - logger.info(f"暂停了{len(to_be_paused)}个种子") - else: - logger.error(f"暂停种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"暂停种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(to_be_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"暂定任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB暂停任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - def __is_excluded(self, file_path) -> bool: - """ - 是否排除目录 - """ - for exclude_dir in self._exclude_dirs.split("\n"): - if exclude_dir and exclude_dir in str(file_path): - return True - return False - def filter_pause_torrents(self, all_torrents): - torrents = [] - for torrent in all_torrents: - if self.__is_excluded(torrent.get("content_path")): - continue - torrents.append(torrent) - return torrents - - @eventmanager.register(EventType.PluginAction) - def handle_resume_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "resume_torrents": - return - self.resume_torrent() - - def resume_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"QB开始任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB开始任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - - resume_torrents = self.filter_resume_torrents(all_torrents) - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(resume_torrents) - ) - if not self._qb.start_torrents(ids=hash_paused): - logger.error(f"开始种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"开始种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(hash_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"开始任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB开始任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - def filter_resume_torrents(self, all_torrents): - """ - 过滤掉不参与保种的种子 - """ - if len(self._op_sites) == 0: - return all_torrents - - urls = [site.get("url") for site in self._op_sites] - op_sites_main_domains = [] - for url in urls: - domain = StringUtils.get_url_netloc(url) - main_domain = self.get_main_domain(domain[1]) - op_sites_main_domains.append(main_domain) - - torrents = [] - for torrent in all_torrents: - if torrent.get("state") == "pausedUP": - tracker_url = self.get_torrent_tracker(torrent) - if not tracker_url: - logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") - torrents.append(torrent) - _, tracker_domain = StringUtils.get_url_netloc(tracker_url) - if not tracker_domain: - logger.info(f"获取种子 {torrent.name} Tracker失败,不过滤该种子") - torrents.append(torrent) - tracker_main_domain = self.get_main_domain(domain=tracker_domain) - if tracker_main_domain in op_sites_main_domains: - logger.info( - f"种子 {torrent.name} 属于站点{tracker_main_domain},不执行操作" - ) - continue - - torrents.append(torrent) - return torrents - - @eventmanager.register(EventType.PluginAction) - def handle_qb_status(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "qb_status": - return - self.qb_status() - - def qb_status(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"QB任务状态 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB任务状态】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_upload_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_upload_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_download_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_download_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - def set_both_limit(self, upload_limit, download_limit): - if not self._enable_upload_limit or not self._enable_upload_limit: - return True - - if ( - not upload_limit - or not upload_limit.isdigit() - or not download_limit - or not download_limit.isdigit() - ): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,download_limit或upload_limit不是一个数值", - ) - return False - - return self._qb.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit) - ) - - def set_upload_limit(self, upload_limit): - if not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,upload_limit不是一个数值", - ) - return False - - download_limit_current_val, _ = self._qb.get_speed_limit() - return self._qb.set_speed_limit( - download_limit=int(download_limit_current_val), - upload_limit=int(upload_limit), - ) - - def set_download_limit(self, download_limit): - if not self._enable_download_limit: - return True - - if not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败,download_limit不是一个数值", - ) - return False - - _, upload_limit_current_val = self._qb.get_speed_limit() - return self._qb.set_speed_limit( - download_limit=int(download_limit), - upload_limit=int(upload_limit_current_val), - ) - - def set_limit(self, upload_limit, download_limit): - # 限速,满足以下三种情况设置限速 - # 1. 插件启用 && download_limit启用 - # 2. 插件启用 && upload_limit启用 - # 3. 插件启用 && download_limit启用 && upload_limit启用 - - flag = None - if self._enabled and self._enable_download_limit and self._enable_upload_limit: - flag = self.set_both_limit(upload_limit, download_limit) - - elif flag is None and self._enabled and self._enable_download_limit: - flag = self.set_download_limit(download_limit) - - elif flag is None and self._enabled and self._enable_upload_limit: - flag = self.set_upload_limit(upload_limit) - - if flag == True: - logger.info(f"设置QB限速成功") - if self._notify: - if upload_limit == 0: - text = f"上传无限速" - else: - text = f"上传限速:{upload_limit} KB/s" - if download_limit == 0: - text += f"\n下载无限速" - else: - text += f"\n下载限速:{download_limit} KB/s" - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=text, - ) - elif flag == False: - logger.error(f"QB设置限速失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【QB远程操作】", - text=f"设置QB限速失败", - ) - - def get_torrent_tracker(self, torrent): - """ - qb解析 tracker - :return: tracker url - """ - if not torrent: - return None - tracker = torrent.get("tracker") - if tracker and len(tracker) > 0: - return tracker - magnet_uri = torrent.get("magnet_uri") - if not magnet_uri or len(magnet_uri) <= 0: - return None - magnet_uri_obj = urlparse(magnet_uri) - query = urllib.parse.parse_qs(magnet_uri_obj.query) - tr = query["tr"] - if not tr or len(tr) <= 0: - return None - return tr[0] - - def get_main_domain(self, domain): - """ - 获取域名的主域名 - :param domain: 原域名 - :return: 主域名 - """ - if not domain: - return None - domain_arr = domain.split(".") - domain_len = len(domain_arr) - if domain_len < 2: - return None - root_domain, root_domain_len = self.match_multi_level_root_domain(domain=domain) - if root_domain: - return f"{domain_arr[-root_domain_len - 1]}.{root_domain}" - else: - return f"{domain_arr[-2]}.{domain_arr[-1]}" - - def match_multi_level_root_domain(self, domain): - """ - 匹配多级根域名 - :param domain: 被匹配的域名 - :return: 匹配的根域名, 匹配的根域名长度 - """ - if not domain or not self._multi_level_root_domain: - return None, 0 - for root_domain in self._multi_level_root_domain: - if domain.endswith("." + root_domain): - root_domain_len = len(root_domain.split(".")) - return root_domain, root_domain_len - return None, 0 - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - customSites = self.__custom_sites() - - site_options = [ - {"title": site.name, "value": site.id} - for site in self._siteoper.list_order_by_pri() - ] + [ - {"title": site.get("name"), "value": site.get("id")} for site in customSites - ] - 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": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseonce", - "label": "立即暂停所有任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyresumeonce", - "label": "立即开始所有任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "pause_cron", - "label": "暂停周期", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "resume_cron", - "label": "开始周期", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_upload_limit", - "label": "上传限速", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_download_limit", - "label": "下载限速", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "upload_limit", - "label": "上传限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "download_limit", - "label": "下载限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseupload", - "label": "暂停上传任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypausedownload", - "label": "暂停下载任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 4}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypausechecking", - "label": "暂停检查任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VSelect", - "props": { - "chips": True, - "multiple": True, - "model": "op_site_ids", - "label": "停止保种站点(暂停保种后不会被恢复)", - "items": site_options, - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12}, - "content": [ - { - "component": "VTextarea", - "props": { - "model": "exclude_dirs", - "label": "不暂停保种目录", - "rows": 5, - "placeholder": "该目录下的做种不会暂停,一行一个目录", - }, - } - ], - } - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *,仅针对开始/暂定全部任务", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "交互命令有暂停QB种子、开始QB种子、QB切换上传限速状态、QB切换下载限速状态", - }, - } - ], - }, - ], - }, - ], - } - ], { - "enabled": False, - "notify": True, - "onlypauseonce": False, - "onlyresumeonce": False, - "onlypauseupload": False, - "onlypausedownload": False, - "onlypausechecking": False, - "upload_limit": 0, - "download_limit": 0, - "enable_upload_limit": False, - "enable_download_limit": False, - "op_site_ids": [], - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/syncdownloadfiles/__init__.py b/plugins.v2/syncdownloadfiles/__init__.py deleted file mode 100644 index 15c8a42..0000000 --- a/plugins.v2/syncdownloadfiles/__init__.py +++ /dev/null @@ -1,579 +0,0 @@ -import time -from datetime import datetime -from pathlib import Path -from typing import Any, List, Dict, Tuple, Optional - -from apscheduler.schedulers.background import BackgroundScheduler - -from app.core.config import settings -from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.transferhistory_oper import TransferHistoryOper -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.modules.transmission import Transmission -from app.plugins import _PluginBase - - -class SyncDownloadFiles(_PluginBase): - # 插件名称 - plugin_name = "下载器文件同步" - # 插件描述 - plugin_desc = "同步下载器的文件信息到数据库,删除文件时联动删除下载任务。" - # 插件图标 - plugin_icon = "Youtube-dl_A.png" - # 插件版本 - plugin_version = "1.1.1" - # 插件作者 - plugin_author = "thsrite" - # 作者主页 - author_url = "https://github.com/thsrite" - # 插件配置项ID前缀 - plugin_config_prefix = "syncdownloadfiles_" - # 加载顺序 - plugin_order = 20 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _enabled = False - # 任务执行间隔 - _time = None - qb = None - tr = None - _onlyonce = False - _history = False - _clear = False - _downloaders = [] - _dirs = None - downloadhis = None - transferhis = None - - # 定时器 - _scheduler: Optional[BackgroundScheduler] = None - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - - self.qb = Qbittorrent() - self.tr = Transmission() - self.downloadhis = DownloadHistoryOper() - self.transferhis = TransferHistoryOper() - - if config: - self._enabled = config.get('enabled') - self._time = config.get('time') or 6 - self._history = config.get('history') - self._clear = config.get('clear') - self._onlyonce = config.get("onlyonce") - self._downloaders = config.get('downloaders') or [] - self._dirs = config.get("dirs") or "" - - if self._clear: - # 清理下载器文件记录 - self.downloadhis.truncate_files() - # 清理下载器最后处理记录 - for downloader in self._downloaders: - # 获取最后同步时间 - self.del_data(f"last_sync_time_{downloader}") - # 关闭clear - self._clear = False - self.__update_config() - - if self._onlyonce: - # 执行一次 - # 关闭onlyonce - self._onlyonce = False - self.__update_config() - - self.sync() - - def sync(self): - """ - 同步所选下载器种子记录 - """ - start_time = datetime.now() - logger.info("开始同步下载器任务文件记录") - - if not self._downloaders: - logger.error("未选择同步下载器,停止运行") - return - - # 遍历下载器同步记录 - for downloader in self._downloaders: - # 获取最后同步时间 - last_sync_time = self.get_data(f"last_sync_time_{downloader}") - - logger.info(f"开始扫描下载器 {downloader} ...") - downloader_obj = self.__get_downloader(downloader) - # 获取下载器中已完成的种子 - torrents = downloader_obj.get_completed_torrents() - if torrents: - logger.info(f"下载器 {downloader} 已完成种子数:{len(torrents)}") - else: - logger.info(f"下载器 {downloader} 没有已完成种子") - continue - - # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 - torrents = self.__get_origin_torrents(torrents, downloader) - logger.info(f"下载器 {downloader} 去除辅种,获取到源种子数:{len(torrents)}") - - for torrent in torrents: - # 返回false,标识后续种子已被同步 - sync_flag = self.__compare_time(torrent, downloader, last_sync_time) - - if not sync_flag: - logger.info(f"最后同步时间{last_sync_time}, 之前种子已被同步,结束当前下载器 {downloader} 任务") - break - - # 获取种子hash - hash_str = self.__get_hash(torrent, downloader) - - # 判断是否是mp下载,判断download_hash是否在downloadhistory表中,是则不处理 - downloadhis = self.downloadhis.get_by_hash(hash_str) - if downloadhis: - downlod_files = self.downloadhis.get_files_by_hash(hash_str) - if downlod_files: - logger.info(f"种子 {hash_str} 通过MoviePilot下载,跳过处理") - continue - - # 获取种子download_dir - download_dir = self.__get_download_dir(torrent, downloader) - - # 处理路径映射 - if self._dirs: - paths = self._dirs.split("\n") - for path in paths: - sub_paths = path.split(":") - download_dir = download_dir.replace(sub_paths[0], sub_paths[1]).replace('\\', '/') - - # 获取种子name - torrent_name = self.__get_torrent_name(torrent, downloader) - # 种子保存目录 - save_path = Path(download_dir).joinpath(torrent_name) - # 获取种子文件 - torrent_files = self.__get_torrent_files(torrent, downloader, downloader_obj) - logger.info(f"开始同步种子 {hash_str}, 文件数 {len(torrent_files)}") - - download_files = [] - for file in torrent_files: - # 过滤掉没下载的文件 - if not self.__is_download(file, downloader): - continue - # 种子文件路径 - file_path_str = self.__get_file_path(file, downloader) - file_path = Path(file_path_str) - # 只处理视频格式 - if not file_path.suffix \ - or file_path.suffix not in settings.RMT_MEDIAEXT: - continue - # 种子文件根路程 - root_path = file_path.parts[0] - # 不含种子名称的种子文件相对路径 - if root_path == torrent_name: - rel_path = str(file_path.relative_to(root_path)) - else: - rel_path = str(file_path) - # 完整路径 - full_path = save_path.joinpath(rel_path) - if self._history: - transferhis = self.transferhis.get_by_src(str(full_path)) - if transferhis and not transferhis.download_hash: - logger.info(f"开始补充转移记录:{transferhis.id} download_hash {hash_str}") - self.transferhis.update_download_hash(historyid=transferhis.id, - download_hash=hash_str) - - # 种子文件记录 - download_files.append( - { - "download_hash": hash_str, - "downloader": downloader, - "fullpath": str(full_path), - "savepath": str(save_path), - "filepath": rel_path, - "torrentname": torrent_name, - } - ) - - if download_files: - # 登记下载文件 - self.downloadhis.add_files(download_files) - logger.info(f"种子 {hash_str} 同步完成") - - logger.info(f"下载器种子文件同步完成!") - self.save_data(f"last_sync_time_{downloader}", - time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time()))) - - # 计算耗时 - end_time = datetime.now() - - logger.info(f"下载器任务文件记录已同步完成。总耗时 {(end_time - start_time).seconds} 秒") - - def __update_config(self): - self.update_config({ - "enabled": self._enabled, - "time": self._time, - "history": self._history, - "clear": self._clear, - "onlyonce": self._onlyonce, - "downloaders": self._downloaders, - "dirs": self._dirs - }) - - @staticmethod - def __get_origin_torrents(torrents: Any, dl_tpe: str): - # 把种子按照名称和种子大小分组,获取添加时间最早的一个,认定为是源种子,其余为辅种 - grouped_data = {} - - # 排序种子,根据种子添加时间倒序 - if dl_tpe == "qbittorrent": - torrents = sorted(torrents, key=lambda x: x.get("added_on"), reverse=True) - # 遍历原始数组,按照size和name进行分组 - for torrent in torrents: - size = torrent.get('size') - name = torrent.get('name') - key = (size, name) # 使用元组作为字典的键 - - # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 - if key not in grouped_data: - grouped_data[key] = torrent - else: - # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 - if torrent.get('added_on') < grouped_data[key].get('added_on'): - grouped_data[key] = torrent - else: - torrents = sorted(torrents, key=lambda x: x.added_date, reverse=True) - # 遍历原始数组,按照size和name进行分组 - for torrent in torrents: - size = torrent.total_size - name = torrent.name - key = (size, name) # 使用元组作为字典的键 - - # 如果分组键不存在,则将当前元素作为最小元素添加到字典中 - if key not in grouped_data: - grouped_data[key] = torrent - else: - # 如果分组键已存在,则比较当前元素的time是否更小,如果更小则更新字典中的元素 - if torrent.added_date < grouped_data[key].added_date: - grouped_data[key] = torrent - - # 新的数组 - return list(grouped_data.values()) - - @staticmethod - def __compare_time(torrent: Any, dl_tpe: str, last_sync_time: str = None): - if last_sync_time: - # 获取种子时间 - if dl_tpe == "qbittorrent": - torrent_date = time.localtime(torrent.get("added_on")) # 将时间戳转换为时间元组 - torrent_date = time.strftime("%Y-%m-%d %H:%M:%S", torrent_date) # 格式化时间 - else: - torrent_date = torrent.added_date - - # 之后的种子已经同步了 - if last_sync_time > str(torrent_date): - return False - - return True - - @staticmethod - def __is_download(file: Any, dl_type: str): - """ - 判断文件是否被下载 - """ - try: - if dl_type == "qbittorrent": - return True - else: - return file.completed and file.completed > 0 - except Exception as e: - print(str(e)) - return True - - @staticmethod - def __get_file_path(file: Any, dl_type: str): - """ - 获取文件路径 - """ - try: - return file.get("name") if dl_type == "qbittorrent" else file.name - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_torrent_files(torrent: Any, dl_type: str, downloader_obj): - """ - 获取种子文件 - """ - try: - return torrent.files if dl_type == "qbittorrent" else downloader_obj.get_files(tid=torrent.id) - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_torrent_name(torrent: Any, dl_type: str): - """ - 获取种子name - """ - try: - return torrent.get("name") if dl_type == "qbittorrent" else torrent.name - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_download_dir(torrent: Any, dl_type: str): - """ - 获取种子download_dir - """ - try: - return torrent.get("save_path") if dl_type == "qbittorrent" else torrent.download_dir - except Exception as e: - print(str(e)) - return "" - - @staticmethod - def __get_hash(torrent: Any, dl_type: str): - """ - 获取种子hash - """ - try: - return torrent.get("hash") if dl_type == "qbittorrent" else torrent.hashString - except Exception as e: - print(str(e)) - return "" - - def __get_downloader(self, dtype: str): - """ - 根据类型返回下载器实例 - """ - if dtype == "qbittorrent": - return self.qb - elif dtype == "transmission": - return self.tr - else: - return None - - def get_state(self) -> bool: - return True if self._enabled and self._time else False - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self.get_state(): - return [{ - "id": "SyncDownloadFiles", - "name": "同步下载器文件记录服务", - "trigger": "interval", - "func": self.sync, - "kwargs": {"seconds": float(str(self._time).strip()) * 3600} - }] - return [] - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - """ - 拼装插件配置页面,需要返回两块数据:1、页面配置;2、数据结构 - """ - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '开启插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'history', - 'label': '补充整理历史记录', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'clear', - 'label': '清理数据', - } - } - ] - }, - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'time', - 'label': '同步时间间隔(小时)' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - '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': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'dirs', - 'label': '目录映射', - 'rows': 5, - 'placeholder': '每一行一个目录,下载器保存目录:MoviePilot映射目录' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '适用于非MoviePilot下载的任务;下载器种子数据较多时,同步时间将会较长,请耐心等候,可查看实时日志了解同步进度;时间间隔建议最少每6小时执行一次,防止上次任务没处理完。' - } - } - ] - } - ] - } - ] - } - ], { - "enabled": False, - "onlyonce": False, - "history": False, - "clear": False, - "time": 6, - "dirs": "", - "downloaders": [] - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - try: - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._scheduler.shutdown() - self._scheduler = None - except Exception as e: - logger.error("退出插件失败:%s" % str(e)) diff --git a/plugins.v2/trackereditor/__init__.py b/plugins.v2/trackereditor/__init__.py deleted file mode 100644 index 872e657..0000000 --- a/plugins.v2/trackereditor/__init__.py +++ /dev/null @@ -1,454 +0,0 @@ -from typing import List, Tuple, Dict, Any, Union, Optional - -from apscheduler.triggers.cron import CronTrigger - -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from qbittorrentapi.torrents import TorrentInfoList -from app.modules.transmission import Transmission -from transmission_rpc.torrent import Torrent -from app.plugins import _PluginBase -from app.schemas import NotificationType - - -class TrackerEditor(_PluginBase): - # 插件名称 - plugin_name = "Tracker替换" - # 插件描述 - plugin_desc = "批量替换种子tracker,支持周期性巡检(如为TR,仅支持4.0以上版本)" - # 插件图标 - plugin_icon = "trackereditor_A.png" - # 插件版本 - plugin_version = "1.5" - # 插件作者 - plugin_author = "honue" - # 作者主页 - author_url = "https://github.com/honue" - # 插件配置项ID前缀 - plugin_config_prefix = "trackereditor_" - # 加载顺序 - plugin_order = 30 - # 可使用的用户级别 - auth_level = 1 - - _downloader_type: str = None - _username: str = None - _password: str = None - _host: str = None - _port: int = None - _target_domain: str = None - _replace_domain: str = None - - _onlyonce: bool = False - _downloader: Union[Qbittorrent, Transmission] = None - - _run_con_enable: bool = False - _run_con: Optional[str] = None - _notify: bool = False - - def init_plugin(self, config: dict = None): - if config: - self._onlyonce = config.get("onlyonce") - self._downloader_type = config.get("downloader_type") - self._host = config.get("host") - self._port = config.get("port") - self._username = config.get("username") - self._password = config.get("password") - self._target_domain = config.get("target_domain") - self._replace_domain = config.get("replace_domain") - self._run_con_enable = config.get("run_con_enable") - self._run_con = config.get("run_con") - self._notify = config.get("notify") - - if self._onlyonce: - # 执行替换 - self.task() - self._onlyonce = False - # 更新onlyonce属性 - self.__update_config() - - def task(self): - logger.info(f"{'*' * 30}TrackerEditor: 开始执行Tracker替换{'*' * 30}") - torrent_total_cnt: int = 0 - torrent_update_cnt: int = 0 - if self._downloader_type == "qbittorrent": - self._downloader = Qbittorrent(self._host, self._port, self._username, self._password) - torrent_info_list: TorrentInfoList - torrent_info_list, error = self._downloader.get_torrents() - torrent_total_cnt = len(torrent_info_list) - if error: - return - for torrent in torrent_info_list: - for tracker in torrent.trackers: - if self._target_domain in tracker.url: - original_url = tracker.url - new_url = tracker.url.replace(self._target_domain, self._replace_domain) - logger.info(f"{original_url} 替换为\n {new_url}") - torrent.edit_tracker(orig_url=original_url, new_url=new_url) - torrent_update_cnt += 1 - - elif self._downloader_type == "transmission": - self._downloader = Transmission(self._host, self._port, self._username, self._password) - tr_version = self._downloader.get_session().get('version') - # "4.0.3 (6b0e49bbb2)" "3.00 (bb6b5a062e)" - torrent_list: List[Torrent] - torrent_list, error = self._downloader.get_torrents() - torrent_total_cnt = len(torrent_list) - if error: - return - for torrent in torrent_list: - new_tracker_list = [] - for tracker in torrent.tracker_list: - if self._target_domain in tracker: - new_url = tracker.replace(self._target_domain, self._replace_domain) - new_tracker_list.append(new_url) - logger.info(f"{tracker} 替换为\n {new_url}") - torrent_update_cnt += 1 - else: - new_tracker_list.append(tracker) - if int(tr_version[0]) >= 4: - # 版本大于等于4.x - __tracker_list = [new_tracker_list] - else: - __tracker_list = new_tracker_list - if torrent_update_cnt > 0: - update_result = self._downloader.update_tracker(hash_string=torrent.hashString, tracker_list=__tracker_list) - if not update_result: - logger.error(f"执行tracker修改出错,中止本次执行") - torrent_update_cnt = 0 - break - if torrent_update_cnt == 0: - logger.info(f"tracker修改条数为0") - logger.info(f"{'*' * 30}TrackerEditor: Tracker替换完成{'*' * 30}") - if (self._run_con_enable and self._notify) or (self._onlyonce and self._notify): - title = '【Tracker替换】' - msg = f'''扫描下载器{self._downloader_type}\n总的种子数: {torrent_total_cnt}\n已修改种子数: {torrent_update_cnt}''' - self.send_site_message(title, msg) - - def __update_config(self): - self.update_config({ - "onlyonce": self._onlyonce, - "downloader_type": self._downloader_type, - "username": self._username, - "password": self._password, - "host": self._host, - "port": self._port, - "target_domain": self._target_domain, - "replace_domain": self._replace_domain, - "run_cron_enable": self._run_con_enable, - "run_cron": self._run_con, - "notify": self._notify - }) - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - pass - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'run_con_enable', - 'label': '启用周期性巡检 (注: 请开启时,务必填写cron表达式)', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - }] - }, { - 'component': 'VRow', - 'content': [ - { - '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': 'run_con', - 'label': 'cron表达式', - 'placeholder': '* * * * *' - } - } - ] - }, { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'downloader_type', - 'label': '下载器类型', - 'items': [ - {'title': 'Qbittorrent', 'value': 'qbittorrent'}, - {'title': 'Transmission', 'value': 'transmission'} - ] - } - } - ] - }] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'host', - 'label': 'host主机ip', - 'placeholder': '192.168.2.100' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'port', - 'label': 'qb/tr端口', - 'placeholder': '8989' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'username', - 'label': '用户名', - 'placeholder': 'username' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'password', - 'label': '密码', - 'placeholder': 'password' - } - } - ] - } - ] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'target_domain', - 'label': '待替换文本', - 'placeholder': 'target.com' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 6 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'replace_domain', - 'label': '替换的文本', - 'placeholder': 'replace.net' - } - } - ] - } - ] - }, { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '对下载器中所有符合代替换文本的tacker进行字符串replace替换' + '\n' + - '现有tracker: https://baidu.com/announce.php?passkey=xxxx' + '\n' + - '待替换 baidu.com 或 https://baidu.com' + '\n' + - '用于替换的文本 qq.com 或 https://qq.com' + '\n' + - '结果为 https://qq.com/announce.php?passkey=xxxx', - 'style': 'white-space: pre-line;' - } - }, - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '强烈建议自己先添加一个tracker测试替换是否符合预期,程序是否正常运行', - 'style': 'white-space: pre-line;' - } - }, - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '周期性巡检时指的是允许设置间隔一段进行巡检下载器中的种子Tracker' + '\n' - '当匹配到等待替换的tracker时,进行替换,其中cron表达式是5位,例如:* * * * * 指的是每过一分钟轮训一次', - 'style': 'white-space: pre-line;' - } - } - ] - } - ] - } - ] - } - ], { - "onlyonce": False, - "downloader_type": "qbittorrent", - "host": "192.168.2.100", - "port": 8989, - "username": "username", - "password": "password", - "target_domain": "", - "replace_domain": "", - "run_con_enable": False, - "run_con": "", - "notify": True - } - - def get_page(self) -> List[dict]: - pass - - def get_state(self) -> bool: - return True - - def stop_service(self): - pass - - def get_service(self) -> List[Dict[str, Any]]: - if self._run_con_enable and self._run_con: - logger.info(f"{'*' * 30}TrackerEditor: 注册公共调度服务{'*' * 30}") - return [ - { - "id": "TrackerChangeRun", - "name": "启用周期性Tracker替换", - "trigger": CronTrigger.from_crontab(self._run_con), - "func": self.task, - "kwargs": {} - }] - - return [] - - def send_site_message(self, title, message): - self.post_message( - mtype=NotificationType.SiteMessage, - title=title, - text=message - ) diff --git a/plugins.v2/trcommand/__init__.py b/plugins.v2/trcommand/__init__.py deleted file mode 100644 index ff8af5c..0000000 --- a/plugins.v2/trcommand/__init__.py +++ /dev/null @@ -1,732 +0,0 @@ -from typing import List, Tuple, Dict, Any - -from app.log import logger -from app.modules.transmission import Transmission -from app.plugins import _PluginBase -from app.schemas import NotificationType -from app.schemas.types import EventType -from apscheduler.triggers.cron import CronTrigger -from app.core.event import eventmanager, Event -import time - - -class TrCommand(_PluginBase): - # 插件名称 - plugin_name = "TR远程操作" - # 插件描述 - plugin_desc = "通过定时任务或交互命令远程操作TR暂停/开始/限速等。" - # 插件图标 - plugin_icon = "Transmission_A.png" - # 插件版本 - plugin_version = "1.1" - # 插件作者 - plugin_author = "Hoey" - # 作者主页 - author_url = "https://github.com/hoey94" - # 插件配置项ID前缀 - plugin_config_prefix = "trcommand_" - # 加载顺序 - plugin_order = 1 - # 可使用的用户级别 - auth_level = 1 - - # 私有属性 - _tr = None - _enabled: bool = False - _notify: bool = False - _pause_cron = None - _resume_cron = None - _only_pause_once = False - _only_resume_once = False - _upload_limit = 0 - _enable_upload_limit = False - _download_limit = 0 - _enable_download_limit = False - - def init_plugin(self, config: dict = None): - # 停止现有任务 - self.stop_service() - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._pause_cron = config.get("pause_cron") - self._resume_cron = config.get("resume_cron") - self._only_pause_once = config.get("onlypauseonce") - self._only_resume_once = config.get("onlyresumeonce") - self._download_limit = config.get("download_limit") - self._upload_limit = config.get("upload_limit") - self._enable_download_limit = config.get("enable_download_limit") - self._enable_upload_limit = config.get("enable_upload_limit") - self._tr = Transmission() - - if self._only_pause_once or self._only_resume_once: - if self._only_pause_once and self._only_resume_once: - logger.warning("只能选择一个: 立即暂停或立即开始所有任务") - elif self._only_pause_once: - self.pause_torrent() - elif self._only_resume_once: - self.resume_torrent() - - self._only_resume_once = False - self._only_pause_once = False - self.update_config( - { - "onlypauseonce": False, - "onlyresumeonce": False, - "enabled": self._enabled, - "notify": self._notify, - "pause_cron": self._pause_cron, - "resume_cron": self._resume_cron, - } - ) - - # 限速 - self.set_limit(self._upload_limit, self._download_limit) - - def get_state(self) -> bool: - return self._enabled - - @staticmethod - def get_command() -> List[Dict[str, Any]]: - """ - 定义远程控制命令 - :return: 命令关键字、事件、描述、附带数据 - """ - return [ - { - "cmd": "/pause_torrents", - "event": EventType.PluginAction, - "desc": "暂停TR种子", - "category": "TR", - "data": {"action": "pause_torrents"}, - }, - { - "cmd": "/resume_torrents", - "event": EventType.PluginAction, - "desc": "开始TR种子", - "category": "TR", - "data": {"action": "resume_torrents"}, - }, - { - "cmd": "/toggle_upload_limit", - "event": EventType.PluginAction, - "desc": "TR切换上传限速状态", - "category": "TR", - "data": {"action": "toggle_upload_limit"}, - }, - { - "cmd": "/toggle_download_limit", - "event": EventType.PluginAction, - "desc": "TR切换下载限速状态", - "category": "TR", - "data": {"action": "toggle_download_limit"}, - }, - ] - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and self._pause_cron and self._resume_cron: - return [ - { - "id": "TrPause", - "name": "暂停TR所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - }, - { - "id": "TrResume", - "name": "开始TR所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - }, - ] - if self._enabled and self._pause_cron: - return [ - { - "id": "TrPause", - "name": "暂停TR所有任务", - "trigger": CronTrigger.from_crontab(self._pause_cron), - "func": self.pause_torrent, - "kwargs": {}, - } - ] - if self._enabled and self._resume_cron: - return [ - { - "id": "TrResume", - "name": "开始TR所有任务", - "trigger": CronTrigger.from_crontab(self._resume_cron), - "func": self.resume_torrent, - "kwargs": {}, - } - ] - return [] - - def get_all_torrents(self): - all_torrents, error = self._tr.get_torrents() - if error: - logger.error(f"获取TR种子失败: {error}") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"获取TR种子失败,请检查TR配置", - ) - return [] - - if not all_torrents: - logger.warning("TR没有种子") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"TR中没有种子", - ) - return [] - return all_torrents - - @staticmethod - def get_torrents_status(torrents): - downloading_torrents = [] - uploading_torrents = [] - paused_torrents = [] - checking_torrents = [] - error_torrents = [] - for torrent in torrents: - match torrent.status.lower(): - case 'stopped': - paused_torrents.append(torrent.id) - case 'check_pending': - checking_torrents.append(torrent.id) - case 'checking': - checking_torrents.append(torrent.id) - case 'download_pending': - downloading_torrents.append(torrent.id) - case 'downloading': - downloading_torrents.append(torrent.id) - case 'seed_pending': - uploading_torrents.append(torrent.id) - case 'seeding': - uploading_torrents.append(torrent.id) - - return ( - downloading_torrents, - uploading_torrents, - paused_torrents, - checking_torrents, - error_torrents, - ) - - @eventmanager.register(EventType.PluginAction) - def handle_pause_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "pause_torrents": - return - self.pause_torrent() - - def pause_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - to_be_paused = hash_downloading + hash_uploading + hash_checking - logger.info( - f"暂定任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR暂停任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"暂停操作中请稍等...\n", - ) - if len(to_be_paused) > 0: - if self._tr.stop_torrents(ids=to_be_paused): - logger.info(f"暂停了{len(to_be_paused)}个种子") - else: - logger.error(f"暂停种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"暂停种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(to_be_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"暂定任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR暂停任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - @eventmanager.register(EventType.PluginAction) - def handle_resume_torrent(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "resume_torrents": - return - self.resume_torrent() - - def resume_torrent(self): - if not self._enabled: - return - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"TR开始任务启动 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR开始任务启动】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - f"开始操作中请稍等...\n", - ) - if not self._tr.start_torrents(ids=hash_paused): - logger.error(f"开始种子失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"开始种子失败", - ) - # 每个种子等待1ms以让状态切换成功,至少等待1S - wait_time = 0.001 * len(hash_paused) + 1 - time.sleep(wait_time) - - all_torrents = self.get_all_torrents() - hash_downloading, hash_uploading, hash_paused, hash_checking, hash_error = ( - self.get_torrents_status(all_torrents) - ) - logger.info( - f"开始任务完成 \n" - f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n" - ) - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR开始任务完成】", - text=f"种子总数: {len(all_torrents)} \n" - f"做种数量: {len(hash_uploading)}\n" - f"下载数量: {len(hash_downloading)}\n" - f"检查数量: {len(hash_checking)}\n" - f"暂停数量: {len(hash_paused)}\n" - f"错误数量: {len(hash_error)}\n", - ) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_upload_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_upload_limit": - return - - self.set_limit(self._upload_limit, self._download_limit) - - @eventmanager.register(EventType.PluginAction) - def handle_toggle_download_limit(self, event: Event): - if not self._enabled: - return - if event: - event_data = event.event_data - if not event_data or event_data.get("action") != "toggle_download_limit": - return - self.set_limit(self._upload_limit, self._download_limit) - - def set_both_limit(self, upload_limit, download_limit): - if not self._enable_upload_limit or not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit() or not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,download_limit或upload_limit不是一个数值", - ) - return False - - return self._tr.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit) - ) - - def set_upload_limit(self, upload_limit): - if not self._enable_upload_limit: - return True - - if not upload_limit or not upload_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,upload_limit不是一个数值", - ) - return False - - download_limit_current_val, _ = self._tr.get_speed_limit() - return self._tr.set_speed_limit( - download_limit=int(download_limit_current_val), upload_limit=int(upload_limit) - ) - - def set_download_limit(self, download_limit): - if not self._enable_download_limit: - return True - - if not download_limit or not download_limit.isdigit(): - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败,download_limit不是一个数值", - ) - return False - - _, upload_limit_current_val = self._tr.get_speed_limit() - return self._tr.set_speed_limit( - download_limit=int(download_limit), upload_limit=int(upload_limit_current_val) - ) - - def set_limit(self, upload_limit, download_limit): - # 限速,满足以下三种情况设置限速 - # 1. 插件启用 && download_limit启用 - # 2. 插件启用 && upload_limit启用 - # 3. 插件启用 && download_limit启用 && upload_limit启用 - - flag = None - if self._enabled and self._enable_download_limit and self._enable_upload_limit: - flag = self.set_both_limit(upload_limit, download_limit) - - elif flag is None and self._enabled and self._enable_download_limit: - flag = self.set_download_limit(download_limit) - - elif flag is None and self._enabled and self._enable_upload_limit: - flag = self.set_upload_limit(upload_limit) - - if flag: - logger.info(f"设置TR限速成功") - if self._notify: - if upload_limit == 0: - text = f"上传无限速" - else: - text = f"上传限速:{upload_limit} KB/s" - if download_limit == 0: - text += f"\n下载无限速" - else: - text += f"\n下载限速:{download_limit} KB/s" - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=text, - ) - elif not flag: - logger.error(f"TR设置限速失败") - if self._notify: - self.post_message( - mtype=NotificationType.SiteMessage, - title=f"【TR远程操作】", - text=f"设置TR限速失败", - ) - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - 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": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlypauseonce", - "label": "立即暂停所有任务", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "onlyresumeonce", - "label": "立即开始所有任务", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "pause_cron", - "label": "暂停周期", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "resume_cron", - "label": "开始周期", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_upload_limit", - "label": "上传限速", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VSwitch", - "props": { - "model": "enable_download_limit", - "label": "下载限速", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "upload_limit", - "label": "上传限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - { - "component": "VCol", - "props": {"cols": 12, "md": 6}, - "content": [ - { - "component": "VTextField", - "props": { - "model": "download_limit", - "label": "下载限速 KB/s", - "placeholder": "KB/s", - }, - } - ], - }, - ], - }, - { - "component": "VRow", - "content": [ - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "开始周期和暂停周期使用Cron表达式,如:0 0 0 * *", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "交互命令有暂停TR种子、开始TR种子、TR切换上传限速状态、TR切换下载限速状态", - }, - } - ], - }, - { - "component": "VCol", - "props": { - "cols": 12, - }, - "content": [ - { - "component": "VAlert", - "props": { - "type": "info", - "variant": "tonal", - "text": "PT精神重在分享,请勿恶意限速,因此导致账号被封禁作者概不负责", - }, - } - ], - } - ], - }, - ], - } - ], { - "enabled": False, - "notify": True, - "onlypauseonce": False, - "onlyresumeonce": False, - "upload_limit": 0, - "download_limit": 0, - "enable_upload_limit": False, - "enable_download_limit": False, - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - pass diff --git a/plugins.v2/vcbanimemonitor/__init__.py b/plugins.v2/vcbanimemonitor/__init__.py deleted file mode 100644 index f81f257..0000000 --- a/plugins.v2/vcbanimemonitor/__init__.py +++ /dev/null @@ -1,1124 +0,0 @@ -import datetime -import re -import shutil -import threading -import time -import traceback -from pathlib import Path -from time import sleep -from typing import List, Tuple, Dict, Any, Optional -import pytz -import qbittorrentapi -from apscheduler.schedulers.background import BackgroundScheduler -from apscheduler.triggers.cron import CronTrigger -from watchdog.events import FileSystemEventHandler -from watchdog.observers import Observer -from watchdog.observers.polling import PollingObserver -from app import schemas -from app.chain.media import MediaChain -from app.chain.tmdb import TmdbChain -from app.chain.transfer import TransferChain -from app.core.config import settings -from app.core.context import MediaInfo -from app.db.downloadhistory_oper import DownloadHistoryOper -from app.db.transferhistory_oper import TransferHistoryOper -from app.log import logger -from app.modules.qbittorrent import Qbittorrent -from app.plugins import _PluginBase -from app.plugins.vcbanimemonitor.remeta import ReMeta -from app.schemas import Notification, NotificationType, TransferInfo -from app.schemas.types import EventType, MediaType, SystemConfigKey -from app.utils.string import StringUtils -from app.utils.system import SystemUtils - -lock = threading.Lock() - - -class FileMonitorHandler(FileSystemEventHandler): - """ - 目录监控响应类 - """ - - def __init__(self, monpath: str, sync: Any, **kwargs): - super(FileMonitorHandler, self).__init__(**kwargs) - self._watch_path = monpath - self.sync = sync - - def on_created(self, event): - self.sync.event_handler(event=event, text="创建", - mon_path=self._watch_path, event_path=event.src_path) - - def on_moved(self, event): - self.sync.event_handler(event=event, text="移动", - mon_path=self._watch_path, event_path=event.dest_path) - - -class TorrentHandler(FileSystemEventHandler): - def __init__(self, monpath: str, sync: Any, **kwargs): - super(TorrentHandler, self).__init__(**kwargs) - self._watch_path = monpath - self.sync = sync - - def on_created(self, event): - self.sync.torrent_event(event=event, text="创建", - mon_path=self._watch_path) - - def on_moved(self, event): - self.sync.torrent_event(event=event, text="移动", - mon_path=self._watch_path) - - -class VCBAnimeMonitor(_PluginBase): - # 插件名称 - plugin_name = "整理VCB动漫压制组作品" - # 插件描述 - plugin_desc = "一款辅助整理&提高识别VCB-Stuido动漫压制组作品的插件" - # 插件图标 - plugin_icon = "vcbmonitor.png" - # 插件版本 - plugin_version = "1.8.2.2" - # 插件作者 - plugin_author = "pixel@qingwa" - # 作者主页 - author_url = "https://github.com/Pixel-LH" - # 插件配置项ID前缀 - plugin_config_prefix = "vcbanimemonitor_" - # 加载顺序 - plugin_order = 4 - # 可使用的用户级别 - auth_level = 2 - - # 私有属性 - _switch_ova = False - _torrents_path = None - new_save_path = None - qb = None - _scheduler = None - transferhis = None - downloadhis = None - transferchian = None - tmdbchain = None - mediaChain = None - _observer = [] - _enabled = False - _notify = False - _onlyonce = False - _cron = None - _size = 0 - _scrape = True - # 模式 compatibility/fast - _mode = "fast" - # 转移方式 - _transfer_type = settings.TRANSFER_TYPE - _monitor_dirs = "" - _exclude_keywords = "" - _interval: int = 10 - # 存储源目录与目的目录关系 - _dirconf: Dict[str, Optional[Path]] = {} - # 存储源目录转移方式 - _transferconf: Dict[str, Optional[str]] = {} - _medias = {} - # 退出事件 - _event = threading.Event() - - def init_plugin(self, config: dict = None): - self.transferhis = TransferHistoryOper() - self.downloadhis = DownloadHistoryOper() - self.transferchian = TransferChain() - self.mediaChain = MediaChain() - self.tmdbchain = TmdbChain() - # 清空配置 - self._dirconf = {} - self._transferconf = {} - - # 读取配置 - if config: - self._enabled = config.get("enabled") - self._notify = config.get("notify") - self._onlyonce = config.get("onlyonce") - self._mode = config.get("mode") - self._transfer_type = config.get("transfer_type") - self._monitor_dirs = config.get("monitor_dirs") or "" - self._exclude_keywords = config.get("exclude_keywords") or "" - self._interval = config.get("interval") or 10 - self._cron = config.get("cron") - self._size = config.get("size") or 0 - self._scrape = config.get("scrape") - self._switch_ova = config.get("ova") - self._torrents_path = config.get("torrents_path") or "" - - # 停止现有任务 - self.stop_service() - - if self._enabled or self._onlyonce: - # 定时服务管理器 - self._scheduler = BackgroundScheduler(timezone=settings.TZ) - # 追加入库消息统一发送服务 - self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) - self.qb = Qbittorrent() - - # 读取目录配置 - monitor_dirs = self._monitor_dirs.split("\n") - if not monitor_dirs: - return - - # 启用种子目录监控 - if self._torrents_path and Path(self._torrents_path).exists() and self._enabled: - # 只取第一个目录作为新的保存 - try: - first_path = monitor_dirs[0] - if SystemUtils.is_windows(): - self.new_save_path = first_path.split(':')[0] + ":" + first_path.split(':')[1] - else: - self.new_save_path = first_path.split(':')[0] - except Exception: - logger.error(f"目录保存失败,请检查输入目录是否合法") - # print(self.new_save_path) - try: - observer = Observer() - self._observer.append(observer) - observer.schedule(TorrentHandler(monpath=self._torrents_path, sync=self), path=self._torrents_path, - recursive=True) - observer.daemon = True - observer.start() - logger.info(f"{self._torrents_path} 的种子目录监控服务启动,开启监控新增的VCB-Studio种子文件") - except Exception as e: - logger.debug(f"{self._torrents_path} 启动种子目录监控失败:{str(e)}") - else: - logger.info("种子目录为空,不转移qb中正在下载的VCB-Studio文件") - - for mon_path in monitor_dirs: - # 格式源目录:目的目录 - if not mon_path: - continue - - # 自定义转移方式 - _transfer_type = self._transfer_type - if mon_path.count("#") == 1: - _transfer_type = mon_path.split("#")[1] - mon_path = mon_path.split("#")[0] - - # 存储目的目录 - if SystemUtils.is_windows(): - if mon_path.count(":") > 1: - paths = [mon_path.split(":")[0] + ":" + mon_path.split(":")[1], - mon_path.split(":")[2] + ":" + mon_path.split(":")[3]] - else: - paths = [mon_path] - else: - paths = mon_path.split(":") - - # 目的目录 - target_path = None - if len(paths) > 1: - mon_path = paths[0] - target_path = Path(paths[1]) - self._dirconf[mon_path] = target_path - else: - self._dirconf[mon_path] = None - - # 转移方式 - self._transferconf[mon_path] = _transfer_type - - # 启用目录监控 - if self._enabled: - # 检查媒体库目录是不是下载目录的子目录 - try: - if target_path and target_path.is_relative_to(Path(mon_path)): - logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") - self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", - title="整理VCB动漫压制组作品") - continue - except Exception as e: - logger.debug(str(e)) - pass - - try: - if self._mode == "compatibility": - # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB - observer = PollingObserver(timeout=10) - else: - # 内部处理系统操作类型选择最优解 - observer = Observer(timeout=10) - self._observer.append(observer) - observer.schedule(FileMonitorHandler(mon_path, self), path=mon_path, recursive=True) - observer.daemon = True - observer.start() - logger.info(f"{mon_path} 的目录监控服务启动") - except Exception as e: - err_msg = str(e) - if "inotify" in err_msg and "reached" in err_msg: - logger.warn( - f"目录监控服务启动出现异常:{err_msg},请在宿主机上(不是docker容器内)执行以下命令并重启:" - + """ - echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf - echo fs.inotify.max_user_instances=524288 | sudo tee -a /etc/sysctl.conf - sudo sysctl -p - """) - else: - logger.error(f"{mon_path} 启动目录监控失败:{err_msg}") - self.systemmessage.put(f"{mon_path} 启动目录监控失败:{err_msg}", title="整理VCB动漫压制组作品") - - # 运行一次定时服务 - if self._onlyonce: - logger.info("目录监控服务启动,立即运行一次") - self._scheduler.add_job(func=self.sync_all, trigger='date', - run_date=datetime.datetime.now( - tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) - ) - # 关闭一次性开关 - self._onlyonce = False - # 保存配置 - self.__update_config() - - # 启动定时服务 - if self._scheduler.get_jobs(): - self._scheduler.print_jobs() - self._scheduler.start() - - def __update_config(self): - """ - 更新配置 - """ - self.update_config({ - "enabled": self._enabled, - "notify": self._notify, - "onlyonce": self._onlyonce, - "mode": self._mode, - "transfer_type": self._transfer_type, - "monitor_dirs": self._monitor_dirs, - "exclude_keywords": self._exclude_keywords, - "interval": self._interval, - "cron": self._cron, - "size": self._size, - "scrape": self._scrape, - "ova": self._switch_ova, - "torrents_path": self._torrents_path - }) - - def __save_data(self, key: str, value: Any): - self.save_data(key, value) - - def __get_data(self, key: str): - return self.get_data(key) - - def sync_all(self): - """ - 立即运行一次,全量同步目录中所有文件 - """ - logger.info("开始全量同步监控目录 ...") - # 清空历史的ova记录 - self.plugindata.truncate() - - # 遍历所有监控目录 - for mon_path in self._dirconf.keys(): - # 遍历目录下所有文件 - for file_path in SystemUtils.list_files(Path(mon_path), settings.RMT_MEDIAEXT): - self.__handle_file(event_path=str(file_path), mon_path=mon_path) - - logger.info("全量同步监控目录完成!") - - def event_handler(self, event, mon_path: str, text: str, event_path: str): - """ - 处理文件变化 - :param event: 事件 - :param mon_path: 监控目录 - :param text: 事件描述 - :param event_path: 事件文件路径 - """ - if not event.is_directory: - # 文件发生变化 - logger.debug("文件%s:%s" % (text, event_path)) - self.__handle_file(event_path=event_path, mon_path=mon_path) - - def __handle_file(self, event_path: str, mon_path: str): - """ - 同步一个文件 - :param event_path: 事件文件路径 - :param mon_path: 监控目录 - """ - file_path = Path(event_path) - try: - if not file_path.exists(): - return - # 全程加锁 - with lock: - transfer_history = self.transferhis.get_by_src(event_path) - if transfer_history: - logger.debug("文件已处理过:%s" % event_path) - return - - # 回收站及隐藏的文件不处理 - if event_path.find('/@Recycle/') != -1 \ - or event_path.find('/#recycle/') != -1 \ - or event_path.find('/.') != -1 \ - or event_path.find('/@eaDir') != -1: - logger.debug(f"{event_path} 是回收站或隐藏的文件") - return - - # 命中过滤关键字不处理 - if self._exclude_keywords: - for keyword in self._exclude_keywords.split("\n"): - if keyword and re.findall(keyword, event_path): - logger.info(f"{event_path} 命中过滤关键字 {keyword},不处理") - return - - # 整理屏蔽词不处理 - transfer_exclude_words = self.systemconfig.get(SystemConfigKey.TransferExcludeWords) - if transfer_exclude_words: - for keyword in transfer_exclude_words: - if not keyword: - continue - if keyword and re.search(r"%s" % keyword, event_path, re.IGNORECASE): - logger.info(f"{event_path} 命中整理屏蔽词 {keyword},不处理") - return - - # 不是媒体文件不处理 - if file_path.suffix not in settings.RMT_MEDIAEXT: - logger.debug(f"{event_path} 不是媒体文件") - return - - # 判断是不是蓝光目录 - bluray_flag = False - if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): - bluray_flag = True - # 截取BDMV前面的路径 - blurray_dir = event_path[:event_path.find("BDMV")] - file_path = Path(blurray_dir) - logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") - - # 查询历史记录,已转移的不处理 - if self.transferhis.get_by_src(str(file_path)): - logger.info(f"{file_path} 已整理过") - return - - # 元数据 - if file_path.parent.name.lower() in ["sps", "scans", "cds", "previews", "extras"]: - logger.warn("位于特典或其他特殊目录下,跳过处理") - return - - if 'VCB-Studio' not in file_path.stem.strip(): - logger.warn("不属于VCB的作品,不处理!") - return - - remeta = ReMeta(ova_switch=self._switch_ova) - file_meta = remeta.handel_file(file_path=file_path) - if file_meta: - if not file_meta.name: - logger.error(f"{file_path.name} 无法识别有效信息") - return - if remeta.is_ova and not self._switch_ova: - logger.warn(f"{file_path.name} 为OVA资源,未开启OVA开关,不处理") - return - if remeta.is_ova and self._switch_ova: - logger.info(f"{file_path.name} 为OVA资源,开始历史记录处理") - ova_history_ep_list = self.get_data(file_meta.title) - if ova_history_ep_list and isinstance(ova_history_ep_list, list): - ep = file_meta.begin_episode - if ep in ova_history_ep_list: - for i in range(1, 100): - if ep + i not in ova_history_ep_list: - ova_history_ep_list.append(ep + i) - file_meta.begin_episode = ep + i - logger.info( - f"{file_path.name} 为OVA资源,历史记录中已存在,自动识别为第{ep + i}集") - break - else: - ova_history_ep_list.append(ep) - self.save_data(file_meta.title, ova_history_ep_list) - else: - self.save_data(file_meta.title, [file_meta.begin_episode]) - else: - return - - # 判断文件大小 - if self._size and float(self._size) > 0 and file_path.stat().st_size < float(self._size) * 1024 ** 3: - logger.info(f"{file_path} 文件大小小于监控文件大小,不处理") - return - - # 查询转移目的目录 - target: Path = self._dirconf.get(mon_path) - # 查询转移方式 - transfer_type = self._transferconf.get(mon_path) - - # 根据父路径获取下载历史 - download_history = None - if bluray_flag: - # 蓝光原盘,按目录名查询 - # FIXME 理论上DownloadHistory表中的path应该是全路径,但实际表中登记的数据只有目录名,暂按目录名查询 - download_history = self.downloadhis.get_by_path(file_path.name) - else: - # 按文件全路径查询 - download_file = self.downloadhis.get_file_by_fullpath(str(file_path)) - if download_file: - download_history = self.downloadhis.get_by_hash(download_file.download_hash) - - # 识别媒体信息 - if download_history and download_history.tmdbid: - mediainfo: MediaInfo = self.mediaChain.recognize_media(mtype=MediaType(download_history.type), - tmdbid=download_history.tmdbid, - doubanid=download_history.doubanid) - else: - mediainfo: MediaInfo = self.mediaChain.recognize_by_meta(file_meta) - - if not mediainfo: - logger.warn(f'未识别到媒体信息,标题:{file_meta.name}') - # self.save_data(plugin_id="vcbanimemonitor", key=file_meta.title, value="null") - # 新增转移成功历史记录 - his = self.transferhis.add_fail( - src_path=file_path, - mode=transfer_type, - meta=file_meta - ) - if self._notify: - self.chain.post_message(Notification( - mtype=NotificationType.Manual, - title=f"{file_path.name} 未识别到媒体信息,无法入库!\n" - f"回复:```\n/redo {his.id} [tmdbid]|[类型]\n``` 手动识别转移。" - )) - return - - # 如果未开启新增已入库媒体是否跟随TMDB信息变化则根据tmdbid查询之前的title - if not settings.SCRAP_FOLLOW_TMDB: - transfer_history = self.transferhis.get_by_type_tmdbid(tmdbid=mediainfo.tmdb_id, - mtype=mediainfo.type.value) - if transfer_history: - mediainfo.title = transfer_history.title - logger.info(f"{file_path.name} 识别为:{mediainfo.type.value} {mediainfo.title_year}") - - # 更新媒体图片 - self.chain.obtain_images(mediainfo=mediainfo) - - # 获取集数据 - if mediainfo.type == MediaType.TV: - episodes_info = self.tmdbchain.tmdb_episodes(tmdbid=mediainfo.tmdb_id, - season=file_meta.begin_season or 1) - else: - episodes_info = None - - # 获取下载Hash - download_hash = None - if download_history: - download_hash = download_history.download_hash - - # 转移 - transferinfo: TransferInfo = self.chain.transfer(mediainfo=mediainfo, - path=file_path, - transfer_type=transfer_type, - target=target, - meta=file_meta, - episodes_info=episodes_info) - - if not transferinfo: - logger.error("文件转移模块运行失败") - return - - if not transferinfo.success: - # 转移失败 - logger.warn(f"{file_path.name} 入库失败:{transferinfo.message}") - # 新增转移失败历史记录 - self.transferhis.add_fail( - src_path=file_path, - mode=transfer_type, - download_hash=download_hash, - meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo - ) - if self._notify: - self.chain.post_message(Notification( - mtype=NotificationType.Manual, - title=f"{mediainfo.title_year}{file_meta.season_episode} 入库失败!", - text=f"原因:{transferinfo.message or '未知'}", - image=mediainfo.get_message_image() - )) - return - - # 新增转移成功历史记录 - self.transferhis.add_success( - src_path=file_path, - mode=transfer_type, - download_hash=download_hash, - meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo - ) - - # 刮削单个文件 - if self._scrape: - self.chain.scrape_metadata(path=transferinfo.target_path, - mediainfo=mediainfo, - transfer_type=transfer_type) - - """ - { - "title_year season": { - "files": [ - { - "path":, - "mediainfo":, - "file_meta":, - "transferinfo": - } - ], - "time": "2023-08-24 23:23:23.332" - } - } - """ - # 发送消息汇总 - media_list = self._medias.get(mediainfo.title_year + " " + file_meta.season) or {} - if media_list: - media_files = media_list.get("files") or [] - if media_files: - file_exists = False - for file in media_files: - if str(file_path) == file.get("path"): - file_exists = True - break - if not file_exists: - media_files.append({ - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - }) - else: - media_files = [ - { - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - } - ] - media_list = { - "files": media_files, - "time": datetime.datetime.now() - } - else: - media_list = { - "files": [ - { - "path": str(file_path), - "mediainfo": mediainfo, - "file_meta": file_meta, - "transferinfo": transferinfo - } - ], - "time": datetime.datetime.now() - } - self._medias[mediainfo.title_year + " " + file_meta.season] = media_list - - # 广播事件 - self.eventmanager.send_event(EventType.TransferComplete, { - 'meta': file_meta, - 'mediainfo': mediainfo, - 'transferinfo': transferinfo - }) - - # 移动模式删除空目录 - if transfer_type == "move": - for file_dir in file_path.parents: - if len(str(file_dir)) <= len(str(Path(mon_path))): - # 重要,删除到监控目录为止 - break - files = SystemUtils.list_files(file_dir, settings.RMT_MEDIAEXT) - if not files: - logger.warn(f"移动模式,删除空目录:{file_dir}") - shutil.rmtree(file_dir, ignore_errors=True) - - except Exception as e: - logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) - - def torrent_event(self, event, mon_path: str, text: str): - """ - 处理种子文件 - :param mon_path: 种子目录 - """ - evc_path = Path(event.src_path) - if not event.is_directory and (evc_path.suffix == ".torrent" or str(evc_path).split('.')[1] == "torrent"): - # 文件发生变化 - logger.debug("文件%s:%s" % (text, mon_path)) - self.__handle_torrent(torrent_path=self._torrents_path) - else: - logger.debug("不是种子文件:%s" % mon_path) - - def __handle_torrent(self, torrent_path: str): - torrent_path = Path(torrent_path) - try: - if not torrent_path.exists(): - return - # 只处理刚刚添加的种子也就是获取正在下载的种子 - # 等待种子文件下载完成 - time.sleep(5) - with lock: - torrents = self.qb.get_downloading_torrents() - for torrent in torrents: - if "VCB-Studio" in torrent.name: - logger.info(f"开始转移qb中正在下载的VCB资源,转移目录为:{self.new_save_path}") - # 原本存在的暂停的种子不处理 - if torrent.state_enum == qbittorrentapi.TorrentState.PAUSED_DOWNLOAD: - continue - if torrent.save_path == self.new_save_path: - continue - torrent.pause() - torrent.set_save_path(save_path=self.new_save_path) - torrent.resume() - else: - continue - except qbittorrentapi.exceptions.APIError as e: - logger.error(f"VCB辅助整理模块转移qb文件移动失败:{e}") - - def send_msg(self): - """ - 定时检查是否有媒体处理完,发送统一消息 - """ - if not self._medias or not self._medias.keys(): - return - - # 遍历检查是否已刮削完,发送消息 - for medis_title_year_season in list(self._medias.keys()): - media_list = self._medias.get(medis_title_year_season) - logger.info(f"开始处理媒体 {medis_title_year_season} 消息") - - if not media_list: - continue - - # 获取最后更新时间 - last_update_time = media_list.get("time") - media_files = media_list.get("files") - if not last_update_time or not media_files: - continue - - transferinfo = media_files[0].get("transferinfo") - file_meta = media_files[0].get("file_meta") - mediainfo = media_files[0].get("mediainfo") - # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 - if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ - or mediainfo.type == MediaType.MOVIE: - # 发送通知 - if self._notify: - - # 汇总处理文件总大小 - total_size = 0 - file_count = 0 - - # 剧集汇总 - episodes = [] - for file in media_files: - transferinfo = file.get("transferinfo") - total_size += transferinfo.total_size - file_count += 1 - - file_meta = file.get("file_meta") - if file_meta and file_meta.begin_episode: - episodes.append(file_meta.begin_episode) - - transferinfo.total_size = total_size - # 汇总处理文件数量 - transferinfo.file_count = file_count - - # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 - season_episode = None - # 处理文件多,说明是剧集,显示季入库消息 - if mediainfo.type == MediaType.TV: - # 季集文本 - season_episode = f"{file_meta.season} {StringUtils.format_ep(episodes)}" - # 发送消息 - self.transferchian.send_transfer_message(meta=file_meta, - mediainfo=mediainfo, - transferinfo=transferinfo, - season_episode=season_episode) - # 发送完消息,移出key - del self._medias[medis_title_year_season] - continue - - def get_state(self) -> bool: - return self._enabled - - def get_service(self) -> List[Dict[str, Any]]: - """ - 注册插件公共服务 - [{ - "id": "服务ID", - "name": "服务名称", - "trigger": "触发器:cron/interval/date/CronTrigger.from_crontab()", - "func": self.xxx, - "kwargs": {} # 定时器参数 - }] - """ - if self._enabled and self._cron: - return [{ - "id": "vcbanimemonitor", - "name": "vcbanimemonitor", - "trigger": CronTrigger.from_crontab(self._cron), - "func": self.sync_all, - "kwargs": {} - }] - return [] - - def sync(self) -> schemas.Response: - """ - API调用目录同步 - """ - self.sync_all() - return schemas.Response(success=True) - - def get_api(self) -> List[Dict[str, Any]]: - pass - - def get_command(self) -> List[Dict[str, Any]]: - pass - - def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: - return [ - { - 'component': 'VForm', - 'content': [ - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'enabled', - 'label': '启用插件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'notify', - 'label': '发送通知', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'onlyonce', - 'label': '立即运行一次', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'ova', - 'label': '开启识别OVA/OAD文件', - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSwitch', - 'props': { - 'model': 'scrape', - 'label': '刮削元数据', - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'mode', - 'label': '监控模式', - 'items': [ - {'title': '兼容模式', 'value': 'compatibility'}, - {'title': '性能模式', 'value': 'fast'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VSelect', - 'props': { - 'model': 'transfer_type', - 'label': '转移方式', - 'items': [ - {'title': '移动', 'value': 'move'}, - {'title': '复制', 'value': 'copy'}, - {'title': '硬链接', 'value': 'link'}, - {'title': '软链接', 'value': 'softlink'}, - {'title': 'Rclone复制', 'value': 'rclone_copy'}, - {'title': 'Rclone移动', 'value': 'rclone_move'} - ] - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'interval', - 'label': '入库消息延迟', - 'placeholder': '10' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'cron', - 'label': '定时全量同步周期', - 'placeholder': '5位cron表达式,留空关闭' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'size', - 'label': '监控文件大小(GB)', - 'placeholder': '0' - } - } - ] - }, - { - 'component': 'VCol', - 'props': { - 'cols': 12, - 'md': 4 - }, - 'content': [ - { - 'component': 'VTextField', - 'props': { - 'model': 'torrents_path', - 'label': '监控种子目录', - 'placeholder': '填入路径代表启用' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12 - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'monitor_dirs', - 'label': '监控目录', - 'rows': 4, - 'placeholder': '每一行一个目录,支持以下几种配置方式,转移方式支持 move、copy、link、softlink、rclone_copy、rclone_move:\n' - '监控目录\n' - '监控目录#转移方式\n' - '监控目录:转移目的目录\n' - '监控目录:转移目的目录#转移方式' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VTextarea', - 'props': { - 'model': 'exclude_keywords', - 'label': '排除关键词', - 'rows': 2, - 'placeholder': '每一行一个关键词' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '核心用法与目录同步插件相同,不同点在于只识别处理VCB-Studio资源。' - '默认不处理SPs、CDs、SCans目录下的文件,OVA/OAD集数暂时根据入库顺序累加命名,' - '因此不保证与TMDB集数匹配。部分季度以罗马音音译为名的作品暂时无法识别出准确季度。' - '有想法,有问题欢迎点击插件作者主页提issue!' - } - } - ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ - { - 'component': 'VCol', - 'props': { - 'cols': 12, - }, - 'content': [ - { - 'component': 'VAlert', - 'props': { - 'type': 'info', - 'variant': 'tonal', - 'text': '最佳使用方式:监控目录单独设置一个作为保存VCB-Studio资源的目录,' - '填入监控种子目录,开启后会将正在QB(仅支持QB)下载器内正在下载的VCB-Studio资源转移到监控目录实现自动整理(' - '仅支持第一个监控目录),' - '监控种子目录为空则不转移文件' - } - } - ] - } - ] - }, - ] - }, - ], { - "enabled": False, - "notify": False, - "onlyonce": False, - "mode": "fast", - "transfer_type": settings.TRANSFER_TYPE, - "monitor_dirs": "", - "exclude_keywords": "", - "interval": 10, - "cron": "", - "size": 0, - "ova": False, - "torrents_path": "", - } - - def get_page(self) -> List[dict]: - pass - - def stop_service(self): - """ - 退出插件 - """ - if self._observer: - for observer in self._observer: - try: - observer.stop() - observer.join() - except Exception as e: - print(str(e)) - self._observer = [] - if self._scheduler: - self._scheduler.remove_all_jobs() - if self._scheduler.running: - self._event.set() - self._scheduler.shutdown() - self._event.clear() - self._scheduler = None diff --git a/plugins.v2/vcbanimemonitor/remeta.py b/plugins.v2/vcbanimemonitor/remeta.py deleted file mode 100644 index ea261eb..0000000 --- a/plugins.v2/vcbanimemonitor/remeta.py +++ /dev/null @@ -1,284 +0,0 @@ -import concurrent -import re -from dataclasses import dataclass -from pathlib import Path -from typing import List -from app.chain.media import MediaChain -from app.chain.tmdb import TmdbChain -from app.core.metainfo import MetaInfoPath -from app.log import logger -from app.schemas import MediaType - -season_patterns = [ - {"pattern": re.compile(r"S(\d+)$", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(\d+)$", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(\d+)(st|nd|rd|th)?\s*season", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r"(.*) ?\s*season (\d+)", re.IGNORECASE), "group": 2}, - {"pattern": re.compile(r"\s(II|III|IV|V|VI|VII|VIII|IX|X)$", re.IGNORECASE), "group": "1"} -] -episode_patterns = [ - {"pattern": re.compile(r"(\d+)\((\d+)\)", re.IGNORECASE), "group": 2}, - {"pattern": re.compile(r"(\d+)", re.IGNORECASE), "group": 1}, - {"pattern": re.compile(r'(\d+)v\d+', re.IGNORECASE), "group": 1}, -] - -ova_patterns = [ - re.compile(r".*?(OVA|OAD).*?", re.IGNORECASE), - re.compile(r"\d+\.5"), - re.compile(r"00") -] - -final_season_patterns = [ - re.compile('final season', re.IGNORECASE), - re.compile('The Final', re.IGNORECASE), - re.compile(r'\sFinal') -] - -movie_patterns = [ - re.compile("Movie", re.IGNORECASE), - re.compile("the Movie", re.IGNORECASE), -] - - -@dataclass -class VCBMetaBase: - # 转化为小写后的原始文件名称 (不含后缀) - original_title: str = "" - # 解析后不包含季度和集数的标题 - title: str = "" - # 类型:TV / Movie (默认TV) - type: str = "TV" - # 可能含有季度的标题,一级解析后的标题 - season_title: str = "" - # 可能含有集数的字符串列表 - ep_title: List[str] = None - # 识别出来的季度 - season: int = None - # 识别出来的集数 - ep: int = None - # 是否是OVA/OAD - is_ova: bool = False - # TMDB ID - tmdb_id: int = None - - -blocked_words = ["vcb-studio", "360p", "480p", "720p", "1080p", "2160p", "hdr", "x265", "x264", "aac", "flac"] - - -class ReMeta: - - def __init__(self, ova_switch: bool = False, custom_season_patterns: list[dict] = None): - self.meta = None - # TODO:自定义季度匹配规则 - self.custom_season_patterns = custom_season_patterns - self.season_patterns = season_patterns - self.ova_switch = ova_switch - self.vcb_meta = VCBMetaBase() - self.is_ova = False - - def is_tv(self, title: str) -> bool: - """ - 判断是否是TV - """ - if title.count("[") != 4 and title.count("]") != 4: - self.vcb_meta.type = "Movie" - self.vcb_meta.title = re.sub(r'\[.*?\]', '', title).strip() - return False - return True - - def handel_file(self, file_path: Path): - file_name = file_path.stem.strip().lower() - self.vcb_meta.original_title = file_name - if not self.is_tv(file_name): - logger.warn( - "不符合VCB-Studio的剧集命名规范,归类为电影,跳过剧集模块处理。注意:年份较为久远的作品可能在此会判断错误") - self.parse_movie() - else: - self.tv_mode() - self.is_ova = self.vcb_meta.is_ova - meta = MetaInfoPath(file_path) - meta.title = self.vcb_meta.title - meta.en_name = self.vcb_meta.title - if self.vcb_meta.type == "Movie": - meta.type = MediaType.MOVIE - else: - meta.type = MediaType.TV - if self.vcb_meta.ep is not None: - meta.begin_episode = self.vcb_meta.ep - if self.vcb_meta.season is not None: - meta.begin_season = self.vcb_meta.season - if self.vcb_meta.tmdb_id is not None: - meta.tmdbid = self.vcb_meta.tmdb_id - return meta - - def split_season_ep(self): - # 把所有的[] 里面的内容获取出来,不需要[]本身 - self.vcb_meta.ep_title = re.findall(r'\[(.*?)\]', self.vcb_meta.original_title) - # 去除所有[]后只剩下剧名 - self.vcb_meta.season_title = re.sub(r"\[.*?\]", "", self.vcb_meta.original_title).strip() - if self.vcb_meta.ep_title: - self.culling_blocked_words() - logger.info( - f"分离出包含可能季度的内容部分:{self.vcb_meta.season_title} | 可能包含集数的内容部分: {self.vcb_meta.ep_title}") - self.vcb_meta.title = self.vcb_meta.season_title - if not self.vcb_meta.ep_title: - self.vcb_meta.title = self.vcb_meta.season_title - logger.warn("未识别出可能存在集数位置的信息,跳过剩余识别步骤!") - - def tv_mode(self): - logger.info("开始分离季度和集数部分") - self.split_season_ep() - if not self.vcb_meta.ep_title: - return - self.parse_season() - self.parse_episode() - - def parse_season(self): - """ - 从标题中解析季度 - """ - flag = False - for pattern in season_patterns: - match = pattern["pattern"].search(self.vcb_meta.season_title) - if match: - if isinstance(pattern["group"], int): - self.vcb_meta.season = int(match.group(pattern["group"])) - else: - self.vcb_meta.season = self.roman_to_int(match.group(pattern["group"])) - # 匹配成功后,标题中去除季度信息 - self.vcb_meta.title = pattern["pattern"].sub("", self.vcb_meta.season_title).strip - logger.info(f"识别出季度为{self.vcb_meta.season}") - return - logger.info(f"正常匹配季度失败,开始匹配ova/oad/最终季度") - if not flag: - # 匹配是否为最终季 - for pattern in final_season_patterns: - if pattern.search(self.vcb_meta.season_title): - logger.info("命中到最终季匹配规则") - self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() - self.handle_final_season() - return - logger.info("未识别出最终季度,开始匹配OVA/OAD") - # 匹配是否为OVA/OAD - if "ova" in self.vcb_meta.season_title or "oad" in self.vcb_meta.season_title: - logger.info("季度部分命中到OVA/OAD匹配规则") - if self.ova_switch: - logger.info("开启OVA/OAD处理逻辑") - self.vcb_meta.is_ova = True - for pattern in ova_patterns: - if pattern.search(self.vcb_meta.season_title): - self.vcb_meta.title = pattern.sub("", self.vcb_meta.season_title).strip() - self.vcb_meta.title = re.sub("ova|oad", "", self.vcb_meta.season_title).strip() - self.vcb_meta.season = 0 - return - logger.warn("未识别出季度,默认处理逻辑返回第一季") - self.vcb_meta.title = self.vcb_meta.season_title - self.vcb_meta.season = 1 - - def parse_episode(self): - """ - 从标题中解析集数 - """ - # 从ep_title中剔除不相关的内容之后只剩下存在集数的字符串 - ep = self.vcb_meta.ep_title[0] - for pattern in episode_patterns: - match = pattern["pattern"].search(ep) - if match: - self.vcb_meta.ep = int(match.group(pattern["group"])) - logger.info(f"识别出集数为{self.vcb_meta.ep}") - return - # 直接进入判断是否为OVA/OAD - for pattern in ova_patterns: - if pattern.search(ep): - self.vcb_meta.is_ova = True - # 直接获取数字 - self.vcb_meta.ep = int(re.search(r"\d+", ep).group()) or 1 - logger.info(f"OVA模式下识别出集数为{self.vcb_meta.ep}") - self.vcb_meta.season = 0 - return - - def culling_blocked_words(self): - """ - 从ep_title中剔除不相关的内容 - """ - blocked_set = set(blocked_words) # 将阻止词列表转换为集合 - result = [ep for ep in self.vcb_meta.ep_title if not any(word in ep for word in blocked_set)] - self.vcb_meta.ep_title = result - - def handle_final_season(self): - - _, medias = MediaChain().search(title=self.vcb_meta.title) - if not medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - - filter_medias = [media for media in medias if media.type == MediaType.TV] - if not filter_medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - medias = [media for media in filter_medias if media.popularity or media.vote_average] - if not medias: - logger.warning("匹配到最终季时无法找到对应的媒体信息!季度返回默认值:1") - self.vcb_meta.season = 1 - return - # 获取欢迎度最高或者评分最高的媒体 - medias_sorted = sorted(medias, key=lambda x: x.popularity or x.vote_average, reverse=True)[0] - self.vcb_meta.tmdb_id = medias_sorted.tmdb_id - if medias_sorted.tmdb_id: - seasons_info = TmdbChain().tmdb_seasons(tmdbid=medias_sorted.tmdb_id) - if seasons_info: - self.vcb_meta.season = len(seasons_info) - logger.info(f"获取到最终季度,季度为{self.vcb_meta.season}") - return - logger.warning("无法获取到最终季度信息,季度返回默认值:1") - self.vcb_meta.season = 1 - - - - def parse_movie(self): - logger.info("开始尝试剧场版模式解析") - for pattern in movie_patterns: - if pattern.search(self.vcb_meta.title): - logger.info("命中剧场版匹配规则,加上剧场版标识辅助识别") - self.vcb_meta.type = "Movie" - self.vcb_meta.title = pattern.sub("", self.vcb_meta.title).strip() - self.vcb_meta.title = self.vcb_meta.title - return - - def find_ova_episode(self): - """ - 搜索OVA的集数 - TODO:模糊匹配OVA的集数 - """ - pass - - - @staticmethod - def roman_to_int(s) -> int: - """ - :param s: 罗马数字字符串 - 罗马数字转整数 - """ - roman_dict = {'I': 1, 'V': 5, 'X': 10, 'L': 50, 'C': 100, 'D': 500, 'M': 1000} - total = 0 - prev_value = 0 - - for char in reversed(s): # 反向遍历罗马数字字符串 - current_value = roman_dict[char] - if current_value >= prev_value: - total += current_value # 如果当前值大于等于前一个值,加上当前值 - else: - total -= current_value # 如果当前值小于前一个值,减去当前值 - prev_value = current_value - - return total - - - -# if __name__ == '__main__': -# ReMeta( -# ova_switch=True, -# ).handel_file(Path( -# r"[Airota&Nekomoe kissaten&VCB-Studio] Yuru Camp [Heya Camp EP00][Ma10p_1080p][x265_flac].mkv"))