import argparse import os import random import re import shutil import traceback from enum import Enum from threading import Lock from time import sleep import log from app.conf import ModuleConf from app.helper import DbHelper, ProgressHelper from app.helper import ThreadHelper from app.media import Media, Category, Scraper from app.media.meta import MetaInfo from app.message import Message from app.plugins import EventManager from app.utils import EpisodeFormat, PathUtils, StringUtils, SystemUtils, ExceptionUtils from app.utils.types import MediaType, SyncType, RmtMode, EventType from config import RMT_SUBEXT, RMT_MEDIAEXT, RMT_FAVTYPE, RMT_MIN_FILESIZE, DEFAULT_MOVIE_FORMAT, \ DEFAULT_TV_FORMAT, Config lock = Lock() class FileTransfer: media = None message = None category = None mediaserver = None scraper = None threadhelper = None dbhelper = None progress = None eventmanager = None _default_rmt_mode = None _movie_path = None _tv_path = None _anime_path = None _movie_category_flag = None _tv_category_flag = None _anime_category_flag = None _unknown_path = None _min_filesize = RMT_MIN_FILESIZE _filesize_cover = False _movie_dir_rmt_format = "" _movie_file_rmt_format = "" _tv_dir_rmt_format = "" _tv_season_rmt_format = "" _tv_file_rmt_format = "" _scraper_flag = False _scraper_nfo = {} _scraper_pic = {} _refresh_mediaserver = False _ignored_paths = [] _ignored_files = '' def __init__(self): self.media = Media() self.message = Message() self.category = Category() self.scraper = Scraper() self.threadhelper = ThreadHelper() self.dbhelper = DbHelper() self.progress = ProgressHelper() self.eventmanager = EventManager() self.init_config() def init_config(self): media = Config().get_config('media') self._scraper_flag = media.get("nfo_poster") self._scraper_nfo = Config().get_config('scraper_nfo') self._scraper_pic = Config().get_config('scraper_pic') if media: # 刷新媒体库开关 self._refresh_mediaserver = media.get("refresh_mediaserver") # 电影目录 self._movie_path = media.get('movie_path') if not isinstance(self._movie_path, list): if self._movie_path: self._movie_path = [self._movie_path] else: self._movie_path = [] # 电影分类 self._movie_category_flag = self.category.get_movie_category_flag() # 电视剧目录 self._tv_path = media.get('tv_path') if not isinstance(self._tv_path, list): if self._tv_path: self._tv_path = [self._tv_path] else: self._tv_path = [] # 电视剧分类 self._tv_category_flag = self.category.get_tv_category_flag() # 动漫目录 self._anime_path = media.get('anime_path') if not isinstance(self._anime_path, list): if self._anime_path: self._anime_path = [self._anime_path] else: self._anime_path = [] # 动漫分类 self._anime_category_flag = self.category.get_anime_category_flag() # 没有动漫目漫切换为电视剧目录和分类 if not self._anime_path: self._anime_path = self._tv_path self._anime_category_flag = self._tv_category_flag # 未识别目录 self._unknown_path = media.get('unknown_path') if not isinstance(self._unknown_path, list): if self._unknown_path: self._unknown_path = [self._unknown_path] else: self._unknown_path = [] # 最小文件大小 min_filesize = media.get('min_filesize') if isinstance(min_filesize, int): self._min_filesize = min_filesize * 1024 * 1024 elif isinstance(min_filesize, str) and min_filesize.isdigit(): self._min_filesize = int(min_filesize) * 1024 * 1024 # 文件路径转移忽略词 ignored_paths = media.get('ignored_paths') if ignored_paths: if ignored_paths.endswith(";"): ignored_paths = ignored_paths[:-1] self._ignored_paths = re.compile(r'%s' % re.sub(r';', r'|', ignored_paths)) # 文件名转移忽略词 ignored_files = media.get('ignored_files') if ignored_files: if ignored_files.endswith(";"): ignored_files = ignored_files[:-1] self._ignored_files = re.compile(r'%s' % re.sub(r';', r'|', ignored_files)) # 高质量文件覆盖 self._filesize_cover = media.get('filesize_cover') # 电影重命名格式 movie_name_format = media.get('movie_name_format') or DEFAULT_MOVIE_FORMAT movie_formats = movie_name_format.rsplit('/', 1) if movie_formats: self._movie_dir_rmt_format = movie_formats[0] if len(movie_formats) > 1: self._movie_file_rmt_format = movie_formats[-1] # 电视剧重命名格式 tv_name_format = media.get('tv_name_format') or DEFAULT_TV_FORMAT tv_formats = tv_name_format.rsplit('/', 2) if tv_formats: self._tv_dir_rmt_format = tv_formats[0] if len(tv_formats) > 2: self._tv_season_rmt_format = tv_formats[-2] self._tv_file_rmt_format = tv_formats[-1] self._default_rmt_mode = ModuleConf.RMT_MODES.get(Config().get_config('pt').get('rmt_mode', 'copy'), RmtMode.COPY) @staticmethod def __transfer_command(file_item, target_file, rmt_mode): """ 使用系统命令处理单个文件 :param file_item: 文件路径 :param target_file: 目标文件路径 :param rmt_mode: RmtMode转移方式 """ with lock: if rmt_mode == RmtMode.LINK: # 更链接 retcode, retmsg = SystemUtils.link(file_item, target_file) elif rmt_mode == RmtMode.SOFTLINK: # 软链接 retcode, retmsg = SystemUtils.softlink(file_item, target_file) elif rmt_mode == RmtMode.MOVE: # 移动 retcode, retmsg = SystemUtils.move(file_item, target_file) elif rmt_mode == RmtMode.RCLONE: # Rclone移动 retcode, retmsg = SystemUtils.rclone_move(file_item, target_file) elif rmt_mode == RmtMode.RCLONECOPY: # Rclone复制 retcode, retmsg = SystemUtils.rclone_copy(file_item, target_file) elif rmt_mode == RmtMode.MINIO: # Minio移动 retcode, retmsg = SystemUtils.minio_move(file_item, target_file) elif rmt_mode == RmtMode.MINIOCOPY: # Minio复制 retcode, retmsg = SystemUtils.minio_copy(file_item, target_file) else: # 复制 retcode, retmsg = SystemUtils.copy(file_item, target_file) if retcode != 0: log.error("【Rmt】%s" % retmsg) return retcode def __transfer_subtitles(self, org_name, new_name, rmt_mode): """ 根据文件名转移对应字幕文件 :param org_name: 原文件名 :param new_name: 新文件名 :param rmt_mode: RmtMode转移方式 """ # 字幕正则式 _zhcn_sub_re = r"([.\[(](((zh[-_])?(cn|ch[si]|sg|sc))|zho?" \ r"|chinese|(cn|ch[si]|sg|zho?|eng)[-_&](cn|ch[si]|sg|zho?|eng)" \ r"|简[体中]?)[.\])])" \ r"|([\u4e00-\u9fa5]{0,3}[中双][\u4e00-\u9fa5]{0,2}[字文语][\u4e00-\u9fa5]{0,3})" \ r"|简体|简中" _zhtw_sub_re = r"([.\[(](((zh[-_])?(hk|tw|cht|tc))" \ r"|繁[体中]?)[.\])])" \ r"|繁体中[文字]|中[文字]繁体|繁体" _eng_sub_re = r"[.\[(]eng[.\])]" # 比对文件名并转移字幕 dir_name = os.path.dirname(org_name) file_name = os.path.basename(org_name) file_list = PathUtils.get_dir_level1_files(dir_name, RMT_SUBEXT) if len(file_list) == 0: log.debug("【Rmt】%s 目录下没有找到字幕文件..." % dir_name) else: log.debug("【Rmt】字幕文件清单:" + str(file_list)) metainfo = MetaInfo(title=file_name) for file_item in file_list: sub_file_name = re.sub(_zhtw_sub_re, ".", re.sub(_zhcn_sub_re, ".", os.path.basename(file_item), flags=re.I), flags=re.I) sub_file_name = re.sub(_eng_sub_re, ".", sub_file_name, flags=re.I) sub_metainfo = MetaInfo(title=os.path.basename(file_item)) if (os.path.splitext(file_name)[0] == os.path.splitext(sub_file_name)[0]) or \ (sub_metainfo.cn_name and sub_metainfo.cn_name == metainfo.cn_name) \ or (sub_metainfo.en_name and sub_metainfo.en_name == metainfo.en_name): if metainfo.get_season_string() \ and metainfo.get_season_string() != sub_metainfo.get_season_string(): continue if metainfo.get_episode_string() \ and metainfo.get_episode_string() != sub_metainfo.get_episode_string(): continue new_file_type = "" # 兼容jellyfin字幕识别(多重识别), emby则会识别最后一个后缀 if re.search(_zhcn_sub_re, file_item, re.I): new_file_type = ".chi.zh-cn" elif re.search(_zhtw_sub_re, file_item, re.I): new_file_type = ".zh-tw" elif re.search(_eng_sub_re, file_item, re.I): new_file_type = ".eng" # 通过对比字幕文件大小 尽量转移所有存在的字幕 file_ext = os.path.splitext(file_item)[-1] new_sub_tag_dict = { ".eng": ".英文", ".chi.zh-cn": ".简体中文", ".zh-tw": ".繁体中文" } new_sub_tag_list = [ new_file_type if t == 0 else "%s%s(%s)" % (new_file_type, new_sub_tag_dict.get( new_file_type, "" ), t) for t in range(6) ] for new_sub_tag in new_sub_tag_list: new_file = os.path.splitext(new_name)[0] + new_sub_tag + file_ext # 如果字幕文件不存在, 直接转移字幕, 并跳出循环 try: if not os.path.exists(new_file): log.debug("【Rmt】正在处理字幕:%s" % os.path.basename(file_item)) retcode = self.__transfer_command(file_item=file_item, target_file=new_file, rmt_mode=rmt_mode) if retcode == 0: log.info("【Rmt】字幕 %s %s完成" % (os.path.basename(file_item), rmt_mode.value)) break else: log.error( "【Rmt】字幕 %s %s失败,错误码 %s" % (file_name, rmt_mode.value, str(retcode))) return retcode # 如果字幕文件的大小与已存在文件相同, 说明已经转移过了, 则跳出循环 elif os.path.getsize(new_file) == os.path.getsize(file_item): log.info("【Rmt】字幕 %s 已存在" % new_file) break # 否则 循环继续 > 通过new_sub_tag_list 获取新的tag附加到字幕文件名, 继续检查是否能转移 except OSError as reason: log.info("【Rmt】字幕 %s 出错了,原因: %s" % (new_file, str(reason))) return 0 def __transfer_bluray_dir(self, file_path, new_path, rmt_mode): """ 转移蓝光文件夹 :param file_path: 原路径 :param new_path: 新路径 :param rmt_mode: RmtMode转移方式 """ log.info("【Rmt】正在%s目录:%s 到 %s" % (rmt_mode.value, file_path, new_path)) # 复制 retcode = self.__transfer_dir_files(src_dir=file_path, target_dir=new_path, rmt_mode=rmt_mode, bludir=True) if retcode == 0: log.info("【Rmt】文件 %s %s完成" % (file_path, rmt_mode.value)) else: log.error("【Rmt】文件%s %s失败,错误码 %s" % (file_path, rmt_mode.value, str(retcode))) return retcode def is_target_dir_path(self, path): """ 判断是否为目的路径下的路径 :param path: 路径 :return: True/False """ if not path: return False for tv_path in self._tv_path: if PathUtils.is_path_in_path(tv_path, path): return True for movie_path in self._movie_path: if PathUtils.is_path_in_path(movie_path, path): return True for anime_path in self._anime_path: if PathUtils.is_path_in_path(anime_path, path): return True for unknown_path in self._unknown_path: if PathUtils.is_path_in_path(unknown_path, path): return True return False def __transfer_dir_files(self, src_dir, target_dir, rmt_mode, bludir=False): """ 按目录结构转移所有文件 :param src_dir: 原路径 :param target_dir: 新路径 :param rmt_mode: RmtMode转移方式 :param bludir: 是否蓝光目录 """ file_list = PathUtils.get_dir_files(src_dir) retcode = 0 for file in file_list: new_file = file.replace(src_dir, target_dir) if os.path.exists(new_file): log.warn("【Rmt】%s 文件已存在" % new_file) continue new_dir = os.path.dirname(new_file) if not os.path.exists(new_dir): os.makedirs(new_dir) retcode = self.__transfer_command(file_item=file, target_file=new_file, rmt_mode=rmt_mode) if retcode != 0: break else: if not bludir: self.dbhelper.insert_transfer_blacklist(file) if retcode == 0 and bludir: self.dbhelper.insert_transfer_blacklist(src_dir) return retcode def __transfer_origin_file(self, file_item, target_dir, rmt_mode): """ 按原文件名link文件到目的目录 :param file_item: 原文件路径 :param target_dir: 目的目录 :param rmt_mode: RmtMode转移方式 """ if not file_item or not target_dir: return -1 if not os.path.exists(file_item): log.warn("【Rmt】%s 不存在" % file_item) return -1 # 计算目录目录 parent_name = os.path.basename(os.path.dirname(file_item)) target_dir = os.path.join(target_dir, parent_name) if not os.path.exists(target_dir): log.debug("【Rmt】正在创建目录:%s" % target_dir) os.makedirs(target_dir) # 目录 if os.path.isdir(file_item): log.info("【Rmt】正在%s目录:%s 到 %s" % (rmt_mode.value, file_item, target_dir)) retcode = self.__transfer_dir_files(src_dir=file_item, target_dir=target_dir, rmt_mode=rmt_mode) # 文件 else: target_file = os.path.join(target_dir, os.path.basename(file_item)) if os.path.exists(target_file): log.warn("【Rmt】%s 文件已存在" % target_file) return 0 retcode = self.__transfer_command(file_item=file_item, target_file=target_file, rmt_mode=rmt_mode) if retcode == 0: self.dbhelper.insert_transfer_blacklist(file_item) if retcode == 0: log.info("【Rmt】%s %s到unknown完成" % (file_item, rmt_mode.value)) else: log.error("【Rmt】%s %s到unknown失败,错误码 %s" % (file_item, rmt_mode.value, retcode)) return retcode def __transfer_file(self, file_item, new_file, rmt_mode, over_flag=False, old_file=None): """ 转移一个文件,同时处理字幕 :param file_item: 原文件路径 :param new_file: 新文件路径 :param rmt_mode: RmtMode转移方式 :param over_flag: 是否覆盖,为True时会先删除再转移 """ file_name = os.path.basename(file_item) if not over_flag and os.path.exists(new_file): log.warn("【Rmt】文件已存在:%s" % new_file) return 0 if over_flag and old_file and os.path.isfile(old_file): log.info("【Rmt】正在删除已存在的文件:%s" % old_file) os.remove(old_file) log.info("【Rmt】正在转移文件:%s 到 %s" % (file_name, new_file)) retcode = self.__transfer_command(file_item=file_item, target_file=new_file, rmt_mode=rmt_mode) if retcode == 0: log.info("【Rmt】文件 %s %s完成" % (file_name, rmt_mode.value)) self.dbhelper.insert_transfer_blacklist(file_item) else: log.error("【Rmt】文件 %s %s失败,错误码 %s" % (file_name, rmt_mode.value, str(retcode))) return retcode # 处理字幕 return self.__transfer_subtitles(org_name=file_item, new_name=new_file, rmt_mode=rmt_mode) def transfer_media(self, in_from: Enum, in_path, rmt_mode: RmtMode = None, files: list = None, target_dir=None, unknown_dir=None, tmdb_info=None, media_type: MediaType = None, season=None, episode: (EpisodeFormat, bool) = None, min_filesize=None, udf_flag=False, root_path=False): """ 识别并转移一个文件、多个文件或者目录 :param in_from: 来源,即调用该功能的渠道 :param in_path: 转移的路径,可能是一个文件也可以是一个目录 :param files: 文件清单,非空时以该文件清单为准,为空时从in_path中按后缀和大小限制检索需要处理的文件清单 :param target_dir: 目的文件夹,非空的转移到该文件夹,为空时则按类型转移到配置文件中的媒体库文件夹 :param unknown_dir: 未识别文件夹,非空时未识别的媒体文件转移到该文件夹,为空时则使用配置文件中的未识别文件夹 :param rmt_mode: 文件转移方式 :param tmdb_info: 手动识别转移时传入的TMDB信息对象,如未输入,则按名称笔TMDB实时查询 :param media_type: 手动识别转移时传入的文件类型,如未输入,则自动识别 :param season: 手动识别目录或文件时传入的的字号,如未输入,则自动识别 :param episode: (EpisodeFormat,是否批处理匹配) :param min_filesize: 过滤小文件大小的上限值 :param udf_flag: 自定义转移标志,为True时代表是自定义转移,此时很多处理不一样 :param root_path: 是否根目录下的文件 :return: 处理状态,错误信息 """ def __finish_transfer(status, message): if status: self.progress.update(ptype="filetransfer", value=100, text=f"{in_path} 转移成功!") else: self.progress.update(ptype="filetransfer", value=100, text=f"{in_path} 转移失败:{message}!") self.progress.end('filetransfer') return status, message # 开始进度 self.progress.start('filetransfer') episode = (None, False) if not episode else episode if not in_path: log.error("【Rmt】输入路径错误!") return __finish_transfer(False, "输入路径错误") if not rmt_mode: rmt_mode = self._default_rmt_mode log.info("【Rmt】开始处理:%s,转移方式:%s" % (in_path, rmt_mode.value)) success_flag = True error_message = "" bluray_disk_dir = None if not files: # 如果传入的是个目录 if os.path.isdir(in_path): if not os.path.exists(in_path): log.error("【Rmt】文件转移失败,目录不存在 %s" % in_path) return __finish_transfer(False, "目录不存在") # 回收站及隐藏的文件不处理 if PathUtils.is_invalid_path(in_path): return __finish_transfer(False, "回收站或者隐藏文件夹") # 判断是不是原盘文件夹 bluray_disk_dir = PathUtils.get_bluray_dir(in_path) if bluray_disk_dir: file_list = [bluray_disk_dir] log.info("【Rmt】当前为蓝光原盘文件夹:%s" % str(in_path)) else: if str(min_filesize) == "0": # 不限制大小 now_filesize = 0 else: # 未输入大小限制默认为配置大小限制 now_filesize = self._min_filesize if not str(min_filesize).isdigit() else int( min_filesize) * 1024 * 1024 # 查找目录下的文件 file_list = PathUtils.get_dir_files(in_path=in_path, episode_format=episode[0], exts=RMT_MEDIAEXT, filesize=now_filesize) log.debug("【Rmt】文件清单:" + str(file_list)) if len(file_list) == 0: log.warn("【Rmt】%s 目录下未找到媒体文件,当前最小文件大小限制为 %s" % (in_path, StringUtils.str_filesize(now_filesize))) return __finish_transfer(False, "目录下未找到媒体文件,当前最小文件大小限制为 %s" % StringUtils.str_filesize(now_filesize)) # 传入的是个文件 else: if not os.path.exists(in_path): log.error("【Rmt】文件转移失败,文件不存在:%s" % in_path) return __finish_transfer(False, "文件不存在") if os.path.splitext(in_path)[-1].lower() not in RMT_MEDIAEXT: log.warn("【Rmt】不支持的媒体文件格式,不处理:%s" % in_path) return __finish_transfer(False, "不支持的媒体文件格式") # 判断是不是原盘文件夹 bluray_disk_dir = PathUtils.get_bluray_dir(in_path) if bluray_disk_dir: file_list = [bluray_disk_dir] log.info("【Rmt】当前为蓝光原盘文件夹:%s" % bluray_disk_dir) else: file_list = [in_path] else: # 传入的是个文件列表,这些文失件是in_path下面的文件 file_list = files # 过滤掉文件列表 file_list, msg = self.check_ignore(file_list=file_list) if not file_list: return __finish_transfer(True, msg) # 目录同步模式下,过滤掉文件列表中已处理过的 if in_from == SyncType.MON: file_list = list(filter(self.dbhelper.is_transfer_notin_blacklist, file_list)) if not file_list: log.info("【Rmt】所有文件均已成功转移过,没有需要处理的文件!如需重新处理,请清理缓存(服务->清理转移缓存)") return __finish_transfer(True, "没有新文件需要处理") # API检索出媒体信息,传入一个文件列表,得出每一个文件的名称,这里是当前目录下所有的文件了 Medias = self.media.get_media_info_on_files(file_list, tmdb_info, media_type, season, episode[0]) if not Medias: log.error("【Rmt】检索媒体信息出错!") return __finish_transfer(False, "检索媒体信息出错") # 更新进度 self.progress.update(ptype="filetransfer", text=f"共 {len(Medias)} 个文件需要处理...") # 统计总的文件数、失败文件数、需要提醒的失败数 failed_count = 0 alert_count = 0 alert_messages = [] total_count = 0 # 电视剧可能有多集,如果在循环里发消息就太多了,要在外面发消息 message_medias = {} # 需要刷新媒体库的清单 refresh_library_items = [] # 处理识别后的每一个文件或单个文件夹 for file_item, media in Medias.items(): try: # 总数量 total_count = total_count + 1 if not udf_flag: if re.search(r'[./\s\[]+Sample[/.\s\]]+', file_item, re.IGNORECASE): log.warn("【Rmt】%s 可能是预告片,跳过..." % file_item) continue # 文件名 file_name = os.path.basename(file_item) # 更新进度 self.progress.update(ptype="filetransfer", value=round(total_count/len(Medias) * 100) - (0.5/len(Medias) * 100), text="正在处理:%s ..." % file_name) # 数据库记录的路径 if bluray_disk_dir: reg_path = bluray_disk_dir else: reg_path = file_item # 未识别 if not media or not media.tmdb_info or not media.get_title_string(): log.warn("【Rmt】%s 无法识别媒体信息!" % file_name) success_flag = False error_message = "无法识别媒体信息" self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) # 记录未识别 is_need_insert_unknown = self.dbhelper.is_need_insert_transfer_unknown(reg_path) if is_need_insert_unknown: self.dbhelper.insert_transfer_unknown(reg_path, target_dir, rmt_mode) alert_count += 1 failed_count += 1 if error_message not in alert_messages and is_need_insert_unknown: alert_messages.append(error_message) # 原样转移过去 if unknown_dir: log.warn("【Rmt】%s 按原文件名转移到未识别目录:%s" % (file_name, unknown_dir)) self.__transfer_origin_file(file_item=file_item, target_dir=unknown_dir, rmt_mode=rmt_mode) elif self._unknown_path: unknown_path = self.__get_best_unknown_path(in_path) if not unknown_path: continue log.warn("【Rmt】%s 按原文件名转移到未识别目录:%s" % (file_name, unknown_path)) self.__transfer_origin_file(file_item=file_item, target_dir=unknown_path, rmt_mode=rmt_mode) else: log.error("【Rmt】%s 无法识别媒体信息!" % file_name) continue # 当前文件大小 media.size = os.path.getsize(file_item) # 目的目录,有输入target_dir时,往这个目录放 if target_dir: dist_path = target_dir else: dist_path = self.__get_best_target_path(mtype=media.type, in_path=in_path, size=media.size) if not dist_path: log.error("【Rmt】文件转移失败,目的路径不存在!") success_flag = False error_message = "目的路径不存在" failed_count += 1 alert_count += 1 if error_message not in alert_messages: alert_messages.append(error_message) continue if dist_path and not os.path.exists(dist_path): return __finish_transfer(False, "目录不存在:%s" % dist_path) # 判断文件是否已存在,返回:目录存在标志、目录名、文件存在标志、文件名 dir_exist_flag, ret_dir_path, file_exist_flag, ret_file_path = self.__is_media_exists(dist_path, media) # 新文件后缀 file_ext = os.path.splitext(file_item)[-1] new_file = ret_file_path # 已存在的文件数量 exist_filenum = 0 handler_flag = False # 路径存在 if dir_exist_flag: # 蓝光原盘 if bluray_disk_dir: log.warn("【Rmt】蓝光原盘目录已存在:%s" % ret_dir_path) if udf_flag: return __finish_transfer(False, "蓝光原盘目录已存在:%s" % ret_dir_path) failed_count += 1 continue # 文件存在 if file_exist_flag: exist_filenum = exist_filenum + 1 if rmt_mode != RmtMode.SOFTLINK: if media.size > os.path.getsize(ret_file_path) and self._filesize_cover or udf_flag: ret_file_path, ret_file_ext = os.path.splitext(ret_file_path) new_file = "%s%s" % (ret_file_path, file_ext) old_file = "%s%s" % (ret_file_path, ret_file_ext) log.info("【Rmt】文件 %s 已存在,覆盖为 %s" % (old_file, new_file)) ret = self.__transfer_file(file_item=file_item, new_file=new_file, rmt_mode=rmt_mode, over_flag=True, old_file=old_file) if ret != 0: success_flag = False error_message = "文件转移失败,错误码 %s" % ret self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) failed_count += 1 alert_count += 1 if error_message not in alert_messages: alert_messages.append(error_message) continue handler_flag = True else: log.warn("【Rmt】文件 %s 已存在" % ret_file_path) failed_count += 1 continue else: log.warn("【Rmt】文件 %s 已存在" % ret_file_path) failed_count += 1 continue # 路径不存在 else: if not ret_dir_path: log.error("【Rmt】拼装目录路径错误,无法从文件名中识别出季集信息:%s" % file_item) success_flag = False error_message = "识别失败,无法从文件名中识别出季集信息" self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) # 记录未识别 is_need_insert_unknown = self.dbhelper.is_need_insert_transfer_unknown(reg_path) if is_need_insert_unknown: self.dbhelper.insert_transfer_unknown(reg_path, target_dir, rmt_mode) alert_count += 1 failed_count += 1 if error_message not in alert_messages and is_need_insert_unknown: alert_messages.append(error_message) continue else: # 创建电录 log.debug("【Rmt】正在创建目录:%s" % ret_dir_path) os.makedirs(ret_dir_path) # 转移蓝光原盘 if bluray_disk_dir: ret = self.__transfer_bluray_dir(file_item, ret_dir_path, rmt_mode) if ret != 0: success_flag = False error_message = "蓝光目录转移失败,错误码:%s" % ret self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) failed_count += 1 alert_count += 1 if error_message not in alert_messages: alert_messages.append(error_message) continue else: # 开始转移文件 if not handler_flag: if not ret_file_path: log.error("【Rmt】拼装文件路径错误,无法从文件名中识别出集数:%s" % file_item) success_flag = False error_message = "识别失败,无法从文件名中识别出集数" self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) # 记录未识别 is_need_insert_unknown = self.dbhelper.is_need_insert_transfer_unknown(reg_path) if is_need_insert_unknown: self.dbhelper.insert_transfer_unknown(reg_path, target_dir, rmt_mode) alert_count += 1 failed_count += 1 if error_message not in alert_messages and is_need_insert_unknown: alert_messages.append(error_message) continue new_file = "%s%s" % (ret_file_path, file_ext) ret = self.__transfer_file(file_item=file_item, new_file=new_file, rmt_mode=rmt_mode, over_flag=False) if ret != 0: success_flag = False error_message = "文件转移失败,错误码 %s" % ret self.progress.update(ptype="filetransfer", text=error_message) if udf_flag: return __finish_transfer(success_flag, error_message) failed_count += 1 alert_count += 1 if error_message not in alert_messages: alert_messages.append(error_message) continue # 媒体库刷新条目:类型-类别-标题-年份 refresh_item = {"type": media.type, "category": media.category, "title": media.title, "year": media.year, "target_path": dist_path} # 登记媒体库刷新 if refresh_item not in refresh_library_items: refresh_library_items.append(refresh_item) # 查询TMDB详情,需要全部数据 media.set_tmdb_info(self.media.get_tmdb_info(mtype=media.type, tmdbid=media.tmdb_id, append_to_response="all")) # 下载字幕条目 subtitle_item = media.to_dict() subtitle_item.update({ "file": ret_file_path, "file_ext": os.path.splitext(file_item)[-1], "bluray": True if bluray_disk_dir else False }) # 登记字幕下载事件 self.eventmanager.send_event(EventType.SubtitleDownload, subtitle_item) # 转移历史记录 self.dbhelper.insert_transfer_history( in_from=in_from, rmt_mode=rmt_mode, in_path=reg_path, out_path=new_file if not bluray_disk_dir else ret_dir_path, dest=dist_path, media_info=media) # 未识别手动识别或历史记录重新识别的批处理模式 if isinstance(episode[1], bool) and episode[1]: # 未识别手动识别,更改未识别记录为已处理 self.dbhelper.update_transfer_unknown_state(file_item) # 电影立即发送消息 if media.type == MediaType.MOVIE: self.message.send_transfer_movie_message(in_from, media, exist_filenum, self._movie_category_flag) # 否则登记汇总发消息 else: # 按季汇总 message_key = "%s-%s" % (media.get_title_string(), media.get_season_string()) if not message_medias.get(message_key): message_medias[message_key] = media # 汇总集数、大小 if not message_medias[message_key].is_in_episode(media.get_episode_list()): message_medias[message_key].total_episodes += media.total_episodes message_medias[message_key].size += media.size # 生成nfo及poster if self._scraper_flag: # 生成刮削文件 self.scraper.gen_scraper_files(media=media, scraper_nfo=self._scraper_nfo, scraper_pic=self._scraper_pic, dir_path=ret_dir_path, file_name=os.path.basename(ret_file_path), file_ext=file_ext) # 更新进度 self.progress.update(ptype="filetransfer", value=round(total_count / len(Medias) * 100), text="%s 转移完成" % file_name) # 移动模式随机休眠(兼容一些网盘挂载目录) if rmt_mode == RmtMode.MOVE: sleep(round(random.uniform(0, 1), 1)) except Exception as err: ExceptionUtils.exception_traceback(err) log.error("【Rmt】文件转移时发生错误:%s - %s" % (str(err), traceback.format_exc())) # 循环结束 # 统计完成情况,发送通知 if message_medias: self.message.send_transfer_tv_message(message_medias, in_from) # 刷新媒体库 if refresh_library_items and self._refresh_mediaserver: self.mediaserver.refresh_library_by_items(refresh_library_items) # 总结 log.info("【Rmt】%s 处理完成,总数:%s,失败:%s" % (in_path, total_count, failed_count)) if alert_count > 0: self.message.send_transfer_fail_message(in_path, alert_count, "、".join(alert_messages)) elif failed_count == 0: # 删除空目录 if rmt_mode == RmtMode.MOVE \ and os.path.exists(in_path) \ and os.path.isdir(in_path) \ and not root_path \ and not PathUtils.get_dir_files(in_path=in_path, exts=RMT_MEDIAEXT) \ and not PathUtils.get_dir_files(in_path=in_path, exts=['.!qb', '.part']): log.info("【Rmt】目录下已无媒体文件及正在下载的文件,移动模式下删除目录:%s" % in_path) shutil.rmtree(in_path) return __finish_transfer(success_flag, error_message) def transfer_manually(self, s_path, t_path, mode): """ 全量转移,用于使用命令调用 :param s_path: 源目录 :param t_path: 目的目录 :param mode: 转移方式 """ if not s_path: return if not os.path.exists(s_path): print("【Rmt】源目录不存在:%s" % s_path) return if t_path: if not os.path.exists(t_path): print("【Rmt】目的目录不存在:%s" % t_path) return rmt_mode = ModuleConf.RMT_MODES.get(mode) if not rmt_mode: print("【Rmt】转移模式错误!") return print("【Rmt】转移模式为:%s" % rmt_mode.value) print("【Rmt】正在转移以下目录中的全量文件:%s" % s_path) for path in PathUtils.get_dir_level1_medias(s_path, RMT_MEDIAEXT): if PathUtils.is_invalid_path(path): continue ret, ret_msg = self.transfer_media(in_from=SyncType.MAN, in_path=path, target_dir=t_path, rmt_mode=rmt_mode) if not ret: print("【Rmt】%s 处理失败:%s" % (path, ret_msg)) def __is_media_exists(self, media_dest, media): """ 判断媒体文件是否忆存在 :param media_dest: 媒体文件所在目录 :param media: 已识别的媒体信息 :return: 目录是否存在,目录路径,文件是否存在,文件路径 """ # 返回变量 dir_exist_flag = False file_exist_flag = False ret_dir_path = None ret_file_path = None # 电影 if media.type == MediaType.MOVIE: # 目录名称 dir_name, file_name = self.get_moive_dest_path(media) # 默认目录路径 file_path = os.path.join(media_dest, dir_name) # 开启分类时目录路径 if self._movie_category_flag: file_path = os.path.join(media_dest, media.category, dir_name) for m_type in [RMT_FAVTYPE, media.category]: type_path = os.path.join(media_dest, m_type, dir_name) # 目录是否存在 if os.path.exists(type_path): file_path = type_path break # 返回路径 ret_dir_path = file_path # 路径存在标志 if os.path.exists(file_path): dir_exist_flag = True # 文件路径 file_dest = os.path.join(file_path, file_name) # 返回文件路径 ret_file_path = file_dest # 文件是否存在 for ext in RMT_MEDIAEXT: ext_dest = "%s%s" % (file_dest, ext) if os.path.exists(ext_dest): file_exist_flag = True ret_file_path = ext_dest break # 电视剧或者动漫 else: # 目录名称 dir_name, season_name, file_name = self.get_tv_dest_path(media) # 剧集目录 if (media.type == MediaType.TV and self._tv_category_flag) or ( media.type == MediaType.ANIME and self._anime_category_flag): media_path = os.path.join(media_dest, media.category, dir_name) else: media_path = os.path.join(media_dest, dir_name) # 季 if media.get_season_list(): # 季路径 season_dir = os.path.join(media_path, season_name) # 返回目录路径 ret_dir_path = season_dir # 目录是否存在 if os.path.exists(season_dir): dir_exist_flag = True # 处理集 episodes = media.get_episode_list() if episodes: # 集文件路径 file_path = os.path.join(season_dir, file_name) # 返回文件路径 ret_file_path = file_path # 文件存在标志 for ext in RMT_MEDIAEXT: ext_dest = "%s%s" % (file_path, ext) if os.path.exists(ext_dest): file_exist_flag = True ret_file_path = ext_dest break return dir_exist_flag, ret_dir_path, file_exist_flag, ret_file_path def transfer_embyfav(self, item_path): """ Emby/Jellyfin点红星后转移电影文件到精选分类 :param item_path: 文件路径 """ if not item_path: return False if not self._movie_category_flag or not self._movie_path: return False if os.path.isdir(item_path): movie_dir = item_path else: movie_dir = os.path.dirname(item_path) # 已经是精选下的不处理 movie_type = os.path.basename(os.path.dirname(movie_dir)) if movie_type == RMT_FAVTYPE \ or movie_type not in self.category.get_movie_categorys(): return False movie_name = os.path.basename(movie_dir) movie_path = self.__get_best_target_path(mtype=MediaType.MOVIE, in_path=movie_dir) # 开始转移文件,转移到同目录下的精选目录 org_path = os.path.join(movie_path, movie_type, movie_name) new_path = os.path.join(movie_path, RMT_FAVTYPE, movie_name) if os.path.exists(org_path): log.info("【Rmt】开始转移文件 %s 到 %s ..." % (org_path, new_path)) if os.path.exists(new_path): log.info("【Rmt】目录 %s 已存在" % new_path) return False ret, retmsg = SystemUtils.move(org_path, new_path) if ret == 0: return True else: log.error("【Rmt】%s" % retmsg) else: log.error("【Rmt】%s 目录不存在" % org_path) return False def get_dest_path_by_info(self, dest, meta_info): """ 拼装转移重命名后的新文件地址 :param dest: 目的目录 :param meta_info: 媒体信息 """ if not dest or not meta_info: return None if meta_info.type == MediaType.MOVIE: dir_name, _ = self.get_moive_dest_path(meta_info) if self._movie_category_flag: return os.path.join(dest, meta_info.category, dir_name) else: return os.path.join(dest, dir_name) else: dir_name, season_name, _ = self.get_tv_dest_path(meta_info) if self._tv_category_flag: return os.path.join(dest, meta_info.category, dir_name, season_name) else: return os.path.join(dest, dir_name, season_name) def get_no_exists_medias(self, meta_info, season=None, total_num=None): """ 根据媒体库目录结构,判断媒体是否存在 :param meta_info: 已识别的媒体信息 :param season: 季号,数字,剧集时需要 :param total_num: 该季总集数,剧集时需要 :return: 如果是电影返回已存在的电影清单:title、year,如果是剧集,则返回不存在的集的清单 """ # 电影 if meta_info.type == MediaType.MOVIE: dir_name, _ = self.get_moive_dest_path(meta_info) for dest_path in self._movie_path: # 判断精选 fav_path = os.path.join(dest_path, RMT_FAVTYPE, dir_name) fav_files = PathUtils.get_dir_files(fav_path, RMT_MEDIAEXT) # 其它分类 if self._movie_category_flag: dest_path = os.path.join(dest_path, meta_info.category, dir_name) else: dest_path = os.path.join(dest_path, dir_name) files = PathUtils.get_dir_files(dest_path, RMT_MEDIAEXT) if len(files) > 0 or len(fav_files) > 0: return [{'title': meta_info.title, 'year': meta_info.year}] return [] # 电视剧 else: dir_name, season_name, _ = self.get_tv_dest_path(meta_info) if not season or not total_num: return [] if meta_info.type == MediaType.ANIME: dest_paths = self._anime_path category_flag = self._anime_category_flag else: dest_paths = self._tv_path category_flag = self._tv_category_flag # 总需要的集 total_episodes = [episode for episode in range(1, total_num + 1)] # 已存在的集 exists_episodes = [] for dest_path in dest_paths: if category_flag: dest_path = os.path.join(dest_path, meta_info.category, dir_name, season_name) else: dest_path = os.path.join(dest_path, dir_name, season_name) # 目录不存在 if not os.path.exists(dest_path): continue files = PathUtils.get_dir_files(dest_path, RMT_MEDIAEXT) for file in files: file_meta_info = MetaInfo(os.path.basename(file)) if not file_meta_info.get_season_list() or not file_meta_info.get_episode_list(): continue if file_meta_info.get_name() != meta_info.title: continue if not file_meta_info.is_in_season(season): continue exists_episodes = list(set(exists_episodes).union(set(file_meta_info.get_episode_list()))) return list(set(total_episodes).difference(set(exists_episodes))) def __get_best_target_path(self, mtype, in_path=None, size=0): """ 查询一个最好的目录返回,有in_path时找与in_path同路径的,没有in_path时,顺序查找1个符合大小要求的,没有in_path和size时,返回第1个 :param mtype: 媒体类型:电影、电视剧、动漫 :param in_path: 源目录 :param size: 文件大小 """ if not mtype: return None if mtype == MediaType.MOVIE: dest_paths = self._movie_path elif mtype == MediaType.TV: dest_paths = self._tv_path else: dest_paths = self._anime_path if not dest_paths: return None if not isinstance(dest_paths, list): return dest_paths if isinstance(dest_paths, list) and len(dest_paths) == 1: return dest_paths[0] # 有输入路径的,匹配有共同上级路径的 if in_path: # 先用自定义规则匹配 找同级目录最多的路径 max_return_path = None max_path_len = 0 for dest_path in dest_paths: try: path_len = len(os.path.commonpath([in_path, dest_path])) if path_len > max_path_len: max_path_len = path_len max_return_path = dest_path except Exception as err: ExceptionUtils.exception_traceback(err) continue if max_return_path: return max_return_path # 有输入大小的,匹配第1个满足空间存储要求的 if size: for path in dest_paths: disk_free_size = SystemUtils.get_free_space_gb(path) if float(disk_free_size) > float(size / 1024 / 1024 / 1024): return path # 默认返回第1个 return dest_paths[0] def __get_best_unknown_path(self, in_path): """ 查找最合适的unknown目录 :param in_path: 源目录 """ if not self._unknown_path: return None for unknown_path in self._unknown_path: if os.path.commonpath([in_path, unknown_path]) not in ["/", "\\"]: return unknown_path return self._unknown_path[0] def link_sync_file(self, src_path, in_file, target_dir, sync_transfer_mode): """ 对文件做纯链接处理,不做识别重命名,则监控模块调用 :param : 来源渠道 :param src_path: 源目录 :param in_file: 源文件 :param target_dir: 目的目录 :param sync_transfer_mode: 明确的转移方式 """ new_file = in_file.replace(src_path, target_dir) new_file_list, msg = self.check_ignore(file_list=[new_file]) if not new_file_list: return 0, msg else: new_file = new_file_list[0] new_dir = os.path.dirname(new_file) if not os.path.exists(new_dir): os.makedirs(new_dir) return self.__transfer_command(file_item=in_file, target_file=new_file, rmt_mode=sync_transfer_mode), "" def get_format_dict(self, media): """ 根据媒体信息,返回Format字典 """ if not media: return {} episode_title = self.media.get_episode_title(media) # 此处使用独立对象,避免影响语言 en_title = Media().get_tmdb_en_title(media) return { "title": StringUtils.clear_file_name(media.title), "en_title": StringUtils.clear_file_name(en_title), "original_name": StringUtils.clear_file_name(os.path.splitext(media.org_string or "")[0]), "original_title": StringUtils.clear_file_name(media.original_title), "name": StringUtils.clear_file_name(media.get_name()), "year": media.year, "edition": media.get_edtion_string() or None, "videoFormat": media.resource_pix, "releaseGroup": media.resource_team, "effect": media.resource_effect, "videoCodec": media.video_encode, "audioCodec": media.audio_encode, "tmdbid": media.tmdb_id, "season": media.get_season_seq(), "episode": media.get_episode_seqs(), "episode_title": StringUtils.clear_file_name(episode_title), "season_episode": "%s%s" % (media.get_season_item(), media.get_episode_items()), "part": media.part } def get_moive_dest_path(self, media_info): """ 计算电影文件路径 :return: 电影目录、电影名称 """ format_dict = self.get_format_dict(media_info) dir_name = re.sub(r"[-_\s.]*None", "", self._movie_dir_rmt_format.format(**format_dict)) file_name = re.sub(r"[-_\s.]*None", "", self._movie_file_rmt_format.format(**format_dict)) return dir_name, file_name def get_tv_dest_path(self, media_info): """ 计算电视剧文件路径 :return: 电视剧目录、季目录、集名称 """ format_dict = self.get_format_dict(media_info) dir_name = re.sub(r"[-_\s.]*None", "", self._tv_dir_rmt_format.format(**format_dict)) season_name = re.sub(r"[-_\s.]*None", "", self._tv_season_rmt_format.format(**format_dict)) file_name = re.sub(r"[-_\s.]*None", "", self._tv_file_rmt_format.format(**format_dict)) return dir_name, season_name, file_name def check_ignore(self, file_list): """ 检查过滤文件列表中忽略项目 :param file_list: 文件路径列表 """ if not file_list: return [], "" # 过滤掉文件列表中文件路径包含文件路径转移忽略词的 if self._ignored_paths: try: for file in file_list[:]: if re.findall(self._ignored_paths, os.path.dirname(file)): log.info(f"【Rmt】{file} 文件路径含转移忽略词,已忽略转移") file_list.remove(file) if not file_list: return [], "排除文件路径转移忽略词后,没有新文件需要处理" except Exception as err: ExceptionUtils.exception_traceback(err) log.error("【Rmt】文件路径转移忽略词设置有误:%s" % str(err)) # 过滤掉文件列表中文件名包含文件名转移忽略词的 if self._ignored_files: try: for file in file_list[:]: if re.findall(self._ignored_files, os.path.basename(file)): log.info(f"【Rmt】{file} 文件名包含转移忽略词,已忽略转移") file_list.remove(file) if not file_list: return [], "排除文件名转移忽略词后,没有新文件需要处理" except Exception as err: ExceptionUtils.exception_traceback(err) log.error("【Rmt】文件名转移忽略词设置有误:%s" % str(err)) return file_list, "" if __name__ == "__main__": """ 手工转移时,使用命名行调用 """ Config().init_syspath() parser = argparse.ArgumentParser(description='文件转移工具') parser.add_argument('-m', '--mode', dest='mode', required=True, help='转移模式:link copy softlink move rclone rclonecopy minio miniocopy') parser.add_argument('-s', '--source', dest='s_path', required=True, help='硬链接源目录路径') parser.add_argument('-d', '--target', dest='t_path', required=False, help='硬链接目的目录路径') args = parser.parse_args() if os.environ.get('NASTOOL_CONFIG'): print("【Rmt】配置文件地址:%s" % os.environ.get('NASTOOL_CONFIG')) print("【Rmt】源目录路径:%s" % args.s_path) if args.t_path: print("【Rmt】目的目录路径:%s" % args.t_path) else: print("【Rmt】目的目录为配置文件中的电影、电视剧媒体库目录") FileTransfer().transfer_manually(args.s_path, args.t_path, args.mode) else: print("【Rmt】未设置环境变量,请先设置 NASTOOL_CONFIG 环境变量为配置文件地址")