diff --git a/docs/CloudAssistant.md b/docs/CloudAssistant.md deleted file mode 100644 index 6e36f03..0000000 --- a/docs/CloudAssistant.md +++ /dev/null @@ -1,182 +0,0 @@ -# 云盘助手 - -### 使用说明 - -提供四种方式,具体看示例 -``` - -直接转移--softlink回本地 -{ - "transfer_type": "move", - "return_mode": "softlink", - "monitor_dirs": [ - { - "retention_time": 0, - "monitor_mode": "fast", - /* 监控模式 compatibility/ */ - "dest_path": "/series/link", - /* MP媒体库文件夹 */ - "mount_path": "/115/CloudDrive/115/video", - /* MP网盘挂载文件夹 */ - "return_path": "/115link/link", - /* 软连接生成文件夹 */ - "delete_dest": "false", - /* 是否删除种子下载文件夹 */ - "dest_preserve_hierarchy": 0, - /* 保留监控路径目录层级,例如 1:表示保留监控目录后一层目录结构,0:表示仅保留到监控目录 */ - "delete_history": "false", - /* 是否删除MoviePilot中转移历史记录 */ - "delete_src": "false", - /* 是否删除做种文件 */ - "src_paths": "/series/download", - /* 做种文件夹 */ - "src_preserve_hierarchy": 0, - /* 保留做种文件夹目录层级,0:表示仅监控到源文件目录,1:表示监控源文件目录及其一级子目录 */ - "only_media": "true", - /* 是否只监控媒体文件 */ - "overwrite": "false", - /* 是否覆盖已存在云盘文件 */ - "upload_cloud": "true" - /* 是否上传到云盘, false则直接软连接或者strm回本地 */ - } - ] -} - -直接转移--strm回本地 -{ - "transfer_type": "copy/move", - "return_mode": "strm", - "monitor_dirs": [ - { - "monitor_mode": "监控模式 compatibility/fast", - "dest_path": "/mnt/link/movies", - "mount_path": "/mnt/cloud/115/media/movies", - "return_path": "/mnt/strm/movies", - "library_dir": "/mnt/movies", - "cloud_type": "alist/cd2", - "cloud_path": "/CloudNas", - "cloud_url": "http://localhost:19798", - "cloud_scheme": "http/https", - "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" - } - ] -} - - -cd2方式上传--softlink回本地(暂时移除) -{ - "cd2_url": "cd2地址:http://localhost:19798", - "username": "用户名", - "password": "密码", - "return_mode": "softlink", - "monitor_dirs": [ - { - "monitor_mode": "监控模式 compatibility/fast", - "dest_path": "/mnt/link/movies", - "mount_path": "/mnt/cloud/115/media/movies", - "cd2_path": "/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" - } - ] -} - -cd2方式上传--strm回本地(暂时移除) -{ - "cd2_url": "cd2地址:http://localhost:19798", - "username": "用户名", - "password": "密码", - "return_mode": "strm", - "monitor_dirs": [ - { - "monitor_mode": "监控模式 compatibility/fast", - "dest_path": "/mnt/link/movies", - "mount_path": "/mnt/cloud/115/media/movies", - "cd2_path": "/115/media/movies", - "return_path": "/mnt/strm/movies", - "library_dir": "/mnt/movies", - "cloud_type": "alist/cd2", - "cloud_path": "/CloudNas", - "cloud_url": "http://localhost:19798", - "cloud_scheme": "http/https", - "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" - } - ] -} - - -``` -- return_mode: 云盘文件回本地模式:softlink/strm -- return_path:MoviePilot中软链接/strm生成路径 - -- retention_time: 本地文件保留时长(小时) 当前日期与文件创建日期的时间差(小时),大于此值的文件将被转移 -- monitor_mode:监控模式 compatibility/fast -- tranfer_type:转移类型,可选值:copy/move -- dest_path: MoviePilot本地刮削好的文件路径(MoviePilot媒体库目录) -- mount_path:MoviePilot中云盘挂载路径 - -- delete_dest:是否删除媒体库文件 -- dest_preserve_hierarchy:保留监控路径目录层级,例如 1:表示保留监控目录后一层目录结构,0:表示仅保留到监控目录 - -- delete_history:是否删除MoviePilot中转移历史记录 - -- delete_src:是否删除源文件,仅上述监控路径查询到转移记录时才生效,删除转移记录的源文件路径 -- src_paths:转移前的源文件路径,多个目录用逗号分隔(MoviePilot下载目录) -- src_preserve_hierarchy:保留源文件路径目录层级,0:表示仅监控到源文件目录,1:表示监控源文件目录及其一级子目录 -- -- only_media:是否只监控媒体文件 -- overwrite:是否覆盖已存在云盘文件 -- upload_cloud: 是否上传到云盘, false则直接软连接或者strm回本地 -- notify_url: 软连接或者strm回本地成功后,通知接口地址,post请求参数:`{"path": "文件路径", "type": "add"}` - -- strm配置具体看[CloudStrm.md](CloudStrm.md) -- library_dir:strm模式下,媒体服务器内源文件路径 -- cloud_type:strm模式下,云盘类型,可选值:alist/cd2 (`不填就是本地模式`) -- cloud_path:strm模式下,cd2/alist挂载本地跟路径 -- cloud_url:strm模式下,cd2/alist地址 -- cloud_scheme:strm模式下,cd2/alist地址 http/https(strm模式可参考云盘Strm生成插件) -- -[//]: # (- cd2_url:cd2地址) -[//]: # (- username:cd2用户名) -[//]: # (- password:cd2密码) -[//]: # (- cd2_path:cd2中云盘挂载路径) - -路径: - -- 监控目录:源文件目录即云盘挂载到MoviePilot中的路径 -- 目的路径:MoviePilot中strm生成路径 -- 媒体服务器内源文件路径:源文件目录即云盘挂载到媒体服务器的路径 - -示例: - -- MoviePilot上云盘源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` - -- MoviePilot上strm生成路径 /mnt/link/aliyun`/tvshow/爸爸去哪儿/Season 5/14.特别版.strm` - -- 媒体服务器内源文件路径 /mount/cloud/aliyun/emby`/tvshow/爸爸去哪儿/Season 5/14.特别版.mp4` - -- 监控配置为:/mount/cloud/aliyun/emby#/mnt/link/aliyun#/mount/cloud/aliyun/emby diff --git a/package.json b/package.json index c533530..7fbbaca 100644 --- a/package.json +++ b/package.json @@ -581,44 +581,6 @@ "v1.0": "自定义容器命令执行" } }, - "CloudAssistant": { - "name": "云盘助手", - "description": "本地文件定时转移到云盘,软连接/strm回本地,定时清理无效软连接。", - "labels": "云盘", - "version": "2.1.5", - "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.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": "定时移动到云盘,软连接回本地(清理无效软连接暂未开发)" - } - }, "Cd2Assistant": { "name": "CloudDrive2助手", "description": "监控上传任务,检测是否有异常,发送通知。", diff --git a/package.v2.json b/package.v2.json index f33125c..c3c32ef 100644 --- a/package.v2.json +++ b/package.v2.json @@ -89,45 +89,6 @@ "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插件下载弹幕。", diff --git a/plugins.v2/cloudassistant/__init__.py b/plugins.v2/cloudassistant/__init__.py deleted file mode 100644 index 39bedae..0000000 --- a/plugins.v2/cloudassistant/__init__.py +++ /dev/null @@ -1,1872 +0,0 @@ -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.helper.mediaserver import MediaServerHelper -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.mediaserver_helper = MediaServerHelper() - # 清空配置 - 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: - # 追加入库消息统一发送服务 - 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/cloudassistant/__init__.py b/plugins/cloudassistant/__init__.py deleted file mode 100644 index cb55ec0..0000000 --- a/plugins/cloudassistant/__init__.py +++ /dev/null @@ -1,1831 +0,0 @@ -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.1.5" - # 插件作者 - 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 - _EMBY_HOST = settings.EMBY_HOST - _EMBY_APIKEY = settings.EMBY_API_KEY - - 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" - - 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") - 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 - }) - - @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 - """ - 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 - - @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': '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), - "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()