From 76ef7e2b8a52e0f4b1da92fb264363244508dfe1 Mon Sep 17 00:00:00 2001 From: thsrite Date: Sun, 23 Jun 2024 19:47:05 +0800 Subject: [PATCH] =?UTF-8?q?feat=20=E4=BA=91=E7=9B=98=E5=8A=A9=E6=89=8Bv1.3?= =?UTF-8?q?=20=E5=8A=9F=E8=83=BD=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- package.json | 2 +- plugins/cloudassistant/__init__.py | 415 +++++++++++++++++------- plugins/cloudassistant/requirements.txt | 1 - 4 files changed, 303 insertions(+), 117 deletions(-) delete mode 100644 plugins/cloudassistant/requirements.txt diff --git a/README.md b/README.md index 625697c..c7b9d2c 100644 --- a/README.md +++ b/README.md @@ -42,4 +42,4 @@ MoviePilot三方插件市场:https://github.com/thsrite/MoviePilot-Plugins/ - 目录监控(统一入库消息增强版) v1.0 - Sql执行器 v1.2 - 命令执行器 v1.2 -- 云盘助手 v1.1 \ No newline at end of file +- 云盘助手 v1.3 \ No newline at end of file diff --git a/package.json b/package.json index 5dc625a..ce0e07f 100644 --- a/package.json +++ b/package.json @@ -520,7 +520,7 @@ "name": "云盘助手", "description": "定时移动到云盘,软连接/strm回本地,定时清理无效软连接。", "labels": "云盘", - "version": "1.2", + "version": "1.3", "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cloudassistant.png", "author": "thsrite", "level": 3, diff --git a/plugins/cloudassistant/__init__.py b/plugins/cloudassistant/__init__.py index 91062ca..d32f620 100644 --- a/plugins/cloudassistant/__init__.py +++ b/plugins/cloudassistant/__init__.py @@ -3,6 +3,7 @@ import json import os import re import shutil +import subprocess import threading import time import traceback @@ -13,7 +14,6 @@ from typing import List, Tuple, Dict, Any, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from clouddrive.proto import CloudDrive_pb2 from watchdog.events import FileSystemEventHandler from watchdog.observers import Observer from watchdog.observers.polling import PollingObserver @@ -26,10 +26,13 @@ from app.core.event import eventmanager, Event from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.transferhistory_oper import TransferHistoryOper from app.log import logger +from app.modules.emby import Emby from app.plugins import _PluginBase from app.schemas.types import EventType, SystemConfigKey +from app.utils.http import RequestUtils from app.utils.system import SystemUtils -from clouddrive import CloudDriveClient + +# from clouddrive import CloudDriveClient lock = threading.Lock() @@ -61,7 +64,7 @@ class CloudAssistant(_PluginBase): # 插件图标 plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cloudassistant.png" # 插件版本 - plugin_version = "1.1" + plugin_version = "1.3" # 插件作者 plugin_author = "thsrite" # 作者主页 @@ -84,6 +87,8 @@ class CloudAssistant(_PluginBase): _notify = False _onlyonce = False _invalid = False + _only_media = False + _refresh = False _cron = None _invalid_cron = None _clean = False @@ -96,16 +101,13 @@ class CloudAssistant(_PluginBase): _event = threading.Event() example = { - "cd2_url": "cd2地址:http://localhost:19798", - "username": "用户名", - "password": "密码", + "transfer_type": "copy/move", "return_mode": "softlink", "monitor_dirs": [ { "monitor_mode": "模式 compatibility/fast", "local_path": "/mnt/media/movies", "mount_path": "/mnt/cloud/115/media/movies", - "cd2_path": "/115/media/movies", "return_path": "/mnt/softlink/movies", "delete_local": "false", "delete_history": "false", @@ -115,9 +117,11 @@ class CloudAssistant(_PluginBase): } ] } - _client = None - _fs = None + # _client = None + # _fs = None _return_mode = None + _EMBY_HOST = settings.EMBY_HOST + _EMBY_APIKEY = settings.EMBY_API_KEY def init_plugin(self, config: dict = None): self.transferhis = TransferHistoryOper() @@ -141,6 +145,12 @@ class CloudAssistant(_PluginBase): self._rmt_mediaext = config.get( "rmt_mediaext") or ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + if self._EMBY_HOST: + if not self._EMBY_HOST.endswith("/"): + self._EMBY_HOST += "/" + if not self._EMBY_HOST.startswith("http"): + self._EMBY_HOST = "http://" + self._EMBY_HOST + # 清理插件历史 if self._clean: self.del_data(key="history") @@ -168,26 +178,27 @@ class CloudAssistant(_PluginBase): self.__update_config() if self._enabled or self._onlyonce: - # 检查cd2配置 dir_confs = json.loads(self._dir_confs) - if not dir_confs.get("cd2_url") or not dir_confs.get("username") or not dir_confs.get("password"): - if not dir_confs.get("transfer_type"): - logger.error("未正确配置CloudDrive2或者transfer_type,请检查配置") - return - else: - self._transfer_type = dir_confs.get("transfer_type") - logger.warn("未配置CloudDrive2,使用transfer_type转移模式") - else: - try: - self._client = CloudDriveClient(dir_confs.get("cd2_url"), - dir_confs.get("username"), - dir_confs.get("password")) - if self._client: - self._fs = self._client.fs - except Exception as e: - logger.warn(f"未正确配置CloudDrive2,请检查配置:{e}") - return + # 检查cd2配置 + # if not dir_confs.get("cd2_url") or not dir_confs.get("username") or not dir_confs.get("password"): + # if not dir_confs.get("transfer_type"): + # logger.error("未正确配置CloudDrive2或者transfer_type,请检查配置") + # return + # else: + # self._transfer_type = dir_confs.get("transfer_type") + # logger.warn("未配置CloudDrive2,使用transfer_type转移模式") + # else: + # try: + # self._client = CloudDriveClient(dir_confs.get("cd2_url"), + # dir_confs.get("username"), + # dir_confs.get("password")) + # if self._client: + # self._fs = self._client.fs + # except Exception as e: + # logger.warn(f"未正确配置CloudDrive2,请检查配置:{e}") + # return + self._transfer_type = dir_confs.get("transfer_type") self._return_mode = dir_confs.get("return_mode") or "softlink" # 读取目录配置 @@ -278,6 +289,8 @@ class CloudAssistant(_PluginBase): "dir_confs": self._dir_confs, "exclude_keywords": self._exclude_keywords, "cron": self._cron, + "only_media": self._only_media, + "refresh": self._refresh, "invalid_cron": self._invalid_cron, "rmt_mediaext": self._rmt_mediaext }) @@ -383,7 +396,7 @@ class CloudAssistant(_PluginBase): # 查询转移配置 monitor_dir = self._dirconf.get(mon_path) mount_path = monitor_dir.get("mount_path") - cd2_path = monitor_dir.get("cd2_path") + # cd2_path = monitor_dir.get("cd2_path") return_path = monitor_dir.get("return_path") delete_local = monitor_dir.get("delete_local") or "false" delete_history = monitor_dir.get("delete_history") or "false" @@ -397,40 +410,52 @@ class CloudAssistant(_PluginBase): if str(upload_cloud) == "true": # cd2模式 - if self._client: - logger.info("开始上传文件到CloudDrive2") - # cd2目标路径 - cd2_file = str(file_path).replace(str(mon_path), str(cd2_path)) - logger.info(f"cd2目录文件 {cd2_file}") + # if self._client: + # logger.info("开始上传文件到CloudDrive2") + # # cd2目标路径 + # cd2_file = str(file_path).replace(str(mon_path), str(cd2_path)) + # logger.info(f"cd2目录文件 {cd2_file}") + # + # # 上传前先检查文件是否存在 + # cd2_file_exists = False + # if str(overwrite) == "false": + # if self._fs.exists(Path(cd2_file)): # 云盘文件存在则跳过 + # logger.info(f"云盘文件 {cd2_file} 已存在,跳过上传") + # cd2_file_exists = True + # + # if not cd2_file_exists: + # # cd2目录不存在则创建 + # if not self._fs.exists(Path(cd2_file).parent): + # self._fs.mkdir(Path(cd2_file).parent) + # logger.info(f"创建cd2目录 {Path(cd2_file).parent}") + # # 切换cd2路径 + # self._fs.chdir(Path(cd2_file).parent) + # + # # 上传文件到cd2 + # logger.info(f"开始上传文件 {file_path} 到 {cd2_file}") + # self._fs.upload(file_path, overwrite_or_ignore=True) + # self._fs.move(file_path) + # logger.info(f"上传文件 {file_path} 到 {cd2_file}完成") + # + # # 上传任务列表 + # # upload_tasklist = self._client.upload_tasklist + # # logger.info(f"上传任务列表 {upload_tasklist}") + # else: + upload = True + if str(overwrite) == "false": + if Path(mount_file).exists(): + logger.info(f"云盘文件 {mount_file} 已存在且未开启覆盖,跳过上传") + upload = False - # 上传前先检查文件是否存在 - cd2_file_exists = False - if str(overwrite) == "false": - if self._fs.exists(Path(cd2_file)): # 云盘文件存在则跳过 - logger.info(f"云盘文件 {cd2_file} 已存在,跳过上传") - cd2_file_exists = True - - if not cd2_file_exists: - # cd2目录不存在则创建 - if not self._fs.exists(Path(cd2_file).parent): - self._fs.mkdir(Path(cd2_file).parent) - logger.info(f"创建cd2目录 {Path(cd2_file).parent}") - # 切换cd2路径 - self._fs.chdir(Path(cd2_file).parent) - - # 上传文件到cd2 - logger.info(f"开始上传文件 {file_path} 到 {cd2_file}") - self._client.MoveFile(CloudDrive_pb2.MoveFileRequest(str(file_path), Path(cd2_file).parent), async_=True) - logger.info(f"上传文件 {file_path} 到 {cd2_file}完成") - - # 上传任务列表 - # upload_tasklist = self._client.upload_tasklist - # logger.info(f"上传任务列表 {upload_tasklist}") - else: - logger.info(f"开始 {self._transfer_type} 方式转移文件") - self.__transfer_file(file_path=file_path, - target_file=mount_file, - transfer_type=self._transfer_type) + if upload: + if Path(file_path).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]: + self.__transfer_file(file_path=file_path, + target_file=mount_file, + transfer_type=self._transfer_type) + else: + # 其他文件复制 + SystemUtils.copy(file_path, Path(mount_file)) # 2、软连接回本地路径 if not Path(mount_file).exists(): @@ -458,35 +483,43 @@ class CloudAssistant(_PluginBase): else: # 其他nfo、jpg等复制文件 - shutil.copy2(str(file_path), target_return_file) + SystemUtils.copy(file_path, Path(target_return_file)) + # shutil.copy2(str(file_path), target_return_file) logger.info(f"复制其他文件 {str(file_path)} 到 {target_return_file}") retcode = 0 if retcode == 0: + transferhis = self.transferhis.get_by_dest(str(file_path)) + if transferhis and self._refresh: + self.__refresh_emby(transferhis) + # 是否删除本地历史 if str(delete_history) == "true": - transferhis = self.transferhis.get_by_src(str(file_path)) if transferhis: self.transferhis.delete(transferhis.id) logger.info(f"删除本地历史记录:{transferhis.id}") # 3、存操作记录 - history = self.get_data('history') or [] - history.append({ - "file_path": str(file_path), - "target_cloud_file": mount_file, - "target_soft_file": target_return_file, - "delete_local": delete_local, - "delete_history": delete_history, - "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) - }) - # 保存历史 - self.save_data(key="history", value=history) + if (self._only_media and Path(file_path).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]) \ + or not self._only_media: + history = self.get_data('history') or [] + history.append({ + "file_path": str(file_path), + "target_cloud_file": mount_file, + "target_soft_file": target_return_file, + "delete_local": delete_local, + "delete_history": delete_history, + "time": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time())) + }) + # 保存历史 + self.save_data(key="history", value=history) # 移动模式删除空目录 if str(delete_local) == "true": - file_path.unlink() - logger.info(f"删除本地文件:{file_path}") + if file_path.exists(): + file_path.unlink() + logger.info(f"删除本地文件:{file_path}") for file_dir in file_path.parents: if len(str(file_dir)) <= len(str(Path(mon_path))): # 重要,删除到监控目录为止 @@ -502,7 +535,7 @@ class CloudAssistant(_PluginBase): """ 转移文件 """ - logger.info(f"开始{transfer_type}文件 {str(file_path)} 到 {target_file}") + logger.info(f"开始 {transfer_type} 文件 {str(file_path)} 到 {target_file}") # 如果是文件夹 if Path(target_file).is_dir(): if not Path(target_file).exists(): @@ -519,41 +552,38 @@ class CloudAssistant(_PluginBase): logger.info(f"创建目标文件夹 {Path(target_file).parent}") os.makedirs(Path(target_file).parent) - # 媒体文件软连接 + # 媒体文件转移 retcode, retmsg = self.__transfer_command(file_path, Path(target_file), transfer_type) logger.info( f"媒体文件{str(file_path)} {transfer_type} 到 {target_file} {retcode} {retmsg}") return retcode - @staticmethod - def __transfer_command(file_item: Path, target_file: Path, transfer_type: str): + def __transfer_command(self, file_item: Path, target_file: Path, transfer_type: str): """ 使用系统命令处理单个文件 :param file_item: 文件路径 :param target_file: 目标文件路径 :param transfer_type: RmtMode转移方式 """ - with lock: - - # 转移 - if transfer_type == 'link': - # 硬链接 - retcode, retmsg = SystemUtils.link(file_item, target_file) - elif transfer_type == 'softlink': - # 软链接 - retcode, retmsg = SystemUtils.softlink(file_item, target_file) - elif transfer_type == 'move': - # 移动 - retcode, retmsg = SystemUtils.move(file_item, target_file) - elif transfer_type == 'rclone_move': - # Rclone 移动 - retcode, retmsg = SystemUtils.rclone_move(file_item, target_file) - elif transfer_type == 'rclone_copy': - # Rclone 复制 - retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file) + # 转移 + if transfer_type == 'link': + # 硬链接 + retcode, retmsg = SystemUtils.link(file_item, target_file) + elif transfer_type == 'softlink': + # 软链接 + retcode, retmsg = SystemUtils.softlink(file_item, target_file) + elif transfer_type == 'move': + # 复制 + retcode, retmsg = SystemUtils.copy(file_item, target_file) + if retcode == 0: + file_item.unlink() else: - # 复制 - retcode, retmsg = SystemUtils.copy(file_item, target_file) + logger.error(f"移动文件失败 {file_item} {target_file} {retcode} {retmsg}") + # 移动 + # retcode, retmsg = SystemUtils.move(file_item, target_file) + else: + # 复制 + retcode, retmsg = SystemUtils.copy(file_item, target_file) if retcode != 0: logger.error(retmsg) @@ -657,6 +687,129 @@ class CloudAssistant(_PluginBase): os.symlink(new_target, path) print(f"Updated symlink: {path} -> {new_target}") + def __refresh_emby(self, transferinfo): + """ + 刷新emby + """ + if transferinfo.type == "电影": + movies = Emby().get_movies(title=transferinfo.title, year=transferinfo.year) + if not movies: + logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})") + return + for movie in movies: + self.__refresh_emby_library_by_id(item_id=movie.item_id) + logger.info(f"已通知刷新Emby电影:{movie.title} ({movie.year}) item_id:{movie.item_id}") + else: + item_id = self.__get_emby_series_id_by_name(name=transferinfo.title, year=transferinfo.year) + if not item_id or item_id is None: + logger.error(f"Emby中没有找到{transferinfo.title} ({transferinfo.year})") + return + + # 验证tmdbid是否相同 + item_info = Emby().get_iteminfo(item_id) + if item_info: + if transferinfo.tmdbid and item_info.tmdbid: + if str(transferinfo.tmdbid) != str(item_info.tmdbid): + logger.error(f"Emby中{transferinfo.title} ({transferinfo.year})的tmdbId与入库记录不一致") + return + + # 查询集的item_id + season = int(transferinfo.seasons.replace("S", "")) + episode = int(transferinfo.episodes.replace("E", "")) + episode_item_id = self.__get_emby_episode_item_id(item_id=item_id, season=season, episode=episode) + if not episode_item_id or episode_item_id is None: + logger.error( + f"Emby中没有找到{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes}") + return + + self.__refresh_emby_library_by_id(item_id=episode_item_id) + logger.info( + f"已通知刷新Emby电视剧:{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes} item_id:{episode_item_id}") + + def __get_emby_episode_item_id(self, item_id: str, season: int, episode: int) -> Optional[str]: + """ + 根据剧集信息查询Emby中集的item_id + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = "%semby/Shows/%s/Episodes?Season=%s&IsMissing=false&api_key=%s" % ( + self._EMBY_HOST, item_id, season, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res_json: + if res_json: + tv_item = res_json.json() + res_items = tv_item.get("Items") + for res_item in res_items: + season_index = res_item.get("ParentIndexNumber") + if not season_index: + continue + if season and season != season_index: + continue + episode_index = res_item.get("IndexNumber") + if not episode_index: + continue + if episode and episode != episode_index: + continue + episode_item_id = res_item.get("Id") + return episode_item_id + except Exception as e: + logger.error(f"连接Shows/Id/Episodes出错:" + str(e)) + return None + return None + + def __refresh_emby_library_by_id(self, item_id: str) -> bool: + """ + 通知Emby刷新一个项目的媒体库 + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return False + req_url = "%semby/Items/%s/Refresh?MetadataRefreshMode=FullRefresh" \ + "&ImageRefreshMode=FullRefresh&ReplaceAllMetadata=true&ReplaceAllImages=true&api_key=%s" % ( + self._EMBY_HOST, item_id, self._EMBY_APIKEY) + try: + with RequestUtils().post_res(req_url) as res: + if res: + return True + else: + logger.info(f"刷新媒体库对象 {item_id} 失败,无法连接Emby!") + except Exception as e: + logger.error(f"连接Items/Id/Refresh出错:" + str(e)) + return False + return False + + def __get_emby_series_id_by_name(self, name: str, year: str) -> Optional[str]: + """ + 根据名称查询Emby中剧集的SeriesId + :param name: 标题 + :param year: 年份 + :return: None 表示连不通,""表示未找到,找到返回ID + """ + if not self._EMBY_HOST or not self._EMBY_APIKEY: + return None + req_url = ("%semby/Items?" + "IncludeItemTypes=Series" + "&Fields=ProductionYear" + "&StartIndex=0" + "&Recursive=true" + "&SearchTerm=%s" + "&Limit=10" + "&IncludeSearchTypes=false" + "&api_key=%s") % ( + self._EMBY_HOST, name, self._EMBY_APIKEY) + try: + with RequestUtils().get_res(req_url) as res: + if res: + res_items = res.json().get("Items") + if res_items: + for res_item in res_items: + if res_item.get('Name') == name and ( + not year or str(res_item.get('ProductionYear')) == str(year)): + return res_item.get('Id') + except Exception as e: + logger.error(f"连接Items出错:" + str(e)) + return None + return "" + def get_state(self) -> bool: return self._enabled @@ -727,7 +880,7 @@ class CloudAssistant(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -743,7 +896,7 @@ class CloudAssistant(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -759,7 +912,7 @@ class CloudAssistant(_PluginBase): 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -770,17 +923,12 @@ class CloudAssistant(_PluginBase): } } ] - } - ] - }, - { - 'component': 'VRow', - 'content': [ + }, { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 }, 'content': [ { @@ -792,11 +940,48 @@ class CloudAssistant(_PluginBase): } ] }, + ] + }, + { + 'component': 'VRow', + 'content': [ { 'component': 'VCol', 'props': { 'cols': 12, - 'md': 4 + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'refresh', + 'label': '刷新媒体库(emby)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'only_media', + 'label': '插件历史仅媒体文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 3 }, 'content': [ { @@ -812,14 +997,14 @@ class CloudAssistant(_PluginBase): "component": "VCol", "props": { "cols": 12, - "md": 4 + "md": 3 }, "content": [ { "component": "VSwitch", "props": { "model": "dialog_closed", - "label": "监控配置径" + "label": "监控路径配置" } } ] @@ -960,7 +1145,7 @@ class CloudAssistant(_PluginBase): { "component": "VCard", "props": { - "title": "监控配置径" + "title": "监控路径配置" }, "content": [ { @@ -1034,6 +1219,8 @@ class CloudAssistant(_PluginBase): "notify": False, "onlyonce": False, "invalid": False, + "refresh": False, + "only_media": False, "clean": False, "exclude_keywords": "", "cron": "", diff --git a/plugins/cloudassistant/requirements.txt b/plugins/cloudassistant/requirements.txt deleted file mode 100644 index 765fe4c..0000000 --- a/plugins/cloudassistant/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -clouddrive \ No newline at end of file