From c5476a41d259b8f3a0e60c60cc7e90640167edc8 Mon Sep 17 00:00:00 2001 From: thsrite Date: Thu, 17 Oct 2024 16:11:20 +0800 Subject: [PATCH] feat MediaSyncDel support v2 --- package.json | 1 - package.v2.json | 54 + plugins.v2/cloudassistant/__init__.py | 1870 +++++++++++++++++++++++++ plugins.v2/mediasyncdel/__init__.py | 1589 +++++++++++++++++++++ 4 files changed, 3513 insertions(+), 1 deletion(-) create mode 100644 plugins.v2/cloudassistant/__init__.py create mode 100644 plugins.v2/mediasyncdel/__init__.py diff --git a/package.json b/package.json index 55307a2..9b2468b 100644 --- a/package.json +++ b/package.json @@ -590,7 +590,6 @@ "author": "thsrite", "level": 99, "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlgN7RtXlPNXoFE9B67ye\ny2mog/hDDrhBAIJogdvfAgBMZ1qVzIPcBfdjENPJ9kV/F+zOoh0CzEaDufM54ERT\nykK1pQw7yj7quRZDbv5byxVNqI8bJg8zQo8Q66SQ8SP+aftmpFrADKClQ8VcVYzZ\nJ+YDu9H9q+TcvBqVtLyKfAH5T9WAxn0bXEh4OgkJn7oO5eI5+Fsi6Aq9suVN/HyK\nz2bDr237GmXJT4YPn9s7kj4Rypzg2ldiuBwtVnaTw+xjZRlCRr4Gs0eFUIMUqnoQ\nip4Px8Mrq5cqHl0HrJ/av/pJLCN1icCgegYW63b2gjjJwmps9NGGOydRzgoFkqj0\nDwIDAQAB", - "v2": true, "history": { "v2.1.5": "监控模式下自定义文件保留时长不生效", "v2.1.4": "自定义文件保留时长,大于该值会被转移", diff --git a/package.v2.json b/package.v2.json index dcab929..6cee5a4 100644 --- a/package.v2.json +++ b/package.v2.json @@ -116,6 +116,45 @@ "v1.0": "根据正则转发通知到其他WeChat应用" } }, + "CloudAssistant": { + "name": "云盘助手", + "description": "本地文件定时转移到云盘,软连接/strm回本地,定时清理无效软连接。", + "labels": "云盘", + "version": "2.2", + "icon": "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cloudassistant.png", + "author": "thsrite", + "level": 99, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlgN7RtXlPNXoFE9B67ye\ny2mog/hDDrhBAIJogdvfAgBMZ1qVzIPcBfdjENPJ9kV/F+zOoh0CzEaDufM54ERT\nykK1pQw7yj7quRZDbv5byxVNqI8bJg8zQo8Q66SQ8SP+aftmpFrADKClQ8VcVYzZ\nJ+YDu9H9q+TcvBqVtLyKfAH5T9WAxn0bXEh4OgkJn7oO5eI5+Fsi6Aq9suVN/HyK\nz2bDr237GmXJT4YPn9s7kj4Rypzg2ldiuBwtVnaTw+xjZRlCRr4Gs0eFUIMUqnoQ\nip4Px8Mrq5cqHl0HrJ/av/pJLCN1icCgegYW63b2gjjJwmps9NGGOydRzgoFkqj0\nDwIDAQAB", + "history": { + "v2.2": "适配v2多媒体服务器", + "v2.1.5": "监控模式下自定义文件保留时长不生效", + "v2.1.4": "自定义文件保留时长,大于该值会被转移", + "v2.1.3": "修复消息通知", + "v2.1.2": "修复清理无效软连接", + "v2.1.1": "修复清理无效软连接", + "v2.1": "修复strm生成", + "v2.0.9": "修复未覆盖时删除监控文件", + "v2.0.8": "修复通知", + "v2.0.7": "修复通知标题", + "v2.0.6": "修复覆盖模式操作", + "v2.0.5": "修复媒体文件转移完但刮削文件未转移删除目录的问题", + "v2.0.4": "修复挂载目录log", + "v2.0.3": "修复默认值bug", + "v2.0.2": "插件自定义数据库操作,避免依赖主程序更新", + "v2.0.1": "修改若看参数名,具体看教程", + "v2.0": "调整通知图片", + "v1.9": "修复非媒体文件转移bug(需要主程序1.9.9+)", + "v1.8": "支持可选开启实时监控", + "v1.7": "转移完成发送消息通知(插件类型消息)", + "v1.6": "修复部分配置未生效bug", + "v1.5": "支持转移后删除源文件、删种", + "v1.4": "转移完删除本地文件时,支持自定义保留目录层级", + "v1.3": "完善逻辑,增加刷新emby媒体库(需要主程序1.9.8+)", + "v1.2": "移除cd2上传", + "v1.1": "支持cd2上传、支持定时清理无效软连接、支持strm生成方式", + "v1.0": "定时移动到云盘,软连接回本地(清理无效软连接暂未开发)" + } + }, "EmbyDanmu": { "name": "Emby弹幕下载", "description": "通知Emby Danmu插件下载弹幕。", @@ -224,5 +263,20 @@ "v1.1": "支持自动刷新媒体库", "v1.0": "媒体库重复媒体检查,可选择保留规则保留其一" } + }, + "MediaSyncDel": { + "name": "媒体文件同步删除", + "description": "同步删除历史记录、源文件和下载任务。", + "labels": "媒体库,文件整理", + "version": "1.8", + "icon": "mediasyncdel.png", + "author": "thsrite", + "level": 1, + "history": { + "v2.0": "适配v2多媒体服务器", + "v1.7": "修复重新整理被一并删除问题", + "v1.6": "修复删除辅种", + "v1.5": "支持手动删除订阅历史记录(本次更新之后的历史)" + } } } diff --git a/plugins.v2/cloudassistant/__init__.py b/plugins.v2/cloudassistant/__init__.py new file mode 100644 index 0000000..189a6f7 --- /dev/null +++ b/plugins.v2/cloudassistant/__init__.py @@ -0,0 +1,1870 @@ +import datetime +import json +import os +import re +import shutil +import subprocess +import threading +import time +import traceback +import urllib +from pathlib import Path +from typing import List, Tuple, Dict, Any, Optional + +import pytz +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 sqlalchemy.orm import Session + +from app import schemas +from app.chain.tmdb import TmdbChain +from app.chain.transfer import TransferChain +from app.core.config import settings +from app.core.event import eventmanager, Event +from app.db import db_query, db_update +from app.db.models import TransferHistory, DownloadHistory, DownloadFiles +from app.log import logger +from app.modules.emby import Emby +from app.plugins import _PluginBase +from app.schemas.types import EventType, SystemConfigKey, MediaType, NotificationType, MediaImageType +from app.utils.http import RequestUtils +from app.utils.string import StringUtils +from app.utils.system import SystemUtils + +lock = threading.Lock() + + +class CloudFileMonitorHandler(FileSystemEventHandler): + """ + 目录监控响应类 + """ + + def __init__(self, monpath: str, sync: Any, **kwargs): + super(CloudFileMonitorHandler, 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 CloudAssistant(_PluginBase): + # 插件名称 + plugin_name = "云盘助手" + # 插件描述 + plugin_desc = "本地文件定时转移到云盘,软连接/strm回本地,定时清理无效软连接。" + # 插件图标 + plugin_icon = "https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/icons/cloudassistant.png" + # 插件版本 + plugin_version = "2.2" + # 插件作者 + plugin_author = "thsrite" + # 作者主页 + author_url = "https://github.com/thsrite" + # 插件配置项ID前缀 + plugin_config_prefix = "cloudassistant_" + # 加载顺序 + plugin_order = 4 + # 可使用的用户级别 + auth_level = 99 + plugin_public_key = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlgN7RtXlPNXoFE9B67yey2mog/hDDrhBAIJogdvfAgBMZ1qVzIPcBfdjENPJ9kV/F+zOoh0CzEaDufM54ERTykK1pQw7yj7quRZDbv5byxVNqI8bJg8zQo8Q66SQ8SP+aftmpFrADKClQ8VcVYzZJ+YDu9H9q+TcvBqVtLyKfAH5T9WAxn0bXEh4OgkJn7oO5eI5+Fsi6Aq9suVN/HyKz2bDr237GmXJT4YPn9s7kj4Rypzg2ldiuBwtVnaTw+xjZRlCRr4Gs0eFUIMUqnoQip4Px8Mrq5cqHl0HrJ/av/pJLCN1icCgegYW63b2gjjJwmps9NGGOydRzgoFkqj0DwIDAQAB" + + # 私有属性 + _scheduler = None + transferhis = None + downloadhis = None + transferchian = None + tmdbchain = None + _observer = [] + _enabled = False + _notify = False + _onlyonce = False + _monitor = False + _invalid = False + _only_media_history = False + _refresh = False + _cron = None + _invalid_cron = None + _clean = False + _exclude_keywords = "" + _interval: int = 60 + _dir_confs = {} + _dirconf = {} + _medias = {} + _transfer_type = None + _rmt_mediaext = ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + + # 退出事件 + _event = threading.Event() + + example = { + "transfer_type": "copy/move", + "return_mode": "softlink", + "monitor_dirs": [ + { + "retention_time": 0, + "monitor_mode": "模式 compatibility/fast", + "dest_path": "/mnt/media/movies", + "mount_path": "/mnt/cloud/115/media/movies", + "return_path": "/mnt/softlink/movies", + "delete_dest": "false", + "dest_preserve_hierarchy": 0, + "delete_history": "false", + "delete_src": "false", + "src_paths": "/mnt/media/movies, /mnt/media/series", + "src_preserve_hierarchy": 0, + "only_media": "true", + "overwrite": "false", + "upload_cloud": "true", + "notify_url": "" + } + ] + } + # _client = None + # _fs = None + _return_mode = None + mediaserver_helper = None + _EMBY_HOST = None + _EMBY_APIKEY = None + + def init_plugin(self, config: dict = None): + self.transferchian = TransferChain() + self.tmdbchain = TmdbChain() + # 清空配置 + self._dirconf = {} + + # 读取配置 + if config: + self._enabled = config.get("enabled") + self._notify = config.get("notify") + self._onlyonce = config.get("onlyonce") + self._monitor = config.get("monitor") or False + self._invalid = config.get("invalid") + self._clean = config.get("clean") + self._exclude_keywords = config.get("exclude_keywords") or "" + self._interval = config.get("interval") or 60 + self._refresh = config.get("refresh") + self._only_media_history = config.get("only_media_history") + self._cron = config.get("cron") + self._invalid_cron = config.get("invalid_cron") + self._dir_confs = config.get("dir_confs") or None + 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" + self._mediaservers = config.get("mediaservers") or [] + + # 清理插件历史 + if self._clean: + self.del_data(key="history") + self._clean = False + self.__update_config() + + if not self._dir_confs: + return + + # 停止现有任务 + self.stop_service() + + # 定时服务管理器 + self._scheduler = BackgroundScheduler(timezone=settings.TZ) + + if self._invalid: + logger.info("清理无效软连接服务启动,立即运行一次") + # 关闭无效软连接开关 + self._invalid = False + # 保存配置 + self.__update_config() + + self._scheduler.add_job(func=self.handle_invalid_links, trigger='date', + run_date=datetime.datetime.now( + tz=pytz.timezone(settings.TZ)) + datetime.timedelta(seconds=3) + ) + + if self._enabled or self._onlyonce: + if self._notify and (self._cron or self._monitor): + # 追加入库消息统一发送服务 + self._scheduler.add_job(self.send_msg, trigger='interval', seconds=15) + + dir_confs = json.loads(self._dir_confs) + # 检查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" + + # 读取目录配置 + monitor_dirs = dir_confs.get("monitor_dirs") or [] + if not monitor_dirs: + return + for monitor_dir in monitor_dirs: + if not monitor_dir: + continue + + # 读取监控目录配置 + mon_path = monitor_dir.get("dest_path") + # 云盘挂载路径 + target_path = monitor_dir.get("mount_path") + # 监控模式 + monitor_mode = monitor_dir.get("monitor_mode") or "compatibility" + self._dirconf[mon_path] = monitor_dir + + # 启用目录监控 + if self._enabled and self._monitor: + # 检查媒体库目录是不是下载目录的子目录 + try: + if target_path and Path(target_path).is_relative_to(Path(mon_path)): + logger.warn(f"{target_path} 是监控目录 {mon_path} 的子目录,无法监控") + self.systemmessage.put(f"{target_path} 是下载目录 {mon_path} 的子目录,无法监控", + title="云盘助手媒体库监控") + continue + except Exception as e: + logger.debug(str(e)) + pass + + try: + if str(monitor_mode) == "compatibility": + # 兼容模式,目录同步性能降低且NAS不能休眠,但可以兼容挂载的远程共享目录如SMB + observer = PollingObserver(timeout=10) + else: + # 内部处理系统操作类型选择最优解 + observer = Observer(timeout=10) + self._observer.append(observer) + observer.schedule(CloudFileMonitorHandler(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="云盘助手媒体库监控") + + # 运行一次定时服务 + 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._invalid_cron: + self._scheduler.add_job(func=self.handle_invalid_links, + trigger=CronTrigger.from_crontab(self._invalid_cron), + id="handle_invalid_links") + logger.info(f"清理无效软连接服务启动,定时任务:{self._invalid_cron}") + + # 启动定时服务 + 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, + "monitor": self._monitor, + "invalid": self._invalid, + "clean": self._clean, + "dir_confs": self._dir_confs, + "exclude_keywords": self._exclude_keywords, + "interval": self._interval, + "cron": self._cron, + "only_media_history": self._only_media_history, + "refresh": self._refresh, + "invalid_cron": self._invalid_cron, + "rmt_mediaext": self._rmt_mediaext, + "mediaservers": self._mediaservers, + }) + + @eventmanager.register(EventType.PluginAction) + def remote_sync(self, event: Event): + """ + 远程全量同步 + """ + if event: + event_data = event.event_data + if not event_data or event_data.get("action") != "cloud_assistant": + return + self.post_message(channel=event.event_data.get("channel"), + title="云盘助手开始同步监控目录 ...", + userid=event.event_data.get("user")) + self.sync_all() + if event: + self.post_message(channel=event.event_data.get("channel"), + title="云盘助手监控目录同步完成!", userid=event.event_data.get("user")) + + def sync_all(self): + """ + 立即运行一次,全量同步目录中所有文件 + """ + logger.info("云盘助手全量同步监控目录 ...") + + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + video_files = [] + other_files = [] + # 遍历目录下所有文件 + for root, dirs, files in os.walk(mon_path): + for name in dirs + files: + file_path = os.path.join(root, name) + if Path(str(file_path)).is_file(): + if Path(str(file_path)).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]: + video_files.append(file_path) + else: + other_files.append(file_path) + + monitor_dir = self._dirconf.get(mon_path) + only_media = monitor_dir.get("only_media") or "true" + if str(only_media) == "false": + # Then, handle other files + for other_file in other_files: + self.__handle_file(event_path=str(other_file), mon_path=mon_path) + # First, handle video files + for video_file in video_files: + self.__handle_file(event_path=str(video_file), 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) + if Path(file_path).is_dir(): + return + try: + if not file_path.exists(): + return + # 全程加锁 + with lock: + # 回收站及隐藏的文件不处理 + 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 + + # 查询转移配置 + monitor_dir = self._dirconf.get(mon_path) + mount_path = monitor_dir.get("mount_path") + # cd2_path = monitor_dir.get("cd2_path") + return_path = monitor_dir.get("return_path") + delete_dest = monitor_dir.get("delete_dest") or "false" + delete_src = monitor_dir.get("delete_src") or "false" + delete_history = monitor_dir.get("delete_history") or "false" + overwrite = monitor_dir.get("overwrite") or "false" + upload_cloud = monitor_dir.get("upload_cloud") or "true" + only_media = monitor_dir.get("only_media") or "true" + dest_preserve_hierarchy = monitor_dir.get("dest_preserve_hierarchy") or 0 + src_paths = monitor_dir.get("src_paths") or "" + src_preserve_hierarchy = monitor_dir.get("src_preserve_hierarchy") or 0 + # 本地文件保留时间 (小时) + retention_time = monitor_dir.get("retention_time") or 0 + if not self._monitor and retention_time > 0: + creation_time = self.__get_file_creation_time(file_path) + creation_datetime = datetime.datetime.fromtimestamp(creation_time) + current_datetime = datetime.datetime.now() + time_difference = (current_datetime - creation_datetime).total_seconds() + if time_difference < (int(retention_time) * 3600): + logger.warning( + f"{file_path} 创建 {round(time_difference / 3600, 2)} 小时,小于保留时间 {retention_time} 小时,暂不处理") + return + + # 判断是不是蓝光目录 + if re.search(r"BDMV[/\\]STREAM", event_path, re.IGNORECASE): + # 截取BDMV前面的路径 + blurray_dir = event_path[:event_path.find("BDMV")] + file_path = Path(blurray_dir) + logger.info(f"{event_path} 是蓝光目录,更正文件路径为:{str(file_path)}") + + # 1、转移到云盘挂载路径 上传到cd2 + # 挂载的路径 + logger.info(f"挂载目录文件 {file_path}") + mount_file = str(file_path).replace(str(mon_path), str(mount_path)) + + # 上传cloud时,如果不是仅媒体文件,则全上传,如果是仅媒体文件,则只上传媒体文件 + if str(upload_cloud) == "true" and (str(only_media) == "false" or ( + str(only_media) == "true" and Path(file_path).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split( + ",")])): + upload = True + if str(overwrite) == "false": + if Path(mount_file).exists(): + logger.info(f"云盘文件 {mount_file} 已存在且未开启覆盖,跳过上传") + upload = False + if str(self._transfer_type) == "move": + Path(file_path).unlink() + else: + if Path(mount_file).exists(): + logger.info(f"云盘文件 {mount_file} 已存在且开启覆盖,删除原云盘文件") + Path(mount_file).unlink() + 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: + # 其他文件复制 + self.__transfer_file(file_path=file_path, + target_file=mount_file, + transfer_type="copy") + + # 2、软连接或者strm回本地路径 + target_return_file = str(file_path).replace(str(mon_path), str(return_path)) + if Path(target_return_file).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]: + # 检查云盘文件是否存在 + if not Path(mount_file).exists(): + logger.info(f"挂载目录文件 {mount_file} 不存在,不创建 {self._return_mode}") + return + + # 媒体文件软连接 + if str(self._return_mode) == "softlink": + # 检查软连接指向是否正确,如果不正确,修正 + if os.path.islink(target_return_file): + current_target = os.readlink(target_return_file) + if str(current_target) != str(target_return_file): + subprocess.run(['ln', '-sf', mount_file, target_return_file]) + logger.info(f"修正软连接 {target_return_file} -> {mount_file}") + retcode = 0 + else: + retcode = self.__transfer_file(file_path=mount_file, + target_file=target_return_file, + transfer_type="softlink") + else: + # 生成strm文件 + retcode = self.__create_strm_file(mount_file=mount_file, + mount_path=mount_path, + target_file=target_return_file, + library_dir=monitor_dir.get("library_dir"), + cloud_type=monitor_dir.get("cloud_type"), + cloud_path=monitor_dir.get("cloud_path"), + cloud_url=monitor_dir.get("cloud_url"), + cloud_scheme=monitor_dir.get("cloud_scheme")) + + if monitor_dir.get("notify_url"): + RequestUtils(content_type="application/json").post(url=monitor_dir.get("notify_url"), json={ + "path": str(mount_file), + "type": "add" + }) + else: + if Path(target_return_file).is_file() and Path(target_return_file).exists(): + logger.info(f"目标文件 {target_return_file} 已存在,不处理") + retcode = 0 + else: + # 其他nfo、jpg等复制文件 + 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.__get_transferhis_by_dest(db=None, dest_path=str(file_path)) + if transferhis and self._refresh: + self.__refresh_emby(transferhis) + + # 是否删除本地历史 + if str(delete_history) == "true": + if transferhis: + self.__delete_history(transferhis) + + # 3、存操作记录 + if (self._only_media_history and Path(file_path).suffix.lower() in [ext.strip() for ext in + self._rmt_mediaext.split(",")]) \ + or not self._only_media_history: + 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_dest": delete_dest, + "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_dest) == "true": + self.__delete_dest_file(file_path, mon_path, dest_preserve_hierarchy, only_media) + # 是否删除源文件 + if str(delete_src) == "true" and transferhis: + self.__delete_src_file(transferhis, src_paths, src_preserve_hierarchy, only_media) + # 发送消息汇总 + if self._notify and transferhis: + self.__msg_handler(transferhis) + + except Exception as e: + logger.error("目录监控发生错误:%s - %s" % (str(e), traceback.format_exc())) + + def __delete_history(self, transferhis): + """ + 删除历史记录 + """ + self.__delete_transferhis_by_id(db=None, transferhisid=transferhis.id) + logger.info(f"删除转移历史记录:{transferhis.id} {transferhis.download_hash}") + + downloadhis = self.__get_downloadhis_by_hash(db=None, download_hash=transferhis.download_hash) + if downloadhis: + self.__delete_downloadhis_by_id(db=None, downloadhisid=downloadhis.id) + logger.info(f"删除下载历史记录:{downloadhis.id} {transferhis.download_hash}") + downloadfiles = self.__get_downloadfiles_by_hash(db=None, download_hash=transferhis.download_hash) + if downloadfiles: + for downloadfile in downloadfiles: + self.__delete_downloadfile_by_id(db=None, downloadfileid=downloadfile.id) + logger.info(f"删除下载文件记录:{downloadfile.id} {transferhis.download_hash}") + + def __delete_dest_file(self, file_path: Path, mon_path: str, dest_preserve_hierarchy: int, only_media: str): + """ + 删除监控文件 + """ + if file_path.exists(): + file_path.unlink() + logger.info(f"删除监控文件:{file_path}") + + # 保留层级 + mon_path_depth = len(Path(mon_path).parts) + retain_depth = mon_path_depth + int(dest_preserve_hierarchy) + + for file_dir in file_path.parents: + if len(file_dir.parts) <= retain_depth: + # 重要,删除到保留层级目录为止 + break + if str(only_media) == "true": + files = SystemUtils.list_files(file_dir, [ext.strip() for ext in + self._rmt_mediaext.split(",")] + settings.DOWNLOAD_TMPEXT) + else: + files = SystemUtils.list_files(file_dir, [".*"]) + if not files: + logger.warn(f"删除监控空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + def __delete_src_file(self, transferhis, src_paths, src_preserve_hierarchy, only_media: str): + """ + 删除源文件 + """ + if Path(transferhis.src).exists(): + Path(transferhis.src).unlink() + logger.info(f"删除源文件:{transferhis.src}") + + # 删除下载文件记录 + self.__delete_downloadfile_by_fullpath(db=None, fullpath=transferhis.src) + + # 发送事件 删种 + eventmanager.send_event( + EventType.DownloadFileDeleted, + { + "src": transferhis.src, + "hash": transferhis.download_hash + } + ) + + # 源文件保留层级 + source_path = None + for source_dir in src_paths.split(","): + source_dir = source_dir.strip() + if not source_dir: + continue + if transferhis.src.startswith(source_dir): + source_path = source_dir + logger.info(f"获取到源文件 {transferhis.src} 根目录 {source_path}") + break + + # 删除源文件空目录 + if source_path: + # 保留层级 + source_path_depth = len(Path(source_path).parts) + retain_depth = source_path_depth + int(src_preserve_hierarchy) + + for file_dir in Path(transferhis.src).parents: + if len(file_dir.parts) <= retain_depth: + # 重要,删除到保留层级目录为止 + break + if str(only_media) == "true": + files = SystemUtils.list_files(file_dir, [ext.strip() for ext in + self._rmt_mediaext.split(",")] + settings.DOWNLOAD_TMPEXT) + else: + files = SystemUtils.list_files(file_dir, [".*"]) + if not files: + logger.warn(f"删除源文件空目录:{file_dir}") + shutil.rmtree(file_dir, ignore_errors=True) + + @staticmethod + def __get_file_creation_time(file_path): + """获取文件的创建时间""" + if os.name == 'nt': # Windows系统 + return os.path.getctime(file_path) + else: # Unix系统 + stat = os.stat(file_path) + try: + return stat.st_birthtime + except AttributeError: + return stat.st_mtime + + def __msg_handler(self, transferhis): + """ + 组织消息发送数据 + """ + """ + { + "title_year season": { + "key": "title_year", + "mtype": "mtype", + "season": "season", + "category": "category", + "image": "image", + "episodes": [], + "time": "2023-08-24 23:23:23.332" + } + } + """ + key = f"{transferhis.title} ({transferhis.year})" + + # 发送消息汇总 + media_list = self._medias.get( + key + " " + transferhis.seasons) or {} + if media_list: + episodes = media_list.get("episodes") or [] + if transferhis.type == MediaType.TV.value: + if episodes: + if int(transferhis.episodes.replace("E", "")) not in episodes: + episodes.append(int(transferhis.episodes.replace("E", ""))) + else: + episodes.append(int(transferhis.episodes.replace("E", ""))) + media_list = { + "key": key, + "mtype": transferhis.type, + "category": transferhis.category, + "image": transferhis.image, + "season": transferhis.seasons, + "episodes": episodes, + "tmdbid": transferhis.tmdbid, + "time": datetime.datetime.now() + } + else: + media_list = { + "key": key, + "mtype": transferhis.type, + "category": transferhis.category, + "image": transferhis.image, + "season": transferhis.seasons, + "episodes": [ + int(transferhis.episodes.replace("E", ""))] if transferhis.type == MediaType.TV.value else [], + "tmdbid": transferhis.tmdbid, + "time": datetime.datetime.now() + } + self._medias[key + " " + transferhis.seasons] = media_list + + 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: + del self._medias[medis_title_year_season] + continue + + # 获取最后更新时间 + last_update_time = media_list.get("time") + key = media_list.get("key") + mtype = media_list.get("mtype") + category = media_list.get("category") + image = media_list.get("image") + season = media_list.get("season") + episodes = media_list.get("episodes") + tmdbid = media_list.get("tmdbid") + if not last_update_time: + del self._medias[medis_title_year_season] + continue + + # 判断剧集最后更新时间距现在是已超过10秒或者电影,发送消息 + if (datetime.datetime.now() - last_update_time).total_seconds() > int(self._interval) \ + or mtype == MediaType.MOVIE.name: + # 发送通知 + if self._notify: + # 剧集季集信息 S01 E01-E04 || S01 E01、E02、E04 + season_episode = None + # 处理文件多,说明是剧集,显示季入库消息 + if season: + # 季集文本 + season_episode = f"{season} {StringUtils.format_ep(episodes)}" + + if tmdbid: + # 获取图片 + try: + image = self.chain.obtain_specific_image( + mediaid=tmdbid, + mtype=MediaType(mtype), + image_type=MediaImageType.Backdrop, + season=int(season.replace("S", "")) if season else None, + episode=int(episodes[0]) if episodes else None, + ) or image + except Exception: + image = image + + # 发送消息 + self.__send_transfer_message(title_year=key, + season_episodes=season_episode, + mtype=mtype, + category=category, + image=image, + count=len(episodes) if episodes else 1) + logger.info(f"发送媒体 {medis_title_year_season} 转移消息成功") + # 发送完消息,移出key + del self._medias[medis_title_year_season] + continue + + def __send_transfer_message(self, title_year, season_episodes, mtype, category, image, count): + """ + 发送入库成功的消息 + """ + msg_title = f"{title_year} {season_episodes if season_episodes else ''} 已转移完成" + msg_str = f"类型:{mtype},类别:{category},共{count}个文件" + # 发送 + self.post_message( + mtype=NotificationType.Plugin, + title=msg_title, + text=msg_str, + image=image, + link=settings.MP_DOMAIN('#/history') + ) + + def __transfer_file(self, file_path, target_file, transfer_type): + """ + 转移文件 + """ + logger.info(f"开始 {transfer_type} 文件 {str(file_path)} 到 {target_file}") + # 如果是文件夹 + if Path(target_file).is_dir(): + if not Path(target_file).exists(): + logger.info(f"创建目标文件夹 {target_file}") + os.makedirs(target_file) + return 1 + else: + if not Path(target_file).parent.exists(): + 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} {'成功' if retcode == 0 else '失败'} {retmsg}") + return retcode + + def __transfer_command(self, file_item: Path, target_file: Path, transfer_type: str): + """ + 使用系统命令处理单个文件 + :param file_item: 文件路径 + :param target_file: 目标文件路径 + :param transfer_type: RmtMode转移方式 + """ + # 转移 + 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: + 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) + + return retcode, retmsg + + @staticmethod + def __create_strm_file(mount_file: str, mount_path: str, target_file: str, library_dir: str = None, + cloud_type: str = None, cloud_path: str = None, cloud_url: str = None, + cloud_scheme: str = None): + """ + 生成strm文件 + :param library_dir: + :param mount_path: + :param mount_file: + """ + try: + # 获取视频文件名和目录 + video_name = Path(target_file).name + # 获取视频目录 + dest_path = Path(target_file).parent + + if not dest_path.exists(): + logger.info(f"创建目标文件夹 {dest_path}") + os.makedirs(str(dest_path)) + + # 构造.strm文件路径 + strm_path = os.path.join(dest_path, f"{os.path.splitext(video_name)[0]}.strm") + # strm已存在跳过处理 + if Path(strm_path).exists(): + logger.info(f"strm文件已存在 {strm_path}") + return + + logger.info(f"替换前挂载云盘路径:::{mount_file}") + + # 云盘模式 + if cloud_type: + # 替换路径中的\为/ + dest_file = mount_file.replace("\\", "/") + dest_file = dest_file.replace(cloud_path, "") + # 对盘符之后的所有内容进行url转码 + dest_file = urllib.parse.quote(dest_file, safe='') + if str(cloud_type) == "cd2": + # 将路径的开头盘符"/mnt/user/downloads"替换为"http://localhost:19798/static/http/localhost:19798/False/" + dest_file = f"{cloud_scheme}://{cloud_url}/static/{cloud_scheme}/{cloud_url}/False/{dest_file}" + logger.info(f"替换后cd2路径:::{dest_file}") + elif str(cloud_type) == "alist": + dest_file = f"{cloud_scheme}://{cloud_url}/d/{dest_file}" + logger.info(f"替换后alist路径:::{dest_file}") + else: + logger.error(f"云盘类型 {cloud_type} 错误") + return + else: + # 本地挂载路径转为emby路径 + dest_file = mount_file.replace(mount_path, library_dir) + logger.info(f"替换后emby容器内路径:::{dest_file}") + + # 写入.strm文件 + with open(strm_path, 'w') as f: + f.write(dest_file) + + logger.info(f"创建strm文件 {strm_path}") + return 0 + except Exception as e: + logger.error(f"创建strm文件失败") + print(str(e)) + return 1 + + @staticmethod + def is_broken_symlink(path): + current_target = os.readlink(path) + if not os.path.exists(current_target): + return True + return False + + @staticmethod + def __get_softlink_list(path, parrent): + """ + 获取软链接列表 + """ + if not os.path.exists(path): + return [] + softlink_list = [] + for file_path in Path(path).rglob('**/*'): + if file_path.is_symlink() and file_path.suffix in parrent: + softlink_list.append(file_path) + return softlink_list + + def handle_invalid_links(self): + """ + 立即运行一次,清理无效软连接 + """ + # 遍历所有监控目录 + for mon_path in self._dirconf.keys(): + monitor_dir = self._dirconf.get(mon_path) + return_path = monitor_dir.get("return_path") + logger.info(f"{return_path} 开始检查无效软连接") + + # 遍历目录及子目录 + softlink_list = self.__get_softlink_list(path=return_path, + parrent=[ext.strip() for ext in self._rmt_mediaext.split(",")]) + if not softlink_list: + logger.info(f"{return_path} 没有软连接") + continue + logger.info(f"{return_path} 软连接数量:{len(softlink_list)}") + + for file_path in softlink_list: + if self.is_broken_symlink(file_path): + logger.warn(f"删除无效软连接: {str(file_path)}") + file_path.unlink() + + # 递归删除空目录,最多到三级深度 + for depth, file_dir in enumerate(file_path.parents): + if depth >= 3 and str(file_dir) == str(return_path): + break + if not any(file_dir.iterdir()): # 检查目录是否为空 + logger.warning(f"删除空目录:{file_dir}") + try: + shutil.rmtree(file_dir, ignore_errors=True) + except OSError as e: + logger.error(f"删除目录失败: {e}") + break + else: + logger.info(f"{str(file_path)} 链接正常,跳过处理") + + logger.info(f"{return_path} 处理无效软连接完成!") + + logger.info("云盘助手清理无效软连接完成!") + + def __refresh_emby(self, transferinfo): + """ + 刷新emby + """ + emby_servers = self.mediaserver_helper.get_services(name_filters=self._mediaservers, type_filter="emby") + if not emby_servers: + logger.error("未配置Emby媒体服务器") + return + + for emby_name, emby_server in emby_servers.items(): + logger.info(f"开始通知媒体服务器 {emby_name} 刷新媒体库") + self._EMBY_APIKEY = emby_server.config.config.get("apikey") + self._EMBY_HOST = emby_server.config.config.get("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 transferinfo.type == "电影": + movies = Emby().get_movies(title=transferinfo.title, year=transferinfo.year) + if not movies: + logger.error(f"{emby_name} 中没有找到{transferinfo.title} ({transferinfo.year})") + break + for movie in movies: + self.__refresh_emby_library_by_id(item_id=movie.item_id) + logger.info(f"{emby_name} 已通知刷新电影:{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_name} 中没有找到{transferinfo.title} ({transferinfo.year})") + break + + # 验证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_name} 中{transferinfo.title} ({transferinfo.year})的tmdbId与入库记录不一致") + break + + # 查询集的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_name} 中没有找到{transferinfo.title} ({transferinfo.year}) {transferinfo.seasons}{transferinfo.episodes}") + break + + self.__refresh_emby_library_by_id(item_id=episode_item_id) + logger.info( + f"{emby_name} 已通知刷新电视剧:{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 + + @staticmethod + def get_command() -> List[Dict[str, Any]]: + """ + 定义远程控制命令 + :return: 命令关键字、事件、描述、附带数据 + """ + return [{ + "cmd": "/cloud_assistant", + "event": EventType.PluginAction, + "desc": "云盘助手同步", + "category": "", + "data": { + "action": "cloud_assistant" + } + }] + + def get_api(self) -> List[Dict[str, Any]]: + return [{ + "path": "/cloud_assistant", + "endpoint": self.sync, + "methods": ["GET"], + "summary": "云盘助手同步", + "description": "云盘助手同步", + }] + + 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": "CloudAssistantSyncAll", + "name": "云盘助手全量同步服务", + "trigger": CronTrigger.from_crontab(self._cron), + "func": self.sync_all, + "kwargs": {} + }] + return [] + + def sync(self, apikey: str) -> schemas.Response: + """ + API调用目录同步 + """ + if apikey != settings.API_TOKEN: + return schemas.Response(success=False, message="API密钥错误") + self.sync_all() + return schemas.Response(success=True) + + 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': 'monitor', + 'label': '实时监控', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'only_media_history', + 'label': '插件历史仅媒体文件', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'invalid', + 'label': '立即清理无效软连接', + } + } + ] + }, + ] + }, + { + 'component': 'VRow', + 'content': [ + { + "component": "VCol", + "props": { + "cols": 12, + "md": 4 + }, + "content": [ + { + "component": "VSwitch", + "props": { + "model": "dialog_closed", + "label": "插件配置" + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'refresh', + 'label': '刷新媒体库(emby)', + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VSwitch', + 'props': { + 'model': 'clean', + 'label': '立即清空插件历史', + } + } + ] + }, + ] + }, + { + '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': 'invalid_cron', + 'label': '定时清理无效软连接周期', + 'placeholder': '5位cron表达式,留空关闭' + } + } + ] + }, + { + 'component': 'VCol', + 'props': { + 'cols': 12, + 'md': 4 + }, + 'content': [ + { + 'component': 'VTextField', + 'props': { + 'model': 'interval', + 'label': '入库消息延迟', + 'placeholder': '10' + } + } + ] + } + ] + }, + { + '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': 'VTextarea', + 'props': { + 'model': 'rmt_mediaext', + 'label': '视频格式', + 'rows': 2, + 'placeholder': ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12 + }, + 'content': [ + { + 'component': 'VSelect', + 'props': { + 'multiple': True, + 'chips': True, + 'clearable': True, + 'model': 'mediaservers', + 'label': '媒体服务器', + 'items': [{"title": config.name, "value": config.name} + for config in self.mediaserver_helper.get_configs().values() if + config.type == "emby"] + } + } + ] + } + ] + }, + { + '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': 'error', + 'variant': 'tonal', + 'text': '插件没想公开,传的快,死的快。' + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'props': { + 'style': { + 'margin-top': '12px' + }, + }, + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'success', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '配置教程请参考:' + }, + { + 'component': 'a', + 'props': { + 'href': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudAssistant.md', + 'target': '_blank' + }, + 'text': 'https://raw.githubusercontent.com/thsrite/MoviePilot-Plugins/main/docs/CloudAssistant.md' + } + ] + } + ] + } + ] + }, + { + "component": "VDialog", + "props": { + "model": "dialog_closed", + "max-width": "65rem", + "overlay-class": "v-dialog--scrollable v-overlay--scroll-blocked", + "content-class": "v-card v-card--density-default v-card--variant-elevated rounded-t" + }, + "content": [ + { + "component": "VCard", + "props": { + "title": "插件配置" + }, + "content": [ + { + "component": "VDialogCloseBtn", + "props": { + "model": "dialog_closed" + } + }, + { + "component": "VCardText", + "props": {}, + "content": [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAceEditor', + 'props': { + 'modelvalue': 'dir_confs', + 'lang': 'json', + 'theme': 'monokai', + 'style': 'height: 30rem', + } + } + ] + } + ] + }, + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VAlert', + 'props': { + 'type': 'info', + 'variant': 'tonal' + }, + 'content': [ + { + 'component': 'span', + 'text': '注意:只有正确配置时,该助手才能正常工作。' + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ] + } + ], { + "enabled": False, + "notify": False, + "monitor": False, + "onlyonce": False, + "invalid": False, + "refresh": False, + "only_media_history": False, + "clean": False, + "exclude_keywords": "", + "interval": 60, + "cron": "", + "invalid_cron": "", + "dir_confs": json.dumps(CloudAssistant.example, indent=4, ensure_ascii=False), + "mediaservers": [], + "rmt_mediaext": ".mp4, .mkv, .ts, .iso,.rmvb, .avi, .mov, .mpeg,.mpg, .wmv, .3gp, .asf, .m4v, .flv, .m2ts, .strm,.tp, .f4v" + } + + def get_page(self) -> List[dict]: + # 查询同步详情 + historys = self.get_data('history') + if not historys: + return [ + { + 'component': 'div', + 'text': '暂无数据', + 'props': { + 'class': 'text-center', + } + } + ] + + if not isinstance(historys, list): + historys = [historys] + + # 按照时间倒序 + historys = sorted(historys, key=lambda x: x.get("time") or 0, reverse=True) + + msgs = [ + { + 'component': 'tr', + 'props': { + 'class': 'text-sm' + }, + 'content': [ + { + 'component': 'td', + 'props': { + 'class': 'whitespace-nowrap break-keep text-high-emphasis' + }, + 'text': history.get("time") + }, + { + 'component': 'td', + 'text': history.get("file_path") + }, + { + 'component': 'td', + 'text': history.get("target_cloud_file") + }, + { + 'component': 'td', + 'text': history.get("target_soft_file") + }, + { + 'component': 'td', + 'text': history.get("delete_dest") + }, + { + 'component': 'td', + 'text': history.get("delete_history") + } + ] + } for history in historys + ] + + # 拼装页面 + return [ + { + 'component': 'VRow', + 'content': [ + { + 'component': 'VCol', + 'props': { + 'cols': 12, + }, + 'content': [ + { + 'component': 'VTable', + 'props': { + 'hover': True + }, + 'content': [ + { + 'component': 'thead', + 'content': [ + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': 'time' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '本地文件' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '云盘挂载文件' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '软连接文件' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '是否删除本地文件' + }, + { + 'component': 'th', + 'props': { + 'class': 'text-start ps-4' + }, + 'text': '是否删除历史记录' + }, + ] + }, + { + 'component': 'tbody', + 'content': msgs + } + ] + } + ] + } + ] + } + ] + + 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 + + @staticmethod + @db_query + def __get_transferhis_by_dest(db: Optional[Session], dest_path: str) -> TransferHistory: + """ + 根据目标路径查询转移记录 + """ + return db.query(TransferHistory).filter(TransferHistory.dest == dest_path).first() + + @staticmethod + @db_update + def __delete_transferhis_by_id(db: Optional[Session], transferhisid: int): + """ + 根据转移记录ID删除转移记录 + """ + TransferHistory.delete(db, transferhisid) + db.commit() + + @staticmethod + @db_query + def __get_downloadhis_by_hash(db: Optional[Session], download_hash: str) -> DownloadHistory: + """ + 根据下载记录hash查询下载记录 + """ + return DownloadHistory.get_by_hash(db, download_hash) + + @staticmethod + @db_update + def __delete_downloadhis_by_id(db: Optional[Session], downloadhisid: int): + """ + 根据下载记录ID删除下载记录 + """ + DownloadHistory.delete(db, downloadhisid) + db.commit() + + @staticmethod + @db_query + def __get_downloadfiles_by_hash(db: Optional[Session], download_hash: str) -> List[DownloadFiles]: + """ + 根据下载记录hash查询下载文件记录 + """ + return DownloadFiles.get_by_hash(db, download_hash, None) + + @staticmethod + @db_update + def __delete_downloadfile_by_id(db: Optional[Session], downloadfileid: int): + """ + 根据下载文件记录ID删除下载文件记录 + """ + DownloadFiles.delete(db, downloadfileid) + db.commit() + + @staticmethod + @db_update + def __delete_downloadfile_by_fullpath(db: Optional[Session], fullpath: str): + """ + 根据下载文件路径删除下载文件记录 + """ + DownloadFiles.delete_by_fullpath(db, fullpath) + db.commit() diff --git a/plugins.v2/mediasyncdel/__init__.py b/plugins.v2/mediasyncdel/__init__.py new file mode 100644 index 0000000..59d192f --- /dev/null +++ b/plugins.v2/mediasyncdel/__init__.py @@ -0,0 +1,1589 @@ +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.8" + # 插件作者 + 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}"