# !/usr/bin/env python3 # -*- coding: utf-8 -*- # ConfigFile: quark_config.json """ new Env('夸克自动追更'); 0 8,18,20 * * * quark_auto_save.py """ import os import re import sys import json import time import random import requests import importlib import urllib.parse from datetime import datetime # 添加数据库导入 try: from app.sdk.db import RecordDB, CalendarDB except ImportError: # 如果直接运行脚本,路径可能不同 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: from app.sdk.db import RecordDB, CalendarDB except ImportError: # 定义一个空的RecordDB类,以防止导入失败 class RecordDB: def __init__(self, *args, **kwargs): self.enabled = False def add_record(self, *args, **kwargs): pass def close(self): pass # 定义一个空的CalendarDB类,以防止导入失败 class CalendarDB: def __init__(self, *args, **kwargs): self.enabled = False def get_task_metrics(self, *args, **kwargs): return None def notify_calendar_changed_safe(reason): """安全地触发SSE通知,避免导入错误""" try: # 尝试导入notify_calendar_changed函数 import sys import os # 添加app目录到Python路径 app_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'app') if app_dir not in sys.path: sys.path.insert(0, app_dir) from run import notify_calendar_changed notify_calendar_changed(reason) except Exception as e: print(f"触发SSE通知失败: {e}") def advanced_filter_files(file_list, filterwords): """ 高级过滤函数,支持保留词和过滤词 Args: file_list: 文件列表 filterwords: 过滤规则字符串,支持以下格式: - "加更,企划,超前,(1),mkv,nfo" # 只有过滤词 - "期|加更,企划,超前,(1),mkv,nfo" # 保留词|过滤词 - "期,2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(或关系)|过滤词 - "期|2160P|加更,企划,超前,(1),mkv,nfo" # 多个保留词(并关系)|过滤词 - "期,2160P|" # 只有保留词,无过滤词 Returns: 过滤后的文件列表 """ if not filterwords or not filterwords.strip(): return file_list # 检查是否包含分隔符 | if '|' not in filterwords: # 只有过滤词的情况 filterwords = filterwords.replace(",", ",") filterwords_list = [word.strip().lower() for word in filterwords.split(',') if word.strip()] filtered_files = [] for file in file_list: file_name = file['file_name'].lower() file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): filtered_files.append(file) return filtered_files # 包含分隔符的情况,需要解析保留词和过滤词 parts = filterwords.split('|') if len(parts) < 2: # 格式错误,返回原列表 return file_list # 最后一个|后面的是过滤词 filter_part = parts[-1].strip() # 前面的都是保留词 keep_parts = [part.strip() for part in parts[:-1] if part.strip()] # 解析过滤词 filterwords_list = [] if filter_part: filter_part = filter_part.replace(",", ",") filterwords_list = [word.strip().lower() for word in filter_part.split(',') if word.strip()] # 解析保留词:每个|分隔的部分都是一个独立的筛选条件 # 这些条件需要按顺序依次应用,形成链式筛选 keep_conditions = [] for part in keep_parts: if part.strip(): if ',' in part or ',' in part: # 包含逗号,表示或关系 part = part.replace(",", ",") or_words = [word.strip().lower() for word in part.split(',') if word.strip()] keep_conditions.append(("or", or_words)) else: # 不包含逗号,表示单个词 keep_conditions.append(("single", [part.strip().lower()])) # 第一步:应用保留词筛选(链式筛选) if keep_conditions: for condition_type, words in keep_conditions: filtered_by_keep = [] for file in file_list: file_name = file['file_name'].lower() if condition_type == "or": # 或关系:包含任意一个词即可 if any(word in file_name for word in words): filtered_by_keep.append(file) elif condition_type == "single": # 单个词:必须包含 if words[0] in file_name: filtered_by_keep.append(file) file_list = filtered_by_keep # 第二步:应用过滤词过滤 if filterwords_list: filtered_files = [] for file in file_list: file_name = file['file_name'].lower() file_ext = os.path.splitext(file_name)[1].lower().lstrip('.') # 检查过滤词是否存在于文件名中,或者过滤词等于扩展名 if not any(word in file_name for word in filterwords_list) and not any(word == file_ext for word in filterwords_list): filtered_files.append(file) return filtered_files return file_list # 全局的文件排序函数 def sort_file_by_name(file): """ 通用的文件排序函数,用于根据文件名智能排序 支持多种格式的日期、期数、集数等提取和排序 使用多级排序键,按日期、期数、上中下顺序排序 如果以上均无法提取,则使用文件更新时间和拼音排序作为最后排序依据 """ if isinstance(file, dict) and file.get("dir", False): # 跳过文件夹 return (float('inf'), float('inf'), float('inf'), 0, "") # 获取文件名,支持字符串或文件对象 if isinstance(file, dict): filename = file.get("file_name", "") # 获取更新时间作为第四级排序依据 update_time = file.get("updated_at", 0) else: filename = file update_time = 0 # 导入拼音排序工具用于第五级排序 try: from app.utils.pinyin_sort import get_filename_pinyin_sort_key pinyin_sort_key = get_filename_pinyin_sort_key(filename) except ImportError: # 如果导入失败,使用简单的小写排序作为备用 pinyin_sort_key = filename.lower() # 提取文件名,不含扩展名 file_name_without_ext = os.path.splitext(filename)[0] # 初始化排序值 date_value = float('inf') # 日期键(第一级) episode_value = float('inf') # 期数/集数键(第二级) segment_value = 0 # 上中下/其他细分键(第三级) # 1. 提取日期 - 第一级排序键 # 1.1 YYYY-MM-DD 或 YYYY.MM.DD 或 YYYY/MM/DD 或 YYYY MM DD格式(四位年份) match_date_full = re.search(r'((?:19|20)\d{2})[-./\s](\d{1,2})[-./\s](\d{1,2})', filename) if match_date_full: year = int(match_date_full.group(1)) month = int(match_date_full.group(2)) day = int(match_date_full.group(3)) date_value = year * 10000 + month * 100 + day # 1.2 YY-MM-DD 或 YY.MM.DD 或 YY/MM/DD 或 YY MM DD格式(两位年份) if date_value == float('inf'): match_yy_date = re.search(r'((?:19|20)?\d{2})[-./\s](\d{1,2})[-./\s](\d{1,2})', filename) if match_yy_date and len(match_yy_date.group(1)) == 2: year_str = match_yy_date.group(1) # 如果是两位年份,假设20xx年 year = int("20" + year_str) month = int(match_yy_date.group(2)) day = int(match_yy_date.group(3)) date_value = year * 10000 + month * 100 + day # 1.3 完整的YYYYMMDD格式(无分隔符) if date_value == float('inf'): match_date_compact = re.search(r'((?:19|20)\d{2})(\d{2})(\d{2})', filename) if match_date_compact: year = int(match_date_compact.group(1)) month = int(match_date_compact.group(2)) day = int(match_date_compact.group(3)) date_value = year * 10000 + month * 100 + day # 1.4 YYMMDD格式(两位年份,无分隔符) if date_value == float('inf'): match_yy_compact = re.search(r'(? 12: month, day = day, month date_value = year * 10000 + month * 100 + day # 1.6 MM-DD 或 MM.DD 或 MM/DD格式(无年份,不包括空格分隔) if date_value == float('inf'): match_date_short = re.search(r'(?= 1 and month <= 12 and day >= 1 and day <= 31) or (day >= 1 and day <= 12 and month >= 1 and month <= 31)): # 检查月份值,如果大于12可能是欧式日期格式(DD/MM) if month > 12: month, day = day, month # 由于没有年份,使用一个较低的基数,确保任何有年份的日期都排在前面 # 使用20000000作为基准,所以无年份日期都会排在有年份日期之后 date_value = 20000000 + month * 100 + day # 2. 提取期数/集数 - 第二级排序键 # 2.1 "第X期/集/话/部/篇" 格式(支持空格) match_chinese = re.search(r'第\s*(\d+)\s*[期集话部篇]', filename) if match_chinese: episode_value = int(match_chinese.group(1)) # 2.1.1 "第[中文数字]期/集/话/部/篇" 格式(支持空格) if episode_value == float('inf'): match_chinese_num = re.search(r'第\s*([一二三四五六七八九十百千万零两]+)\s*[期集话部篇]', filename) if match_chinese_num: chinese_num = match_chinese_num.group(1) arabic_num = chinese_to_arabic(chinese_num) if arabic_num is not None: episode_value = arabic_num # 2.2 "X集/期/话" 格式 if episode_value == float('inf'): match_chinese_simple = re.search(r'(\d+)[期集话]', filename) if match_chinese_simple: episode_value = int(match_chinese_simple.group(1)) # 2.2.1 "[中文数字]集/期/话" 格式 if episode_value == float('inf'): match_chinese_simple_num = re.search(r'([一二三四五六七八九十百千万零两]+)[期集话]', filename) if match_chinese_simple_num: chinese_num = match_chinese_simple_num.group(1) arabic_num = chinese_to_arabic(chinese_num) if arabic_num is not None: episode_value = arabic_num # 2.3 S01E01格式 if episode_value == float('inf'): match_s_e = re.search(r'[Ss](\d+)[Ee](\d+)', filename) if match_s_e: season = int(match_s_e.group(1)) episode = int(match_s_e.group(2)) # 使用季*1000+集作为期数值 episode_value = episode # 只用集数作为排序键 # 2.4 E01/EP01格式 if episode_value == float('inf'): match_e = re.search(r'[Ee][Pp]?(\d+)', filename) if match_e: # 若数字位于含字母的中括号内部,跳过该匹配 if not _in_alpha_brackets(filename, match_e.start(1), match_e.end(1)): episode_value = int(match_e.group(1)) # 2.5 1x01格式 if episode_value == float('inf'): match_x = re.search(r'(\d+)[Xx](\d+)', filename) if match_x: episode = int(match_x.group(2)) episode_value = episode # 2.6 方括号/中括号包围的数字 if episode_value == float('inf'): match_bracket = re.search(r'\[(\d+)\]|【(\d+)】', filename) if match_bracket: episode_value = int(match_bracket.group(1) if match_bracket.group(1) else match_bracket.group(2)) # 2.7 其他数字格式(如果没有明确的期数) if episode_value == float('inf'): # 优先尝试纯数字文件名 if file_name_without_ext.isdigit(): episode_value = int(file_name_without_ext) else: # 预处理:移除分辨率标识(如 720p, 1080P, 2160p 等) filename_without_resolution = filename resolution_patterns = [ r'\b\d+[pP]\b', # 匹配 720p, 1080P, 2160p 等 r'\b\d+x\d+\b', # 匹配 1920x1080 等 r'(? 9999: continue candidates.append((m.start(), value)) if candidates: candidates.sort(key=lambda x: x[0]) episode_value = candidates[0][1] # 3. 提取上中下标记或其他细分 - 第三级排序键 segment_base = 0 # 基础值:上=1, 中=2, 下=3 sequence_number = 0 # 序号值:用于处理上中下后的数字或中文数字序号 # 严格匹配上中下标记:支持多种格式 # 1. 直接相邻:上集、期上 # 2. 括号分隔:期(上)、集(中) # 3. 其他分隔符:期-上、集_中 if re.search(r'上[集期话部篇]|[集期话部篇]上|[集期话部篇]\s*[((]\s*上\s*[))]|[集期话部篇]\s*[-_·丨]\s*上', filename): segment_base = 1 elif re.search(r'中[集期话部篇]|[集期话部篇]中|[集期话部篇]\s*[((]\s*中\s*[))]|[集期话部篇]\s*[-_·丨]\s*中', filename): segment_base = 2 elif re.search(r'下[集期话部篇]|[集期话部篇]下|[集期话部篇]\s*[((]\s*下\s*[))]|[集期话部篇]\s*[-_·丨]\s*下', filename): segment_base = 3 # 统一的序号提取逻辑,支持多种分隔符和格式 # 无论是否有上中下标记,都使用相同的序号提取逻辑 # 定义序号提取的模式,使用正向匹配组合的方式 # 这样可以精准匹配,避免误判"星期六"等内容 sequence_patterns = [ # 第+中文数字+期集话部篇+序号:第一期(一)、第五十六期-二、第 一 期 三 (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s*[((]\s*([一二三四五六七八九十百千万零两]+)\s*[))]', 'chinese'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s*[((]\s*(\d+)\s*[))]', 'arabic'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s*[-_·丨]\s*([一二三四五六七八九十百千万零两]+)', 'chinese'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s*[-_·丨]\s*(\d+)', 'arabic'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s+([一二三四五六七八九十百千万零两]+)(?![一二三四五六七八九十])', 'chinese'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]\s+(\d+)(?!\d)', 'arabic'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇]([一二三四五六七八九十])(?![一二三四五六七八九十])', 'chinese'), (r'第\s*[一二三四五六七八九十百千万零两]+\s*[期集话部篇](\d+)(?!\d)', 'arabic'), # 第+阿拉伯数字+期集话部篇+序号:第1期(一)、第100期-二、第 1 期 三 (r'第\s*\d+\s*[期集话部篇]\s*[((]\s*([一二三四五六七八九十百千万零两]+)\s*[))]', 'chinese'), (r'第\s*\d+\s*[期集话部篇]\s*[((]\s*(\d+)\s*[))]', 'arabic'), (r'第\s*\d+\s*[期集话部篇]\s*[-_·丨]\s*([一二三四五六七八九十百千万零两]+)', 'chinese'), (r'第\s*\d+\s*[期集话部篇]\s*[-_·丨]\s*(\d+)', 'arabic'), (r'第\s*\d+\s*[期集话部篇]\s+([一二三四五六七八九十百千万零两]+)(?![一二三四五六七八九十])', 'chinese'), (r'第\s*\d+\s*[期集话部篇]\s+(\d+)(?!\d)', 'arabic'), (r'第\s*\d+\s*[期集话部篇]([一二三四五六七八九十])(?![一二三四五六七八九十])', 'chinese'), (r'第\s*\d+\s*[期集话部篇](\d+)(?!\d)', 'arabic'), # 上中下+集期话部篇+序号:上集(一)、中部-二、下篇 三 (r'[上中下][集期话部篇]\s*[((]\s*([一二三四五六七八九十百千万零两]+)\s*[))]', 'chinese'), (r'[上中下][集期话部篇]\s*[((]\s*(\d+)\s*[))]', 'arabic'), (r'[上中下][集期话部篇]\s*[-_·丨]\s*([一二三四五六七八九十百千万零两]+)', 'chinese'), (r'[上中下][集期话部篇]\s*[-_·丨]\s*(\d+)', 'arabic'), (r'[上中下][集期话部篇]\s+([一二三四五六七八九十百千万零两]+)(?![一二三四五六七八九十])', 'chinese'), (r'[上中下][集期话部篇]\s+(\d+)(?!\d)', 'arabic'), (r'[上中下][集期话部篇]([一二三四五六七八九十])(?![一二三四五六七八九十])', 'chinese'), (r'[上中下][集期话部篇](\d+)(?!\d)', 'arabic'), # 集期话部篇+上中下+序号:集上(一)、部中-二、篇下 三 (r'[集期话部篇][上中下]\s*[((]\s*([一二三四五六七八九十百千万零两]+)\s*[))]', 'chinese'), (r'[集期话部篇][上中下]\s*[((]\s*(\d+)\s*[))]', 'arabic'), (r'[集期话部篇][上中下]\s*[-_·丨]\s*([一二三四五六七八九十百千万零两]+)', 'chinese'), (r'[集期话部篇][上中下]\s*[-_·丨]\s*(\d+)', 'arabic'), (r'[集期话部篇][上中下]\s+([一二三四五六七八九十百千万零两]+)(?![一二三四五六七八九十])', 'chinese'), (r'[集期话部篇][上中下]\s+(\d+)(?!\d)', 'arabic'), (r'[集期话部篇][上中下]([一二三四五六七八九十])(?![一二三四五六七八九十])', 'chinese'), (r'[集期话部篇][上中下](\d+)(?!\d)', 'arabic'), ] # 尝试匹配序号 for pattern, num_type in sequence_patterns: match = re.search(pattern, filename) if match: if num_type == 'chinese': arabic_num = chinese_to_arabic(match.group(1)) if arabic_num is not None: sequence_number = arabic_num # 如果之前没有检测到上中下标记,给一个基础值 if segment_base == 0: segment_base = 1 break else: # arabic sequence_number = int(match.group(1)) # 如果之前没有检测到上中下标记,给一个基础值 if segment_base == 0: segment_base = 1 break # 组合segment_value:基础值*1000 + 序号值,确保排序正确 segment_value = segment_base * 1000 + sequence_number # 返回多级排序元组,加入更新时间作为第四级排序键,拼音排序作为第五级排序键 return (date_value, episode_value, segment_value, update_time, pinyin_sort_key) # 全局的剧集编号提取函数 def _in_alpha_brackets(text, start, end): """ 判断 [start,end) 范围内的数字是否位于"含字母的中括号对"内部。 支持英文方括号 [] 和中文方括号 【】。 要求:数字左侧最近的未闭合括号与右侧最近的对应闭合括号形成对,且括号内容包含字母。 但是允许 E/e 和 EP/ep/Ep 这样的集数格式。 """ if start < 0 or end > len(text): return False # 检查英文方括号 [] last_open_en = text.rfind('[', 0, start) if last_open_en != -1: close_before_en = text.rfind(']', 0, start) if close_before_en == -1 or close_before_en < last_open_en: close_after_en = text.find(']', end) if close_after_en != -1: content = text[last_open_en + 1:close_after_en] if _check_bracket_content(content): return True # 检查中文方括号 【】 last_open_cn = text.rfind('【', 0, start) if last_open_cn != -1: close_before_cn = text.rfind('】', 0, start) if close_before_cn == -1 or close_before_cn < last_open_cn: close_after_cn = text.find('】', end) if close_after_cn != -1: content = text[last_open_cn + 1:close_after_cn] if _check_bracket_content(content): return True return False def _check_bracket_content(content): """ 检查括号内容是否应该被排除 """ # 检查是否包含字母 has_letters = bool(re.search(r'[A-Za-z]', content)) if not has_letters: return False # 如果是 E/e 或 EP/ep/Ep 格式,则允许通过 if re.match(r'^[Ee][Pp]?\d+$', content): return False return True def extract_episode_number(filename, episode_patterns=None, config_data=None): """ 从文件名中提取剧集编号 Args: filename: 文件名 episode_patterns: 可选的自定义匹配模式列表 config_data: 可选的任务配置数据 Returns: int: 提取到的剧集号,如果无法提取则返回None """ # 首先去除文件扩展名 file_name_without_ext = os.path.splitext(filename)[0] # 特判:SxxEyy.zz 模式(例如 S01E11.11),在日期清洗前优先识别 m_spec = re.search(r'[Ss](\d+)[Ee](\d{1,2})[._\-/]\d{1,2}', file_name_without_ext) if m_spec: return int(m_spec.group(2)) # 预处理顺序调整:先移除技术规格,再移除日期,降低误判 # 预处理:先移除技术规格信息,避免误提取技术参数中的数字为集编号 filename_without_dates = file_name_without_ext tech_spec_patterns = [ # 分辨率相关(限定常见p档) r'(?:240|360|480|540|720|900|960|1080|1440|2160|4320)[pP]', # 常见分辨率 WxH(白名单) r'\b(?:640x360|640x480|720x480|720x576|854x480|960x540|1024x576|1280x720|1280x800|1280x960|1366x768|1440x900|1600x900|1920x1080|2560x1080|2560x1440|3440x1440|3840x1600|3840x2160|4096x2160|7680x4320)\b', r'(?= 3: if re.match(r'(?:19|20)\d{2}', match.group(1)): # 首个分组是年份 month = int(match.group(2)) day = int(match.group(3)) elif re.match(r'(?:19|20)\d{2}', match.group(3)): # 末尾分组是年份 month = int(match.group(1)) day = int(match.group(2)) else: # 处理两位数年份的情况(如25.03.21) try: # 假设第一个是年份,第二个是月,第三个是日 year = int(match.group(1)) month = int(match.group(2)) day = int(match.group(3)) # 如果月和日在有效范围内,则这可能是一个日期 if 1 <= month <= 12 and 1 <= day <= 31: pass # 保持month和day的值 else: # 尝试另一种解释:月.日.年 month = int(match.group(1)) day = int(match.group(2)) # 检查月和日的有效性 if not (1 <= month <= 12 and 1 <= day <= 31): # 仍然无效,重置month和day month = None day = None except ValueError: # 转换失败,保持month和day为None pass elif len(match.groups()) == 2: # 处理两个分组的模式(如MM-DD格式) try: month = int(match.group(1)) day = int(match.group(2)) # 检查月和日的有效性 if not (1 <= month <= 12 and 1 <= day <= 31): # 尝试另一种解释:日-月 month = int(match.group(2)) day = int(match.group(1)) if not (1 <= month <= 12 and 1 <= day <= 31): # 仍然无效,重置month和day month = None day = None except ValueError: # 转换失败,保持month和day为None pass # 如果能确定月日且是有效的日期,则从文件名中删除该日期 if month and day and 1 <= month <= 12 and 1 <= day <= 31: filename_without_dates = filename_without_dates.replace(date_str, " ") # 优先匹配SxxExx格式 match_s_e = re.search(r'[Ss](\d+)[Ee](\d+)', filename_without_dates) if match_s_e: # 直接返回E后面的集数 return int(match_s_e.group(2)) # 其次匹配E01格式 match_e = re.search(r'[Ee][Pp]?(\d+)', filename_without_dates) if match_e: # 若数字位于含字母的中括号内部,跳过该匹配 if not _in_alpha_brackets(filename_without_dates, match_e.start(1), match_e.end(1)): return int(match_e.group(1)) # 添加中文数字匹配模式(优先匹配) chinese_patterns = [ r'第([一二三四五六七八九十百千万零两]+)集', r'第([一二三四五六七八九十百千万零两]+)期', r'第([一二三四五六七八九十百千万零两]+)话', r'([一二三四五六七八九十百千万零两]+)集', r'([一二三四五六七八九十百千万零两]+)期', r'([一二三四五六七八九十百千万零两]+)话' ] # 优先匹配中文数字模式 for pattern_regex in chinese_patterns: try: match = re.search(pattern_regex, filename_without_dates) if match: chinese_num = match.group(1) arabic_num = chinese_to_arabic(chinese_num) if arabic_num is not None: return arabic_num except: continue # 尝试匹配更多格式(注意:避免匹配季数) default_patterns = [ r'第(\d+)集', r'第(\d+)期', r'第(\d+)话', r'(? 9999: continue candidates.append((m.start(), value)) if candidates: candidates.sort(key=lambda x: x[0]) return candidates[0][1] return None # 全局变量 VERSION = "2.9.0" CONFIG_PATH = "quark_config.json" COOKIE_PATH = "quark_cookie.txt" CONFIG_DATA = {} LOG_LIST = [] NOTIFYS = [] def is_date_format(number_str): """ 判断一个纯数字字符串是否可能是日期格式 支持的格式:YYYYMMDD, YYMMDD """ # 判断YYYYMMDD格式 (8位数字) if len(number_str) == 8 and number_str.startswith('20'): year = int(number_str[:4]) month = int(number_str[4:6]) day = int(number_str[6:8]) # 简单检查月份和日期是否有效 if 1 <= month <= 12 and 1 <= day <= 31: # 可能是日期格式 return True # 判断YYMMDD格式 (6位数字) elif len(number_str) == 6: year_str = number_str[:2] month = int(number_str[2:4]) day = int(number_str[4:6]) # 检查月份和日期是否有效 if 1 <= month <= 12 and 1 <= day <= 31: # 可能是日期格式 return True # 不再将4位纯数字按MMDD视为日期,避免误伤集号(如1124) # 其他格式不视为日期格式 return False def chinese_to_arabic(chinese): """ 将中文数字转换为阿拉伯数字 支持格式:一、二、三、四、五、六、七、八、九、十、百、千、万 以及:零、两(特殊处理为2) Args: chinese: 中文数字字符串 Returns: int: 转换后的阿拉伯数字,如果无法转换则返回None """ if not chinese: return None # 数字映射 digit_map = { '零': 0, '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '两': 2 } # 单位映射 unit_map = { '十': 10, '百': 100, '千': 1000, '万': 10000 } # 如果是单个字符,直接返回对应数字 if len(chinese) == 1: if chinese == '十': return 10 return digit_map.get(chinese) result = 0 section = 0 number = 0 # 从左向右处理 for i in range(len(chinese)): char = chinese[i] if char in digit_map: number = digit_map[char] elif char in unit_map: unit = unit_map[char] # 如果前面没有数字,默认为1,例如"十"表示1*10=10 section += (number or 1) * unit number = 0 # 如果是万级单位,累加到结果并重置section if unit == 10000: result += section section = 0 else: # 非法字符 return None # 加上最后的数字和小节 result += section + number return result # 兼容青龙 try: from treelib import Tree except: print("正在尝试自动安装依赖...") os.system("pip3 install treelib &> /dev/null") from treelib import Tree MAGIC_REGEX = { "$TV": { "pattern": r".*?([Ss]\d{1,2})?(?:[第EePpXx\.\-\_\( ]{1,2}|^)(\d{1,3})(?!\d).*?\.(mp4|mkv)", "replace": r"\1E\2.\3", }, "$BLACK_WORD": { "pattern": r"^(?!.*纯享)(?!.*加更)(?!.*超前企划)(?!.*训练室)(?!.*蒸蒸日上).*", "replace": "", }, } # 发送通知消息 def send_ql_notify(title, body): try: # 导入通知模块 import notify # 如未配置 push_config 则使用青龙环境通知设置 if CONFIG_DATA.get("push_config"): notify.push_config = CONFIG_DATA["push_config"].copy() notify.push_config["CONSOLE"] = notify.push_config.get("CONSOLE", True) notify.send(title, body) except Exception as e: if e: print("发送通知消息失败!") # 添加消息 def add_notify(text): global NOTIFYS # 防止重复添加相同的通知 if text in NOTIFYS: return text # 检查推送通知类型配置 push_notify_type = CONFIG_DATA.get("push_notify_type", "full") # 定义关键字(复用于过滤与分隔) failure_keywords = ["❌", "❗", "失败", "失效", "错误", "异常", "无效", "登录失败"] invalid_keywords = ["分享资源已失效", "分享详情获取失败", "分享为空", "文件已被分享者删除"] # 如果设置为仅推送成功信息,则过滤掉失败和错误信息 if push_notify_type == "success_only": # 检查是否包含失败或错误相关的关键词 if any(keyword in text for keyword in failure_keywords): # 只打印到控制台,不添加到通知列表 print(text) return text # 如果设置为排除失效信息,则过滤掉资源失效信息,但保留转存失败信息 elif push_notify_type == "exclude_invalid": # 检查是否包含资源失效相关的关键词(主要是分享资源失效) if any(keyword in text for keyword in invalid_keywords): # 只打印到控制台,不添加到通知列表 print(text) return text # 在每个任务块之间插入一个空行,增强可读性 # 成功块:以“✅《”开头;失败/错误块:以“❌”“❗”开头或包含失败相关关键词 is_success_block = text.startswith("✅《") is_failure_block = text.startswith("❌") or text.startswith("❗") or any(k in text for k in failure_keywords if k not in ["✅"]) if is_success_block or is_failure_block: if NOTIFYS and NOTIFYS[-1] != "": NOTIFYS.append("") # 仅在通知体中添加空行,不额外打印控制台空行 NOTIFYS.append(text) print(text) return text # 格式化文件显示,统一图标和文件名之间的空格 def format_file_display(prefix, icon, name): """ 格式化文件/文件夹的显示,确保图标和名称之间只有一个空格 Args: prefix: 树形结构的前缀(如"├── ") icon: 文件/文件夹图标 name: 文件/文件夹名称 Returns: 格式化后的显示字符串 """ # 去除图标和名称中可能存在的空格 clean_icon = icon.strip() if icon else "" clean_name = name.strip() if name else "" # 如果有图标,确保图标和名称之间只有一个空格 if clean_icon: return f"{prefix}{clean_icon} {clean_name}" else: return f"{prefix}{clean_name}" # 定义一个通用的文件类型图标选择函数 def get_file_icon(file_name, is_dir=False): """根据文件扩展名返回对应的图标""" # 如果是文件夹,直接返回文件夹图标 if is_dir: return "📁" # 文件名转小写便于匹配 lower_name = file_name.lower() # 视频文件 if any(lower_name.endswith(ext) for ext in ['.mp4', '.mkv', '.avi', '.mov', '.rmvb', '.flv', '.wmv', '.m4v', '.ts', '.webm', '.3gp', '.f4v']): return "🎞️" # 图片文件 if any(lower_name.endswith(ext) for ext in ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.svg']): return "🖼️" # 音频文件 if any(lower_name.endswith(ext) for ext in ['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma']): return "🎵" # 文档文件 if any(lower_name.endswith(ext) for ext in ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.md', '.csv']): return "📄" # 压缩文件 if any(lower_name.endswith(ext) for ext in ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2']): return "📦" # 代码文件 if any(lower_name.endswith(ext) for ext in ['.py', '.js', '.html', '.css', '.java', '.c', '.cpp', '.php', '.go', '.json']): return "📝" # 字幕文件 if any(lower_name.endswith(ext) for ext in ['.srt', '.ass', '.ssa', '.vtt', '.sup']): return "💬" # 歌词文件 if any(lower_name.endswith(ext) for ext in ['.lrc']): return "💬" # 默认图标(其他文件类型) return "" # 定义一个函数来去除文件名中的所有图标 def remove_file_icons(filename): """去除文件名开头的所有图标""" # 定义所有可能的文件图标 icons = ["🎞️", "🖼️", "🎵", "📄", "📦", "📝", "💬", "📁"] # 去除开头的图标和空格 clean_name = filename for icon in icons: if clean_name.startswith(icon): clean_name = clean_name[len(icon):].lstrip() break return clean_name # 定义一个函数来检测字幕文件并应用语言代码后缀 def apply_subtitle_naming_rule(filename, task_settings): """ 检测字幕文件并应用语言代码后缀 Args: filename: 原始文件名 task_settings: 任务设置,包含字幕命名规则配置 Returns: 处理后的文件名 """ # 从任务设置中获取字幕命名规则配置 subtitle_add_language_code = task_settings.get("subtitle_add_language_code", False) subtitle_naming_rule = task_settings.get("subtitle_naming_rule", "zh") # 如果任务设置中没有配置,尝试从全局配置中获取 if not subtitle_add_language_code: try: if 'CONFIG_DATA' in globals() and CONFIG_DATA: global_task_settings = CONFIG_DATA.get("task_settings", {}) subtitle_add_language_code = global_task_settings.get("subtitle_add_language_code", False) subtitle_naming_rule = global_task_settings.get("subtitle_naming_rule", "zh") except (KeyError, AttributeError, TypeError): # 配置获取失败,使用默认值 pass # 检查是否启用了字幕命名规则 if not subtitle_add_language_code or not subtitle_naming_rule: return filename # 检测是否为字幕文件 lower_name = filename.lower() subtitle_extensions = ['.srt', '.ass', '.ssa', '.vtt', '.sup'] if not any(lower_name.endswith(ext) for ext in subtitle_extensions): return filename # 分离文件名和扩展名 name_without_ext, ext = os.path.splitext(filename) # 检查是否已经包含语言代码后缀,避免重复添加 if f".{subtitle_naming_rule}" in name_without_ext: return filename # 添加语言代码后缀 return f"{name_without_ext}.{subtitle_naming_rule}{ext}" class Config: # 下载配置 def download_file(url, save_path): response = requests.get(url) if response.status_code == 200: with open(save_path, "wb") as file: file.write(response.content) return True else: return False # 读取 JSON 文件内容 def read_json(config_path): with open(config_path, "r", encoding="utf-8") as f: data = json.load(f) return data # 将数据写入 JSON 文件 def write_json(config_path, data): with open(config_path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, sort_keys=False, indent=2) # 读取CK def get_cookies(cookie_val): if isinstance(cookie_val, list): return cookie_val elif cookie_val: if "\n" in cookie_val: return cookie_val.split("\n") else: return [cookie_val] else: return False def load_plugins(plugins_config={}, plugins_dir="plugins"): PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "").split(",") plugins_available = {} task_plugins_config = {} all_modules = [ f.replace(".py", "") for f in os.listdir(plugins_dir) if f.endswith(".py") ] # 调整模块优先级 priority_path = os.path.join(plugins_dir, "_priority.json") try: with open(priority_path, encoding="utf-8") as f: priority_modules = json.load(f) if priority_modules: all_modules = [ module for module in priority_modules if module in all_modules ] + [module for module in all_modules if module not in priority_modules] except (FileNotFoundError, json.JSONDecodeError): priority_modules = [] for module_name in all_modules: if f"-{module_name}" in PLUGIN_FLAGS: continue try: module = importlib.import_module(f"{plugins_dir}.{module_name}") ServerClass = getattr(module, module_name.capitalize()) # 检查配置中是否存在该模块的配置 if module_name in plugins_config: plugin = ServerClass(**plugins_config[module_name]) plugins_available[module_name] = plugin else: plugin = ServerClass() plugins_config[module_name] = plugin.default_config # 检查插件是否支持单独任务配置 if hasattr(plugin, "default_task_config"): task_plugins_config[module_name] = plugin.default_task_config except (ImportError, AttributeError) as e: print(f"载入模块 {module_name} 失败: {e}") print() return plugins_available, plugins_config, task_plugins_config def breaking_change_update(config_data): if config_data.get("emby"): print("🔼 Update config v0.3.6.1 to 0.3.7") config_data.setdefault("media_servers", {})["emby"] = { "url": config_data["emby"]["url"], "token": config_data["emby"]["apikey"], } del config_data["emby"] for task in config_data.get("tasklist", {}): task["media_id"] = task.get("emby_id", "") if task.get("emby_id"): del task["emby_id"] if config_data.get("media_servers"): print("🔼 Update config v0.3.8 to 0.3.9") config_data["plugins"] = config_data.get("media_servers") del config_data["media_servers"] for task in config_data.get("tasklist", {}): task["addition"] = { "emby": { "media_id": task.get("media_id", ""), } } if task.get("media_id"): del task["media_id"] class Quark: BASE_URL = "https://drive-pc.quark.cn" BASE_URL_APP = "https://drive-m.quark.cn" USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) quark-cloud-drive/3.14.2 Chrome/112.0.5615.165 Electron/24.1.3.8 Safari/537.36 Channel/pckk_other_ch" def __init__(self, cookie, index=None): self.cookie = cookie.strip() self.index = index + 1 self.is_active = False self.nickname = "" self.mparam = self._match_mparam_form_cookie(cookie) self.savepath_fid = {"/": "0"} def _match_mparam_form_cookie(self, cookie): mparam = {} kps_match = re.search(r"(?= total: break # 统一输出结构,缺失字段时提供默认值 if not isinstance(data, dict): return {"error": response.get("message", "request error")} data["list"] = list_merge if "paths" not in data: data["paths"] = [] return data def get_fids(self, file_paths): fids = [] while True: url = f"{self.BASE_URL}/1/clouddrive/file/info/path_list" querystring = {"pr": "ucpro", "fr": "pc"} payload = {"file_path": file_paths[:50], "namespace": "0"} response = self._send_request( "POST", url, json=payload, params=querystring ).json() if response["code"] == 0: fids += response["data"] file_paths = file_paths[50:] else: print(f"获取目录ID: 失败, {response['message']}") break if len(file_paths) == 0: break return fids def ls_dir(self, pdir_fid, **kwargs): file_list = [] page = 1 # 优化:增加每页大小,减少API调用次数 page_size = kwargs.get("page_size", 200) # 从50增加到200 while True: url = f"{self.BASE_URL}/1/clouddrive/file/sort" querystring = { "pr": "ucpro", "fr": "pc", "uc_param_str": "", "pdir_fid": pdir_fid, "_page": page, "_size": str(page_size), "_fetch_total": "1", "_fetch_sub_dirs": "0", "_sort": "file_type:asc,updated_at:desc", "_fetch_full_path": kwargs.get("fetch_full_path", 0), } response = self._send_request("GET", url, params=querystring).json() if response["code"] != 0: return {"error": response["message"]} if response["data"]["list"]: file_list += response["data"]["list"] page += 1 else: break if len(file_list) >= response["metadata"]["_total"]: break # 修复文件夹大小显示问题:当include_items字段不存在时,通过额外API调用获取 file_list = self._fix_folder_sizes(file_list) return file_list def _fix_folder_sizes(self, file_list): """ 修复文件夹大小显示问题 当include_items字段不存在时,通过额外API调用获取文件夹项目数量 """ if not isinstance(file_list, list): return file_list for item in file_list: if item.get("dir", False) and "include_items" not in item: folder_id = item.get("fid") if folder_id: # 获取文件夹项目数量 item_count = self._get_folder_item_count(folder_id) item["include_items"] = item_count return file_list def _get_folder_item_count(self, folder_id): """ 获取文件夹项目数量 """ try: url = f"{self.BASE_URL}/1/clouddrive/file/sort" querystring = { "pr": "ucpro", "fr": "pc", "uc_param_str": "", "pdir_fid": folder_id, "_page": 1, "_size": 1, # 只获取第一页,用于获取总数 "_fetch_total": "1", "_fetch_sub_dirs": "0", "_sort": "file_type:asc,updated_at:desc", "_fetch_full_path": 0, } response = self._send_request("GET", url, params=querystring).json() if response.get("code") == 0: metadata = response.get("metadata", {}) return metadata.get("_total", 0) else: return 0 except Exception: return 0 def get_paths(self, folder_id): """ 获取指定文件夹ID的完整路径信息 Args: folder_id: 文件夹ID Returns: list: 路径信息列表,每个元素包含fid和name """ if folder_id == "0" or folder_id == 0: return [] url = f"{self.BASE_URL}/1/clouddrive/file/sort" querystring = { "pr": "ucpro", "fr": "pc", "uc_param_str": "", "pdir_fid": folder_id, "_page": 1, "_size": "50", "_fetch_total": "1", "_fetch_sub_dirs": "0", "_sort": "file_type:asc,updated_at:desc", "_fetch_full_path": 1, } try: response = self._send_request("GET", url, params=querystring).json() if response["code"] == 0 and "full_path" in response["data"]: paths = [] for item in response["data"]["full_path"]: paths.append({ "fid": item["fid"], "name": item["file_name"] }) return paths except Exception as e: print(f"获取文件夹路径出错: {str(e)}") return [] def save_file(self, fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken): url = f"{self.BASE_URL}/1/clouddrive/share/sharepage/save" querystring = { "pr": "ucpro", "fr": "pc", "uc_param_str": "", "app": "clouddrive", "__dt": int(random.uniform(1, 5) * 60 * 1000), "__t": datetime.now().timestamp(), } payload = { "fid_list": fid_list, "fid_token_list": fid_token_list, "to_pdir_fid": to_pdir_fid, "pwd_id": pwd_id, "stoken": stoken, "pdir_fid": "0", "scene": "link", } response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def query_task(self, task_id): retry_index = 0 while True: url = f"{self.BASE_URL}/1/clouddrive/task" querystring = { "pr": "ucpro", "fr": "pc", "uc_param_str": "", "task_id": task_id, "retry_index": retry_index, "__dt": int(random.uniform(1, 5) * 60 * 1000), "__t": datetime.now().timestamp(), } response = self._send_request("GET", url, params=querystring).json() if response["data"]["status"] != 0: if retry_index > 0: print() break else: if retry_index == 0: print( f"正在等待「{response['data']['task_title']}」执行结果", end="", flush=True, ) else: print(".", end="", flush=True) retry_index += 1 time.sleep(0.500) return response def download(self, fids): url = f"{self.BASE_URL}/1/clouddrive/file/download" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = {"fids": fids} response = self._send_request("POST", url, json=payload, params=querystring) set_cookie = response.cookies.get_dict() cookie_str = "; ".join([f"{key}={value}" for key, value in set_cookie.items()]) return response.json(), cookie_str def mkdir(self, dir_path): url = f"{self.BASE_URL}/1/clouddrive/file" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = { "pdir_fid": "0", "file_name": "", "dir_path": dir_path, "dir_init_lock": False, } response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def mkdir_in_folder(self, parent_fid, folder_name): """在指定父目录下创建新文件夹""" url = f"{self.BASE_URL}/1/clouddrive/file" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = { "pdir_fid": parent_fid, "file_name": folder_name, "dir_path": "", "dir_init_lock": False, } response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def rename(self, fid, file_name): url = f"{self.BASE_URL}/1/clouddrive/file/rename" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = {"fid": fid, "file_name": file_name} response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def delete(self, filelist): url = f"{self.BASE_URL}/1/clouddrive/file/delete" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = {"action_type": 2, "filelist": filelist, "exclude_fids": []} response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def move(self, filelist, to_pdir_fid): """移动文件到指定目录""" url = f"{self.BASE_URL}/1/clouddrive/file/move" querystring = {"pr": "ucpro", "fr": "pc", "uc_param_str": ""} payload = { "action_type": 2, "filelist": filelist, "to_pdir_fid": to_pdir_fid, "exclude_fids": [] } response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response def recycle_list(self, page=1, size=30): url = f"{self.BASE_URL}/1/clouddrive/file/recycle/list" querystring = { "_page": page, "_size": size, "pr": "ucpro", "fr": "pc", "uc_param_str": "", } response = self._send_request("GET", url, params=querystring).json() return response["data"]["list"] def recycle_remove(self, record_list): url = f"{self.BASE_URL}/1/clouddrive/file/recycle/remove" querystring = {"uc_param_str": "", "fr": "pc", "pr": "ucpro"} payload = { "select_mode": 2, "record_list": record_list, } response = self._send_request( "POST", url, json=payload, params=querystring ).json() return response # ↑ 请求函数 # ↓ 操作函数 # 魔法正则匹配 def magic_regex_func(self, pattern, replace, taskname=None, magic_regex={}): magic_regex = magic_regex or CONFIG_DATA.get("magic_regex") or MAGIC_REGEX keyword = pattern if keyword in magic_regex: pattern = magic_regex[keyword]["pattern"] if replace == "": replace = magic_regex[keyword]["replace"] if taskname: replace = replace.replace("$TASKNAME", taskname) return pattern, replace # def get_id_from_url(self, url): # url = url.replace("https://pan.quark.cn/s/", "") # pattern = r"(\w+)(\?pwd=(\w+))?(#/list/share.*/(\w+))?" # match = re.search(pattern, url) # if match: # pwd_id = match.group(1) # passcode = match.group(3) if match.group(3) else "" # pdir_fid = match.group(5) if match.group(5) else 0 # return pwd_id, passcode, pdir_fid # else: # return None def extract_url(self, url): # pwd_id match_id = re.search(r"/s/(\w+)", url) pwd_id = match_id.group(1) if match_id else None # passcode match_pwd = re.search(r"pwd=(\w+)", url) passcode = match_pwd.group(1) if match_pwd else "" # path: fid-name paths = [] matches = re.findall(r"/(\w{32})-?([^/]+)?", url) for match in matches: fid = match[0] name = urllib.parse.unquote(match[1]) paths.append({"fid": fid, "name": name}) pdir_fid = paths[-1]["fid"] if matches else 0 return pwd_id, passcode, pdir_fid, paths def update_savepath_fid(self, tasklist): dir_paths = [ re.sub(r"/{2,}", "/", f"/{item['savepath']}") for item in tasklist if not item.get("enddate") or ( datetime.now().date() <= datetime.strptime(item["enddate"], "%Y-%m-%d").date() ) ] # 去掉每个路径开头的斜杠,确保格式一致 dir_paths = [path.lstrip('/') for path in dir_paths] if not dir_paths: return False # 重新添加斜杠前缀,确保格式一致 dir_paths = [f"/{path}" for path in dir_paths] dir_paths_exist_arr = self.get_fids(dir_paths) dir_paths_exist = [item["file_path"] for item in dir_paths_exist_arr] # 比较创建不存在的 dir_paths_unexist = list(set(dir_paths) - set(dir_paths_exist) - set(["/"])) for dir_path in dir_paths_unexist: mkdir_return = self.mkdir(dir_path) if mkdir_return["code"] == 0: new_dir = mkdir_return["data"] dir_paths_exist_arr.append( {"file_path": dir_path, "fid": new_dir["fid"]} ) # print(f"创建文件夹:{dir_path}") else: # print(f"创建文件夹:{dir_path} 失败, {mkdir_return['message']}") pass # 储存目标目录的fid for dir_path in dir_paths_exist_arr: self.savepath_fid[dir_path["file_path"]] = dir_path["fid"] # print(dir_paths_exist_arr) def do_save_check(self, shareurl, savepath): try: pwd_id, passcode, pdir_fid, _ = self.extract_url(shareurl) _, stoken = self.get_stoken(pwd_id, passcode) share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["list"] fid_list = [item["fid"] for item in share_file_list] fid_token_list = [item["share_fid_token"] for item in share_file_list] file_name_list = [item["file_name"] for item in share_file_list] if not fid_list: return get_fids = self.get_fids([savepath]) to_pdir_fid = ( get_fids[0]["fid"] if get_fids else self.mkdir(savepath)["data"]["fid"] ) save_file = self.save_file( fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken ) if save_file["code"] == 41017: return elif save_file["code"] == 0: dir_file_list = self.ls_dir(to_pdir_fid) del_list = [ item["fid"] for item in dir_file_list if (item["file_name"] in file_name_list) and ((datetime.now().timestamp() - item["created_at"]) < 60) ] if del_list: self.delete(del_list) recycle_list = self.recycle_list() record_id_list = [ item["record_id"] for item in recycle_list if item["fid"] in del_list ] self.recycle_remove(record_id_list) return save_file else: return False except Exception as e: print(f"转存测试失败: {str(e)}") def save_transfer_record(self, task, file_info, renamed_to=""): """保存转存记录到数据库 Args: task: 任务信息 file_info: 文件信息 renamed_to: 重命名后的名称 """ try: # 初始化数据库 db = RecordDB() # 提取文件信息 original_name = file_info.get("file_name", "") file_size = file_info.get("size", 0) # 处理修改日期 # 检查updated_at是否为未来日期 current_time = int(time.time()) modify_date = file_info.get("updated_at", current_time) # 如果修改日期是毫秒级时间戳,转换为秒级 if isinstance(modify_date, int) and modify_date > 9999999999: modify_date = int(modify_date / 1000) # 确保修改日期是合理的值(不是未来日期) if modify_date > current_time: # 使用当前时间作为备用值 modify_date = current_time file_id = file_info.get("fid", "") file_type = os.path.splitext(original_name)[1].lower().lstrip(".") if original_name else "" # 如果没有重命名信息,使用原始名称 if not renamed_to: renamed_to = original_name # 提取视频信息(时长和分辨率) duration = "" resolution = "" # 对常见视频格式添加时长和分辨率信息 video_exts = ["mp4", "mkv", "avi", "mov", "wmv", "flv", "m4v", "webm"] if file_type in video_exts: # 在实际应用中,这里可以通过媒体处理库提取时长和分辨率 # 目前只是添加占位符,未来可以扩展功能 pass # 获取保存路径 save_path = task.get("savepath", "") # 如果file_info中有子目录路径信息,则拼接完整路径 subdir_path = file_info.get("subdir_path", "") if subdir_path: # 确保路径格式正确,避免双斜杠 if save_path.endswith('/') and subdir_path.startswith('/'): save_path = save_path + subdir_path[1:] elif not save_path.endswith('/') and not subdir_path.startswith('/'): save_path = save_path + '/' + subdir_path else: save_path = save_path + subdir_path # 添加记录到数据库 db.add_record( task_name=task.get("taskname", ""), original_name=original_name, renamed_to=renamed_to, file_size=file_size, modify_date=modify_date, duration=duration, resolution=resolution, file_id=file_id, file_type=file_type, save_path=save_path ) # 调用后端接口触发该任务的指标实时同步(进度热更新) try: import requests _tname = task.get("taskname", "") if _tname: requests.post("http://127.0.0.1:9898/api/calendar/metrics/sync_task", json={"task_name": _tname}, timeout=1) except Exception: pass # 关闭数据库连接 db.close() # 触发SSE通知,让前端实时感知转存记录变化 notify_calendar_changed_safe('transfer_record_created') except Exception as e: print(f"保存转存记录失败: {e}") # 添加一个新的函数,功能与save_transfer_record相同,但名称更清晰表示其用途 def create_transfer_record(self, task, file_info, renamed_to=""): """创建新的转存记录 此函数与save_transfer_record功能完全相同,但名称更明确地表达了其目的 - 用于在文件初次转存时创建记录 Args: task: 任务信息 file_info: 文件信息 renamed_to: 重命名后的名称(如果有) """ self.save_transfer_record(task, file_info, renamed_to) def update_transfer_record(self, task, file_info, renamed_to): """更新转存记录的重命名信息 Args: task: 任务信息 file_info: 文件信息 renamed_to: 重命名后的名称 """ try: # 初始化数据库 db = RecordDB() # 提取信息用于查找记录 original_name = file_info.get("file_name", "") file_id = file_info.get("fid", "") task_name = task.get("taskname", "") # 获取保存路径 save_path = task.get("savepath", "") # 如果file_info中有子目录路径信息,则拼接完整路径 subdir_path = file_info.get("subdir_path", "") if subdir_path: # 确保路径格式正确,避免双斜杠 if save_path.endswith('/') and subdir_path.startswith('/'): save_path = save_path + subdir_path[1:] elif not save_path.endswith('/') and not subdir_path.startswith('/'): save_path = save_path + '/' + subdir_path else: save_path = save_path + subdir_path # 更新记录 updated = db.update_renamed_to( file_id=file_id, original_name=original_name, renamed_to=renamed_to, task_name=task_name, save_path=save_path ) # 关闭数据库连接 db.close() # 如果更新成功,触发SSE通知 if updated > 0: notify_calendar_changed_safe('transfer_record_updated') return updated > 0 except Exception as e: print(f"更新转存记录失败: {e}") return False # 添加一个专门从重命名日志更新记录的方法 def update_transfer_record_from_log(self, task, rename_log, actual_file_names=None): """从重命名日志中提取信息并更新记录 Args: task: 任务信息 rename_log: 重命名日志,格式为 "重命名: 旧名 → 新名" actual_file_names: 实际文件名映射字典,用于修正显示的文件名 """ try: # 使用字符串分割方法提取文件名,更可靠地获取完整文件名 if "重命名:" not in rename_log or " → " not in rename_log: return False # 先分割出"重命名:"后面的部分 parts = rename_log.split("重命名:", 1)[1].strip() # 再按箭头分割 if " → " not in parts: return False old_name, expected_new_name = parts.split(" → ", 1) # 如果新名称包含"失败",则是失败的重命名,跳过 if "失败" in expected_new_name: return False # 处理可能的截断标记,只保留实际文件名部分 # 注意:只有明确是失败消息才应该截断 if " 失败," in expected_new_name: expected_new_name = expected_new_name.split(" 失败,")[0] # 去除首尾空格 old_name = old_name.strip() expected_new_name = expected_new_name.strip() # 确保提取到的是完整文件名 if not old_name or not expected_new_name: return False # 获取实际的文件名(如果提供了映射) actual_new_name = expected_new_name if actual_file_names and old_name in actual_file_names: actual_new_name = actual_file_names[old_name] # 初始化数据库 db = RecordDB() # 使用原文件名和任务名查找记录 task_name = task.get("taskname", "") # 获取保存路径 save_path = task.get("savepath", "") # 注意:从日志中无法获取子目录信息,只能使用任务的主保存路径 # 检查文件是否已存在于记录中 # 先查询是否有匹配的记录 cursor = db.conn.cursor() query = "SELECT file_id FROM transfer_records WHERE original_name = ? AND task_name = ? AND save_path = ?" cursor.execute(query, (old_name, task_name, save_path)) result = cursor.fetchone() # 如果找到了匹配的记录,使用file_id进行更新 file_id = result[0] if result else "" # 更新记录,使用实际的文件名 if file_id: # 使用file_id更新 updated = db.update_renamed_to( file_id=file_id, original_name="", # 不使用原文件名,因为已有file_id renamed_to=actual_new_name, # 使用实际的文件名 task_name=task_name, save_path=save_path ) else: # 使用原文件名更新 updated = db.update_renamed_to( file_id="", # 不使用file_id查询,因为在日志中无法获取 original_name=old_name, renamed_to=actual_new_name, # 使用实际的文件名 task_name=task_name, save_path=save_path ) # 关闭数据库连接 db.close() return updated > 0 except Exception as e: print(f"根据日志更新转存记录失败: {e}") return False # 批量处理重命名日志 def process_rename_logs(self, task, rename_logs): """处理重命名日志列表,更新数据库记录 Args: task: 任务信息 rename_logs: 重命名日志列表 """ # 获取实际的文件名映射 actual_file_names = self.get_actual_file_names_from_directory(task, rename_logs) for log in rename_logs: if "重命名:" in log and "→" in log and "失败" not in log: self.update_transfer_record_from_log(task, log, actual_file_names) def get_actual_file_names_from_directory(self, task, rename_logs): """从目录中获取实际的文件名,用于修正转存记录和日志显示 Args: task: 任务信息 rename_logs: 重命名日志列表 Returns: dict: 原文件名到实际文件名的映射 """ try: # 获取保存路径 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") # 获取当前目录的文件列表 if hasattr(self, 'savepath_fid') and self.savepath_fid.get(savepath): dir_file_list = self.ls_dir(self.savepath_fid[savepath]) else: return {} # 从重命名日志中提取预期的重命名映射(包括失败的重命名) expected_renames = {} for log in rename_logs: if "重命名:" in log and " → " in log: parts = log.split("重命名:", 1)[1].strip() if " → " in parts: old_name, new_name = parts.split(" → ", 1) # 处理可能的失败信息 if " 失败," in new_name: new_name = new_name.split(" 失败,")[0] old_name = old_name.strip() new_name = new_name.strip() expected_renames[old_name] = new_name # 获取目录中实际存在的文件名 actual_files = [f["file_name"] for f in dir_file_list if not f["dir"]] # 创建实际文件名映射 actual_renames = {} # 对于每个预期的重命名,检查实际文件是否存在 for old_name, expected_new_name in expected_renames.items(): if expected_new_name in actual_files: # 预期的重命名成功了 actual_renames[old_name] = expected_new_name elif old_name in actual_files: # 重命名失败,文件保持原名 actual_renames[old_name] = old_name else: # 尝试模糊匹配,可能文件名有细微差异 for actual_file in actual_files: # 简单的相似度检查:如果文件名包含预期名称的主要部分 if (len(expected_new_name) > 10 and expected_new_name[:10] in actual_file and actual_file not in actual_renames.values()): actual_renames[old_name] = actual_file break else: # 如果找不到匹配,使用预期名称(可能文件已被删除或移动) actual_renames[old_name] = expected_new_name return actual_renames except Exception as e: print(f"获取实际文件名失败: {e}") return {} def check_file_exists_in_records(self, file_id, task=None): """检查文件ID是否存在于转存记录中 Args: file_id: 要检查的文件ID task: 可选的任务信息,用于进一步筛选 Returns: bool: 文件是否已存在于记录中 """ if not file_id: return False try: # 初始化数据库 db = RecordDB() # 构建查询条件 conditions = ["file_id = ?"] params = [file_id] # 如果提供了任务信息,添加任务名称条件 if task and task.get("taskname"): conditions.append("task_name = ?") params.append(task.get("taskname")) # 构建WHERE子句 where_clause = " AND ".join(conditions) # 查询是否存在匹配的记录 cursor = db.conn.cursor() query = f"SELECT COUNT(*) FROM transfer_records WHERE {where_clause}" cursor.execute(query, params) count = cursor.fetchone()[0] # 关闭数据库连接 db.close() return count > 0 except Exception as e: print(f"检查文件记录时出错: {e}") return False def do_save_task(self, task): # 判断资源失效记录 if task.get("shareurl_ban"): add_notify(f"❗《{task['taskname']}》分享资源已失效: {task['shareurl_ban']}\n") return # 标准化保存路径,去掉可能存在的首位斜杠,然后重新添加 savepath = task["savepath"].lstrip('/') task["savepath"] = savepath # 更新任务中的路径,确保后续处理一致 # 提取链接参数 pwd_id, passcode, pdir_fid, paths = self.extract_url(task["shareurl"]) if not pwd_id: task["shareurl_ban"] = f"提取链接参数失败,请检查分享链接是否有效" print(f"提取链接参数失败,请检查分享链接是否有效") return # 获取分享详情 is_sharing, stoken = self.get_stoken(pwd_id, passcode) if not is_sharing: # 如果是可恢复错误(网络/临时),不要设置为失效资源 try: error_text = str(stoken or "") if self.is_recoverable_error(error_text): print(f"分享详情获取失败(网络异常): {error_text}") return # 直接返回,不设置 shareurl_ban except Exception: pass # 非可恢复错误,按失效处理 task["shareurl_ban"] = stoken add_notify(f"❗《{task['taskname']}》分享详情获取失败: {stoken}\n") return share_detail = self.get_detail(pwd_id, stoken, pdir_fid, _fetch_share=1) # 如果获取详情返回错误,按可恢复性判断 if isinstance(share_detail, dict) and share_detail.get("error"): error_text = str(share_detail.get("error") or "") if self.is_recoverable_error(error_text): print(f"获取分享详情失败(网络异常): {error_text}") return # 直接返回,不设置 shareurl_ban else: task["shareurl_ban"] = self.format_unrecoverable_error(error_text) if hasattr(self, 'format_unrecoverable_error') else error_text add_notify(f"❗《{task['taskname']}》获取分享详情失败: {task['shareurl_ban']}\n") return # 获取保存路径fid savepath = task["savepath"] if not self.savepath_fid.get(savepath): # 检查规范化路径是否已在字典中 norm_savepath = re.sub(r"/{2,}", "/", f"/{savepath}") if norm_savepath != savepath and self.savepath_fid.get(norm_savepath): self.savepath_fid[savepath] = self.savepath_fid[norm_savepath] else: savepath_fids = self.get_fids([savepath]) if not savepath_fids: # print(f"保存路径不存在,准备新建:{savepath}") mkdir_result = self.mkdir(savepath) if mkdir_result["code"] == 0: self.savepath_fid[savepath] = mkdir_result["data"]["fid"] # print(f"保存路径新建成功:{savepath}") else: # print(f"保存路径新建失败:{mkdir_result['message']}") return else: # 路径已存在,直接设置fid self.savepath_fid[savepath] = savepath_fids[0]["fid"] # 支持顺序命名模式 if task.get("use_sequence_naming") and task.get("sequence_naming"): # 顺序命名模式下已经在do_save中打印了顺序命名信息,这里不再重复打印 # 设置正则模式为空 task["regex_pattern"] = None # 构建顺序命名的正则表达式 sequence_pattern = task["sequence_naming"] # 将{}替换为(\d+)用于匹配 if sequence_pattern == "{}": # 对于单独的{},使用特殊匹配 regex_pattern = "(\\d+)" else: regex_pattern = re.escape(sequence_pattern).replace('\\{\\}', '(\\d+)') task["regex_pattern"] = regex_pattern # 支持剧集命名模式 elif task.get("use_episode_naming") and task.get("episode_naming"): # 剧集命名模式下已经在do_save中打印了剧集命名信息,这里不再重复打印 # 构建剧集命名的正则表达式 episode_pattern = task["episode_naming"] # 先检查是否包含合法的[]字符 if "[]" in episode_pattern: # 对于所有包含[]的模式,使用完整的剧集号识别规则 regex_pattern = "SPECIAL_EPISODE_PATTERN" # 这个标记后续用于特殊处理 task["use_complex_episode_extraction"] = True # 添加一个标记 # 保存原始模式,用于生成新文件名 task["original_episode_pattern"] = episode_pattern else: # 如果输入模式不包含[],则使用简单匹配模式,避免正则表达式错误 regex_pattern = "^" + re.escape(episode_pattern) + "(\\d+)$" task["regex_pattern"] = regex_pattern else: # 正则命名模式 pattern, replace = self.magic_regex_func( task.get("pattern", ""), task.get("replace", ""), task["taskname"] ) # 注释掉这里的正则表达式打印,因为在do_save函数中已经打印了 # 只有在非魔法变量情况下才显示展开后的正则表达式 # 对于魔法变量($TV等),显示原始输入 # if pattern and task.get("pattern") and task.get("pattern") not in CONFIG_DATA.get("magic_regex", MAGIC_REGEX): # print(f"正则匹配: {pattern}") # print(f"正则替换: {replace}") # 保存文件 tree = self.dir_check_and_save(task, pwd_id, stoken, pdir_fid) # 检查是否有新文件转存 if tree and tree.size() <= 1: # 只有根节点意味着没有新文件 return False return tree def dir_check_and_save(self, task, pwd_id, stoken, pdir_fid="", subdir_path="", parent_dir_info=None): tree = Tree() # 获取分享文件列表 share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["list"] # print("share_file_list: ", share_file_list) if not share_file_list: if subdir_path == "": task["shareurl_ban"] = "分享为空,文件已被分享者删除" add_notify(f"❌《{task['taskname']}》: {task['shareurl_ban']}\n") return tree elif ( len(share_file_list) == 1 and share_file_list[0]["dir"] and subdir_path == "" ): # 仅有一个文件夹 print("🧠 该分享是一个文件夹,读取文件夹内列表") share_file_list = self.get_detail( pwd_id, stoken, share_file_list[0]["fid"] )["list"] # 添加父目录信息传递,用于确定是否在更新目录内 if parent_dir_info is None: parent_dir_info = { "in_update_dir": False, # 标记是否在更新目录内 "update_dir_pattern": task.get("update_subdir", ""), # 保存更新目录的正则表达式 "dir_path": [] # 保存目录路径 } # 对文件列表进行排序,使用全局文件排序函数的倒序排序 # 这样可以确保起始文件过滤逻辑正确工作 share_file_list.sort(key=sort_file_by_name, reverse=True) # 应用过滤词过滤 if task.get("filterwords"): # 记录过滤前的文件总数(包括文件夹) original_total_count = len(share_file_list) # 使用高级过滤函数处理保留词和过滤词 share_file_list = advanced_filter_files(share_file_list, task["filterwords"]) # 打印过滤信息(格式保持不变) # 计算剩余文件数 remaining_count = len(share_file_list) # 区分不同模式的显示逻辑: # 顺序命名和剧集命名模式不处理文件夹,应该排除文件夹计数 # 正则命名模式会处理文件夹,但只处理符合正则表达式的文件夹 if task.get("use_sequence_naming") or task.get("use_episode_naming"): # 计算剩余的实际可用文件数(排除文件夹) remaining_usable_count = len([f for f in share_file_list if not f.get("dir", False)]) print(f"📑 应用过滤词: {task['filterwords']},剩余 {remaining_usable_count} 个项目") else: # 正则模式下,需要先检查哪些文件/文件夹会被实际转存 pattern, replace = "", "" # 检查是否是剧集命名模式 if task.get("use_episode_naming") and task.get("regex_pattern"): # 使用预先准备好的正则表达式 pattern = task["regex_pattern"] else: # 普通正则命名模式 pattern, replace = self.magic_regex_func( task.get("pattern", ""), task.get("replace", ""), task["taskname"] ) # 确保pattern不为空,避免正则表达式错误 if not pattern: pattern = ".*" # 计算真正会被转存的项目数量,使用简化的逻辑 try: # 简化的计算逻辑:只检查正则表达式匹配 processable_items = [] for share_file in share_file_list: # 检查是否符合正则表达式 if not re.search(pattern, share_file["file_name"]): continue processable_items.append(share_file) remaining_count = len(processable_items) except Exception as e: # 出错时回退到简单计数方式 print(f"⚠️ 计算可处理项目时出错: {str(e)}") remaining_count = len([f for f in share_file_list if re.search(pattern, f["file_name"])]) print(f"📑 应用过滤词: {task['filterwords']},剩余 {remaining_count} 个项目") print() # 获取目标目录文件列表 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}") if not self.savepath_fid.get(savepath): # 检查规范化路径是否已在字典中 norm_savepath = re.sub(r"/{2,}", "/", f"/{savepath}") if norm_savepath != savepath and self.savepath_fid.get(norm_savepath): self.savepath_fid[savepath] = self.savepath_fid[norm_savepath] else: savepath_fids = self.get_fids([savepath]) if not savepath_fids: # print(f"保存路径不存在,准备新建:{savepath}") mkdir_result = self.mkdir(savepath) if mkdir_result["code"] == 0: self.savepath_fid[savepath] = mkdir_result["data"]["fid"] # print(f"保存路径新建成功:{savepath}") else: # print(f"保存路径新建失败:{mkdir_result['message']}") return else: # 路径已存在,直接设置fid self.savepath_fid[savepath] = savepath_fids[0]["fid"] to_pdir_fid = self.savepath_fid[savepath] dir_file_list = self.ls_dir(to_pdir_fid) # 检查根节点是否已存在 if not tree.contains(pdir_fid): tree.create_node( savepath, pdir_fid, data={ "is_dir": True, }, ) # 处理顺序命名模式 if task.get("use_sequence_naming") and task.get("sequence_naming"): # 顺序命名模式 current_sequence = 1 sequence_pattern = task["sequence_naming"] regex_pattern = task.get("regex_pattern") # 查找目录中现有的最大序号 for dir_file in dir_file_list: if not dir_file["dir"]: # 只检查文件 if sequence_pattern == "{}": # 对于单独的{},直接尝试匹配整个文件名是否为数字 file_name_without_ext = os.path.splitext(dir_file["file_name"])[0] if file_name_without_ext.isdigit(): try: seq_num = int(file_name_without_ext) current_sequence = max(current_sequence, seq_num + 1) except (ValueError, IndexError): pass elif matches := re.match(regex_pattern, dir_file["file_name"]): try: seq_num = int(matches.group(1)) current_sequence = max(current_sequence, seq_num + 1) except (ValueError, IndexError): pass # 构建目标目录中所有文件的查重索引(按大小和修改时间) dir_files_map = {} for dir_file in dir_file_list: if not dir_file["dir"]: # 仅处理文件 file_size = dir_file.get("size", 0) file_ext = os.path.splitext(dir_file["file_name"])[1].lower() update_time = dir_file.get("updated_at", 0) # 创建大小+扩展名的索引,用于快速查重 key = f"{file_size}_{file_ext}" if key not in dir_files_map: dir_files_map[key] = [] dir_files_map[key].append({ "file_name": dir_file["file_name"], "updated_at": update_time, }) # 预先过滤掉已经存在的文件(按大小和扩展名比对) # 只保留文件,不保留文件夹 filtered_share_files = [] start_fid = task.get("startfid", "") start_file_found = False for share_file in share_file_list: if share_file["dir"]: # 顺序命名模式下,未设置update_subdir时不处理文件夹 continue # 改进的起始文件过滤逻辑 - 优先执行,在数据库查重之前 if start_fid: if share_file["fid"] == start_fid: start_file_found = True # 找到起始文件,但不包含起始文件本身,只处理比它更新的文件 continue elif start_file_found: # 已经找到起始文件,跳过后续(更旧的)文件 continue # 如果还没找到起始文件,说明当前文件比起始文件更新,需要处理 else: # 没有设置起始文件,处理所有文件 pass # 检查文件ID是否存在于转存记录中 file_id = share_file.get("fid", "") if file_id and self.check_file_exists_in_records(file_id, task): # 文件ID已存在于记录中,跳过处理 continue file_size = share_file.get("size", 0) file_ext = os.path.splitext(share_file["file_name"])[1].lower() share_update_time = share_file.get("last_update_at", 0) or share_file.get("updated_at", 0) # 检查是否已存在相同大小和扩展名的文件 key = f"{file_size}_{file_ext}" is_duplicate = False if key in dir_files_map: for existing_file in dir_files_map[key]: existing_update_time = existing_file.get("updated_at", 0) # 防止除零错误 if existing_update_time == 0: continue # 如果修改时间相近(30天内)或者差距不大(10%以内),认为是同一个文件 time_diff = abs(share_update_time - existing_update_time) time_ratio = abs(1 - (share_update_time / existing_update_time)) if existing_update_time else 1 if time_diff < 2592000 or time_ratio < 0.1: # 文件已存在,跳过处理 is_duplicate = True break # 只有非重复文件才进行处理 if not is_duplicate: filtered_share_files.append(share_file) # 实现高级排序算法 def extract_sorting_value(file): # 使用全局排序函数 sort_tuple = sort_file_by_name(file) # 返回排序元组,实现多级排序 return sort_tuple # 对过滤后的文件进行排序(正序,确保顺序命名按正确顺序进行) # 注意:这里使用正序排序,因为顺序命名需要按照正确的顺序分配序号 filtered_share_files.sort(key=extract_sorting_value) # 判断是否使用单独的{}模式 # 需保存的文件清单 need_save_list = [] # 为每个文件分配序号 for share_file in filtered_share_files: # 获取文件扩展名 file_ext = os.path.splitext(share_file["file_name"])[1] # 生成新文件名 save_name = sequence_pattern.replace("{}", f"{current_sequence:02d}") + file_ext # 应用字幕命名规则 save_name = apply_subtitle_naming_rule(save_name, task) # 检查目标目录是否已存在此文件,支持忽略后缀选项 if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名部分,不比较扩展名 save_name_base = os.path.splitext(save_name)[0] file_exists = any( os.path.splitext(dir_file["file_name"])[0] == save_name_base for dir_file in dir_file_list ) else: # 不忽略后缀模式:完整文件名必须匹配 file_exists = any( dir_file["file_name"] == save_name for dir_file in dir_file_list ) if not file_exists: # 设置保存文件名(单独的{}不在这里重命名,而是在do_rename_task中处理) if sequence_pattern == "{}": share_file["save_name"] = share_file["file_name"] # 保持原文件名,稍后在do_rename_task中处理 else: share_file["save_name"] = save_name share_file["original_name"] = share_file["file_name"] # 保存原文件名,用于排序 need_save_list.append(share_file) current_sequence += 1 else: # print(f"跳过已存在的文件: {save_name}") pass # 这里不需要再次检查起始文件,因为在前面的过滤中已经处理了 # 处理子文件夹 for share_file in share_file_list: if share_file["dir"] and task.get("update_subdir", False): # 确定是否处理此目录: # 1. 如果当前在更新目录内,则处理所有子目录 # 2. 如果不在更新目录内,只处理符合更新目录规则的子目录 if (parent_dir_info and parent_dir_info.get("in_update_dir", False)) or re.search(task["update_subdir"], share_file["file_name"]): # print(f"检查子目录: {savepath}/{share_file['file_name']}") # 创建一个子任务对象,保留原任务的属性,但专门用于子目录处理 subdir_task = task.copy() # 确保子目录也可以使用忽略后缀功能 # 将子目录任务设置为正则命名模式 # 如果原任务没有设置pattern,或者使用的是顺序/剧集命名,确保有基本的pattern if (not subdir_task.get("pattern") or subdir_task.get("use_sequence_naming") or subdir_task.get("use_episode_naming")): subdir_task["pattern"] = ".*" subdir_task["replace"] = "" # 取消顺序命名和剧集命名模式,强制使用正则模式 subdir_task["use_sequence_naming"] = False subdir_task["use_episode_naming"] = False # 更新子目录的parent_dir_info,跟踪目录路径和更新状态 current_parent_info = parent_dir_info.copy() if parent_dir_info else { "in_update_dir": False, "update_dir_pattern": task.get("update_subdir", ""), "dir_path": [] } # 如果当前文件夹符合更新目录规则,标记为在更新目录内 if re.search(task["update_subdir"], share_file["file_name"]): current_parent_info["in_update_dir"] = True # 添加当前目录到路径 current_parent_info["dir_path"] = current_parent_info["dir_path"].copy() if "dir_path" in current_parent_info else [] current_parent_info["dir_path"].append(share_file["file_name"]) subdir_tree = self.dir_check_and_save( subdir_task, pwd_id, stoken, share_file["fid"], f"{subdir_path}/{share_file['file_name']}", current_parent_info ) # 只有当子目录树有实际内容(大于1表示不只有根节点)时才处理 if subdir_tree.size(1) > 0: # 检查子目录树是否只包含文件夹而没有文件 has_files = False for node in subdir_tree.all_nodes_itr(): # 检查是否有非目录节点(即文件节点) if node.data and not node.data.get("is_dir", False): has_files = True break # 只有当子目录包含文件时才将其合并到主树中 if has_files: # 获取保存路径的最后一部分目录名 save_path_basename = os.path.basename(task.get("savepath", "").rstrip("/")) # 跳过与保存路径同名的目录 if share_file["file_name"] == save_path_basename: continue # 合并子目录树 tree.create_node( f"📁{share_file['file_name']}", share_file["fid"], parent=pdir_fid, data={ "is_dir": share_file["dir"], }, ) tree.merge(share_file["fid"], subdir_tree, deep=False) # 标记此文件夹有更新 # 检查文件夹是否已添加到need_save_list folder_in_list = False for item in need_save_list: if item.get("fid") == share_file["fid"]: item["has_updates"] = True folder_in_list = True break # 如果文件夹未添加到need_save_list,需要添加 if not folder_in_list and has_files: # 检查目标目录中是否已存在同名文件夹 dir_exists = False for dir_file in dir_file_list: if dir_file["dir"] and dir_file["file_name"] == share_file["file_name"]: dir_exists = True break # 如果存在同名文件夹,检查文件夹内是否有更新 # 如果不存在同名文件夹,或者文件夹内有更新,则添加到保存列表 if not dir_exists or has_files: share_file["save_name"] = share_file["file_name"] share_file["original_name"] = share_file["file_name"] share_file["has_updates"] = True need_save_list.append(share_file) elif task.get("use_episode_naming") and task.get("episode_naming"): # 剧集命名模式 need_save_list = [] # 构建目标目录中所有文件的查重索引(按大小和修改时间) dir_files_map = {} for dir_file in dir_file_list: if not dir_file["dir"]: # 仅处理文件 file_size = dir_file.get("size", 0) file_ext = os.path.splitext(dir_file["file_name"])[1].lower() update_time = dir_file.get("updated_at", 0) # 创建大小+扩展名的索引,用于快速查重 key = f"{file_size}_{file_ext}" if key not in dir_files_map: dir_files_map[key] = [] dir_files_map[key].append({ "file_name": dir_file["file_name"], "updated_at": update_time, }) # 预先过滤分享文件列表,去除已存在的文件 filtered_share_files = [] start_fid = task.get("startfid", "") start_file_found = False for share_file in share_file_list: if share_file["dir"]: # 处理子目录 if task.get("update_subdir") and re.search(task["update_subdir"], share_file["file_name"]): filtered_share_files.append(share_file) continue # 改进的起始文件过滤逻辑 - 优先执行,在数据库查重之前 if start_fid: if share_file["fid"] == start_fid: start_file_found = True # 找到起始文件,但不包含起始文件本身,只处理比它更新的文件 continue elif start_file_found: # 已经找到起始文件,跳过后续(更旧的)文件 continue # 如果还没找到起始文件,说明当前文件比起始文件更新,需要处理 else: # 没有设置起始文件,处理所有文件 pass # 检查文件ID是否存在于转存记录中 file_id = share_file.get("fid", "") if file_id and self.check_file_exists_in_records(file_id, task): # 文件ID已存在于记录中,跳过处理 continue # 从共享文件中提取剧集号 episode_num = extract_episode_number(share_file["file_name"]) is_duplicate = False # 通过文件名判断是否已存在(新的查重逻辑) if not is_duplicate: # 如果没有重命名,判断原文件名是否已存在 original_name = share_file["file_name"] # 如果有剧集号,判断重命名后的文件名是否已存在 file_ext = os.path.splitext(original_name)[1] if episode_num is not None: # 根据剧集命名模式生成目标文件名 episode_pattern = task["episode_naming"] if episode_pattern == "[]": target_name = f"{episode_num:02d}{file_ext}" else: target_name = episode_pattern.replace("[]", f"{episode_num:02d}") + file_ext # 应用字幕命名规则 target_name = apply_subtitle_naming_rule(target_name, task) # 检查目标文件名是否已存在,支持忽略后缀选项 if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名部分,不比较扩展名 target_name_base = os.path.splitext(target_name)[0] target_exists = any( os.path.splitext(dir_file["file_name"])[0] == target_name_base for dir_file in dir_file_list ) else: # 不忽略后缀模式:完整文件名必须匹配 target_exists = any(dir_file["file_name"] == target_name for dir_file in dir_file_list) if target_exists: is_duplicate = True # 如果没有重复,检查文件大小和扩展名是否重复 if not is_duplicate: file_size = share_file.get("size", 0) file_ext_lower = file_ext.lower() share_update_time = share_file.get("last_update_at", 0) or share_file.get("updated_at", 0) # 检查是否已存在相同大小和扩展名的文件 key = f"{file_size}_{file_ext_lower}" if key in dir_files_map: for existing_file in dir_files_map[key]: existing_update_time = existing_file.get("updated_at", 0) # 防止除零错误 if existing_update_time == 0: continue # 如果修改时间相近(30天内)或者差距不大(10%以内),认为是同一个文件 time_diff = abs(share_update_time - existing_update_time) time_ratio = abs(1 - (share_update_time / existing_update_time)) if existing_update_time else 1 if time_diff < 2592000 or time_ratio < 0.1: # 文件已存在,跳过处理 is_duplicate = True break if not is_duplicate: share_file["save_name"] = share_file["file_name"] # 剧集命名模式下保持原文件名,重命名在后续步骤进行 share_file["original_name"] = share_file["file_name"] filtered_share_files.append(share_file) # 实现高级排序算法 def sort_by_episode(file): if file["dir"]: return (float('inf'), 0) filename = file["file_name"] # 优先匹配S01E01格式 match_s_e = re.search(r'[Ss](\d+)[Ee](\d+)', filename) if match_s_e: season = int(match_s_e.group(1)) episode = int(match_s_e.group(2)) return (season * 1000 + episode, 0) # 使用统一的剧集提取函数 episode_num = extract_episode_number(filename) if episode_num is not None: return (episode_num, 0) # 无法识别,回退到修改时间排序 return (float('inf'), file.get("last_update_at", 0)) # 过滤出文件并排序 files_to_process = [f for f in filtered_share_files if not f["dir"]] sorted_files = sorted(files_to_process, key=sort_by_episode) # 要保存的文件列表 need_save_list = [] # 添加排序后的文件到保存列表 for share_file in sorted_files: need_save_list.append(share_file) # 处理文件夹 for share_file in filtered_share_files: if share_file["dir"]: need_save_list.append(share_file) else: # 正则命名模式(普通正则命名模式) need_save_list = [] # 构建目标目录中所有文件的查重索引(按大小和修改时间)- 加入文件查重机制 dir_files_map = {} for dir_file in dir_file_list: if not dir_file["dir"]: # 仅处理文件 file_size = dir_file.get("size", 0) file_ext = os.path.splitext(dir_file["file_name"])[1].lower() update_time = dir_file.get("updated_at", 0) # 创建大小+扩展名的索引,用于快速查重 key = f"{file_size}_{file_ext}" if key not in dir_files_map: dir_files_map[key] = [] dir_files_map[key].append({ "file_name": dir_file["file_name"], "updated_at": update_time, }) # 应用起始文件过滤逻辑 start_fid = task.get("startfid", "") if start_fid: # 找到起始文件的索引 start_index = -1 for i, share_file in enumerate(share_file_list): if share_file["fid"] == start_fid: start_index = i break if start_index >= 0: # 只处理起始文件之前的文件(比起始文件更新的文件,不包括起始文件本身) share_file_list = share_file_list[:start_index] # 添加符合的 for share_file in share_file_list: # 检查文件ID是否存在于转存记录中 file_id = share_file.get("fid", "") if file_id and self.check_file_exists_in_records(file_id, task): # 文件ID已存在于记录中,跳过处理 continue # 检查文件是否已存在(通过大小和扩展名)- 新增的文件查重逻辑 is_duplicate = False if not share_file["dir"]: # 文件夹不进行内容查重 # 新的查重逻辑:优先使用文件名查重,支持忽略后缀 original_file_name = share_file["file_name"] original_name_base = os.path.splitext(original_file_name)[0] # 判断是否用正则替换后的文件名 if task.get("pattern") and task.get("replace") is not None: pattern, replace = self.magic_regex_func( task.get("pattern", ""), task.get("replace", ""), task.get("taskname", "") ) # 确保pattern不为空,避免正则表达式错误 if pattern: try: # 尝试应用正则替换 if re.search(pattern, original_file_name): renamed_file = re.sub(pattern, replace, original_file_name) # 应用字幕命名规则 renamed_file = apply_subtitle_naming_rule(renamed_file, task) renamed_base = os.path.splitext(renamed_file)[0] else: renamed_file = None renamed_base = None except Exception: # 正则出错时使用原文件名 renamed_file = None renamed_base = None else: renamed_file = None renamed_base = None else: renamed_file = None renamed_base = None # 查重逻辑,同时考虑原文件名和重命名后的文件名 for dir_file in dir_file_list: if dir_file["dir"]: continue if task.get("ignore_extension", False): # 忽略后缀:只比较文件名部分,不管扩展名 existing_name_base = os.path.splitext(dir_file["file_name"])[0] # 如果原文件名或重命名后文件名与目标目录中文件名相同(忽略后缀),则视为已存在 if (existing_name_base == original_name_base or (renamed_file and existing_name_base == renamed_base)): is_duplicate = True break else: # 不忽略后缀:文件名和扩展名都要一致才视为同一个文件 if (dir_file["file_name"] == original_file_name or (renamed_file and dir_file["file_name"] == renamed_file)): is_duplicate = True break # 如果文件已经存在并且不是目录,跳过后续处理 if is_duplicate and not share_file["dir"]: continue # 设置匹配模式:目录使用update_subdir,文件使用普通正则 if share_file["dir"] and task.get("update_subdir", False): # 确定是否处理此目录: # 1. 如果当前在更新目录内,则处理所有子目录 # 2. 如果不在更新目录内,只处理符合更新目录规则的子目录 if parent_dir_info and parent_dir_info.get("in_update_dir", False): # 已经在更新目录内,处理所有子目录 pass elif not re.search(task["update_subdir"], share_file["file_name"]): # 不在更新目录内,且不符合更新目录规则,跳过处理 continue # 先检查目标目录中是否已存在这个子目录 dir_exists = False for dir_file in dir_file_list: if dir_file["dir"] and dir_file["file_name"] == share_file["file_name"]: dir_exists = True break # 如果目标中已经存在此子目录,则直接检查子目录的内容更新,不要重复转存 if dir_exists: # 子目录存在,直接递归处理其中的文件,不在主目录的处理中再转存一次 # print(f"检查子目录: {savepath}/{share_file['file_name']} (已存在)") # 创建一个子任务对象,专门用于子目录处理 subdir_task = task.copy() if (not subdir_task.get("pattern") or subdir_task.get("use_sequence_naming") or subdir_task.get("use_episode_naming")): subdir_task["pattern"] = ".*" subdir_task["replace"] = "" # 取消顺序命名和剧集命名模式,强制使用正则模式 subdir_task["use_sequence_naming"] = False subdir_task["use_episode_naming"] = False # 更新子目录的parent_dir_info,跟踪目录路径和更新状态 current_parent_info = parent_dir_info.copy() if parent_dir_info else { "in_update_dir": False, "update_dir_pattern": task.get("update_subdir", ""), "dir_path": [] } # 如果当前文件夹符合更新目录规则,标记为在更新目录内 if re.search(task["update_subdir"], share_file["file_name"]): current_parent_info["in_update_dir"] = True # 添加当前目录到路径 current_parent_info["dir_path"] = current_parent_info["dir_path"].copy() if "dir_path" in current_parent_info else [] current_parent_info["dir_path"].append(share_file["file_name"]) # 递归处理子目录但不在need_save_list中添加目录本身 subdir_tree = self.dir_check_and_save( subdir_task, pwd_id, stoken, share_file["fid"], f"{subdir_path}/{share_file['file_name']}", current_parent_info ) # 如果子目录有新内容,合并到主树中 if subdir_tree and subdir_tree.size() > 1: has_files = False for node in subdir_tree.all_nodes_itr(): if node.data and not node.data.get("is_dir", False): has_files = True break if has_files: # 添加目录到树中但不添加到保存列表 if not tree.contains(share_file["fid"]): tree.create_node( f"📁{share_file['file_name']}", share_file["fid"], parent=pdir_fid, data={ "is_dir": share_file["dir"], }, ) # 合并子目录树 tree.merge(share_file["fid"], subdir_tree, deep=False) # 跳过后续处理,不对已存在的子目录再做转存处理 continue # 目录不存在,继续正常流程 pattern, replace = task["update_subdir"], "" else: # 检查是否是剧集命名模式 if task.get("use_episode_naming") and task.get("regex_pattern"): # 使用预先准备好的正则表达式 pattern = task["regex_pattern"] replace = "" else: # 普通正则命名模式 pattern, replace = self.magic_regex_func( task.get("pattern", ""), task.get("replace", ""), task["taskname"] ) # 确保pattern不为空,避免正则表达式错误 if not pattern: pattern = ".*" # 正则文件名匹配 try: if re.search(pattern, share_file["file_name"]): # 替换后的文件名 save_name = ( re.sub(pattern, replace, share_file["file_name"]) if replace != "" else share_file["file_name"] ) # 应用字幕命名规则 save_name = apply_subtitle_naming_rule(save_name, task) # 检查新名称是否存在重复的前缀 if replace and " - " in save_name: parts = save_name.split(" - ") if len(parts) >= 2 and parts[0] == parts[1]: # 如果新名称包含重复前缀,使用原文件名 save_name = share_file["file_name"] # 检查是否任务名已经存在于原文件名中 taskname = task.get("taskname", "") if taskname and taskname in share_file["file_name"] and share_file["file_name"].startswith(taskname): # 如果原文件名已包含任务名作为前缀,保持原样 save_name = share_file["file_name"] # 为正则模式实现基于文件名的查重逻辑,支持忽略后缀选项 # 判断目标目录文件是否存在 file_exists = False for dir_file in dir_file_list: if dir_file["dir"] and share_file["dir"]: # 如果都是目录,只要名称相同就视为已存在 if dir_file["file_name"] == share_file["file_name"]: file_exists = True break elif not dir_file["dir"] and not share_file["dir"]: # 如果都是文件 if task.get("ignore_extension", False): # 忽略后缀:只比较文件名部分,不管扩展名 original_name_base = os.path.splitext(share_file["file_name"])[0] renamed_name_base = os.path.splitext(save_name)[0] existing_name_base = os.path.splitext(dir_file["file_name"])[0] # 如果原文件名或重命名后文件名与目标目录中文件名相同(忽略后缀),则视为已存在 if existing_name_base == original_name_base or existing_name_base == renamed_name_base: file_exists = True break else: # 不忽略后缀:文件名和扩展名都要一致才视为同一个文件 if dir_file["file_name"] == share_file["file_name"] or dir_file["file_name"] == save_name: file_exists = True break if not file_exists: # 不打印保存信息 share_file["save_name"] = save_name share_file["original_name"] = share_file["file_name"] # 保存原文件名,用于排序 # 文件夹需要特殊处理,标记为has_updates=False,等待后续检查 # 只有在文件夹匹配update_subdir时才设置 if share_file["dir"]: share_file["has_updates"] = False # 将文件添加到保存列表 need_save_list.append(share_file) elif share_file["dir"]: # 文件夹已存在,根据是否递归处理子目录决定操作 # 如果开启了子目录递归,处理子目录结构 if task.get("update_subdir", False): # print(f"检查子目录: {savepath}/{share_file['file_name']}") # 创建一个子任务对象,保留原任务的属性,但专门用于子目录处理 subdir_task = task.copy() # 确保子目录也可以使用忽略后缀功能 # 如果原任务没有设置pattern,确保有基本的pattern if not subdir_task.get("pattern"): subdir_task["pattern"] = ".*" # 在子目录中匹配所有文件 subdir_task["replace"] = "" # 更新子目录的parent_dir_info,跟踪目录路径和更新状态 current_parent_info = parent_dir_info.copy() if parent_dir_info else { "in_update_dir": False, "update_dir_pattern": task.get("update_subdir", ""), "dir_path": [] } # 如果当前文件夹符合更新目录规则,标记为在更新目录内 if re.search(task["update_subdir"], share_file["file_name"]): current_parent_info["in_update_dir"] = True # 添加当前目录到路径 current_parent_info["dir_path"] = current_parent_info["dir_path"].copy() if "dir_path" in current_parent_info else [] current_parent_info["dir_path"].append(share_file["file_name"]) # 递归处理子目录 subdir_tree = self.dir_check_and_save( subdir_task, pwd_id, stoken, share_file["fid"], f"{subdir_path}/{share_file['file_name']}", current_parent_info ) # 只有当子目录树有实际内容(大于1表示不只有根节点)时才处理 if subdir_tree and subdir_tree.size() > 1: # 检查子目录树是否只包含文件夹而没有文件 has_files = False for node in subdir_tree.all_nodes_itr(): # 检查是否有非目录节点(即文件节点) if node.data and not node.data.get("is_dir", False): has_files = True break # 只有当子目录包含文件时才将其合并到主树中 if has_files: # 获取保存路径的最后一部分目录名 save_path_basename = os.path.basename(task.get("savepath", "").rstrip("/")) # 跳过与保存路径同名的目录 if share_file["file_name"] == save_path_basename: continue # 添加目录到树中 # 检查节点是否已存在于树中,避免重复添加 if not tree.contains(share_file["fid"]): tree.create_node( f"📁{share_file['file_name']}", share_file["fid"], parent=pdir_fid, data={ "is_dir": share_file["dir"], }, ) # 合并子目录树 tree.merge(share_file["fid"], subdir_tree, deep=False) # 检查文件夹是否已添加到need_save_list folder_in_list = False for item in need_save_list: if item.get("fid") == share_file["fid"]: # 文件夹已在列表中,设置为有更新 item["has_updates"] = True folder_in_list = True break # 如果文件夹未添加到need_save_list且有文件更新,则添加 if not folder_in_list: # 检查目标目录中是否已存在同名子目录 dir_exists = False for dir_file in dir_file_list: if dir_file["dir"] and dir_file["file_name"] == share_file["file_name"]: dir_exists = True break # 只有当目录不存在于目标位置时,才将其添加到转存列表 if not dir_exists: # 将父文件夹添加到保存列表,确保子目录的变化能被处理 share_file["save_name"] = share_file["file_name"] share_file["original_name"] = share_file["file_name"] share_file["has_updates"] = True # 标记为有更新 need_save_list.append(share_file) print(f"发现子目录 {share_file['file_name']} 有更新,将包含到转存列表") else: # 如果子目录已存在,只显示提示消息,不添加到转存列表 print(f"发现子目录 {share_file['file_name']} 有更新,将更新到已存在的文件夹中") except Exception as e: print(f"⚠️ 正则表达式错误: {str(e)}, pattern: {pattern}") # 使用安全的默认值 share_file["save_name"] = share_file["file_name"] share_file["original_name"] = share_file["file_name"] need_save_list.append(share_file) fid_list = [item["fid"] for item in need_save_list] fid_token_list = [item["share_fid_token"] for item in need_save_list] # 过滤掉没有真正内容更新的文件夹(仅在正则命名模式下) if not task.get("use_sequence_naming") and not task.get("use_episode_naming") and need_save_list: # 计算非目录文件数量 non_dir_files = [item for item in need_save_list if not item.get("dir", False)] # 如果有常规文件,代表有真正的更新 has_file_updates = len(non_dir_files) > 0 # 检查文件夹是否标记为有更新 folders_with_updates = [item for item in need_save_list if item.get("dir", False) and item.get("has_updates", False) == True] has_folder_updates = len(folders_with_updates) > 0 # 获取保存路径的最后一部分目录名 save_path_basename = os.path.basename(task.get("savepath", "").rstrip("/")) # 从列表中移除没有真正更新的文件夹和与保存路径同名的目录 filtered_need_save_list = [] for item in need_save_list: # 跳过与保存路径同名的目录 if item.get("dir", False) and item.get("save_name") == save_path_basename: continue # 跳过没有更新的文件夹 if item.get("dir", False) and item.get("has_updates", False) == False and not has_file_updates and not has_folder_updates: continue # 保留其他所有项目 filtered_need_save_list.append(item) need_save_list = filtered_need_save_list # 如果过滤后列表为空,直接返回树对象 if not need_save_list: return tree # 更新fid列表 fid_list = [item["fid"] for item in need_save_list] fid_token_list = [item["share_fid_token"] for item in need_save_list] if fid_list: # 只在有新文件需要转存时才处理 save_file_return = self.save_file( fid_list, fid_token_list, to_pdir_fid, pwd_id, stoken ) err_msg = None if save_file_return["code"] == 0: task_id = save_file_return["data"]["task_id"] query_task_return = self.query_task(task_id) if query_task_return["code"] == 0: # 建立目录树 saved_files = [] for index, item in enumerate(need_save_list): icon = ( "📁" if item["dir"] == True else "🎞️" if item["obj_category"] == "video" else get_file_icon(item["save_name"], False) ) # 修复文件树显示问题 - 防止文件名重复重复显示 # 对于顺序命名和剧集命名模式,转存时使用原文件名显示 # 因为实际文件是以原名保存的,重命名在后续步骤进行 if task.get("use_sequence_naming") or task.get("use_episode_naming"): display_name = item['file_name'] # 使用原文件名 else: # 其他模式使用save_name display_name = item['save_name'] # 确保只显示文件/文件夹名,而不是完整路径 if "/" in display_name: # 只取路径的最后一部分作为显示名 display_name = display_name.split("/")[-1] # 不再自动添加任务名称前缀,尊重用户选择 # 保存到树中 saved_files.append(format_file_display("", icon, display_name)) # 检查节点是否已存在于树中,避免重复添加 if not tree.contains(item["fid"]): # 安全地获取save_as_top_fids中的fid,防止索引越界 save_as_top_fids = query_task_return.get('data', {}).get('save_as', {}).get('save_as_top_fids', []) saved_fid = save_as_top_fids[index] if index < len(save_as_top_fids) else item["fid"] tree.create_node( display_name, # 只存储文件名,不包含图标 item["fid"], parent=pdir_fid, data={ "fid": f"{saved_fid}", "path": f"{savepath}/{item['save_name']}", "is_dir": item["dir"], "icon": icon, # 将图标存储在data中 }, ) # 保存转存记录到数据库 if not item["dir"]: # 只记录文件,不记录文件夹 # 转存时先用原文件名记录,重命名后再更新 self.create_transfer_record( task=task, file_info=item, renamed_to=item["file_name"] # 转存时使用原文件名 ) # 移除通知生成,由do_save函数统一处理 # 顺序命名模式和剧集命名模式都不在此处生成通知 else: err_msg = query_task_return["message"] else: err_msg = save_file_return["message"] if err_msg: add_notify(f"❌《{task['taskname']}》转存失败: {err_msg}\n") else: # 没有新文件需要转存 if not subdir_path: # 只在顶层(非子目录)打印一次消息 pass return tree def do_rename_task(self, task, subdir_path=""): # 检查是否为顺序命名模式 if task.get("use_sequence_naming") and task.get("sequence_naming"): # 使用顺序命名模式 sequence_pattern = task["sequence_naming"] # 替换占位符为正则表达式捕获组 if sequence_pattern == "{}": # 对于单独的{},使用特殊匹配 regex_pattern = "(\\d+)" else: regex_pattern = re.escape(sequence_pattern).replace('\\{\\}', '(\\d+)') savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}") if not self.savepath_fid.get(savepath): # 路径已存在,直接设置fid self.savepath_fid[savepath] = self.get_fids([savepath])[0]["fid"] dir_file_list = self.ls_dir(self.savepath_fid[savepath]) dir_file_name_list = [item["file_name"] for item in dir_file_list] # 判断目录是否为空(只包含非目录文件) non_dir_files = [f for f in dir_file_list if not f.get("dir", False)] is_empty_dir = len(non_dir_files) == 0 # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) if task.get("filterwords"): # 记录过滤前的文件总数 original_total_count = len(dir_file_list) # 使用高级过滤函数处理保留词和过滤词 dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) dir_file_name_list = [item["file_name"] for item in dir_file_list] # 找出当前最大序号 max_sequence = 0 # 先检查目录中是否有符合命名规则的文件 has_matching_files = False for dir_file in dir_file_list: if dir_file.get("dir", False): continue # 跳过文件夹 if sequence_pattern == "{}": # 对于单独的{},直接尝试匹配整个文件名是否为数字 file_name_without_ext = os.path.splitext(dir_file["file_name"])[0] if file_name_without_ext.isdigit(): # 增加判断:如果是日期格式的纯数字,不应被视为序号 if not is_date_format(file_name_without_ext): try: current_seq = int(file_name_without_ext) max_sequence = max(max_sequence, current_seq) has_matching_files = True except (ValueError, IndexError): pass elif matches := re.match(regex_pattern, dir_file["file_name"]): try: current_seq = int(matches.group(1)) max_sequence = max(max_sequence, current_seq) has_matching_files = True except (IndexError, ValueError): pass if not has_matching_files: # 没有符合命名规则的文件时,检查数据库中的转存记录 try: from app.sdk.db import RecordDB db = RecordDB() # 获取当前保存路径 current_save_path = task.get("savepath", "") if subdir_path: current_save_path = f"{current_save_path}/{subdir_path}" # 查询该目录的转存记录 records = db.get_records_by_save_path(current_save_path, include_subpaths=False) # 从转存记录中提取最大序号 max_sequence_from_records = 0 for record in records: renamed_to = record.get("renamed_to", "") if renamed_to: if sequence_pattern == "{}": # 对于单独的{},直接尝试匹配整个文件名是否为数字 file_name_without_ext = os.path.splitext(renamed_to)[0] if file_name_without_ext.isdigit(): try: seq_num = int(file_name_without_ext) max_sequence_from_records = max(max_sequence_from_records, seq_num) except (ValueError, IndexError): pass elif matches := re.match(regex_pattern, renamed_to): try: seq_num = int(matches.group(1)) max_sequence_from_records = max(max_sequence_from_records, seq_num) except (ValueError, IndexError): pass # 使用记录中的最大序号 max_sequence = max_sequence_from_records db.close() except Exception as e: max_sequence = 0 # 实现高级排序算法 def extract_sorting_value(file): # 使用全局排序函数 sort_tuple = sort_file_by_name(file) # 返回排序元组,实现多级排序 return sort_tuple # 判断是否使用单独的{}模式 # 初始化sorted_files列表,用于收集需要重命名的文件 sorted_files = [] # 对于单独的{}模式,增加额外检查 if sequence_pattern == "{}": # 收集所有不是纯数字命名的文件 for dir_file in dir_file_list: if dir_file["dir"]: continue # 跳过文件夹 file_name_without_ext = os.path.splitext(dir_file["file_name"])[0] # 检查文件名是否为纯数字,如果是则跳过(已经命名好的) if file_name_without_ext.isdigit(): # 增加判断:如果是日期格式的纯数字,不视为已命名 if not is_date_format(file_name_without_ext): # 不是日期格式,是纯数字序号,跳过 continue # 是日期格式,需要重命名,所以不跳过 # 添加到需要处理的文件列表 sorted_files.append(dir_file) else: # 对于非单独{}的模式,收集所有不符合模式的文件 for dir_file in dir_file_list: if dir_file["dir"]: continue # 跳过文件夹 # 检查是否已符合命名模式 if re.match(regex_pattern, dir_file["file_name"]): continue # 跳过已经符合命名规则的文件 # 添加到需要处理的文件列表 sorted_files.append(dir_file) # 使用extract_sorting_value函数对所有需要处理的文件进行排序 sorted_files = sorted(sorted_files, key=extract_sorting_value) # 收集所有需要重命名的文件,并按顺序处理 renamed_pairs = [] # 如果没有找到有效的最大序号,从1开始命名 if max_sequence == 0: current_sequence = 0 # 会立即加1,所以从0开始 else: current_sequence = max_sequence # 对排序好的文件应用顺序命名 for dir_file in sorted_files: current_sequence += 1 file_ext = os.path.splitext(dir_file["file_name"])[1] # 根据顺序命名模式生成新的文件名 if sequence_pattern == "{}": # 对于单独的{},直接使用数字序号作为文件名,不再使用日期格式 save_name = f"{current_sequence:02d}{file_ext}" else: save_name = sequence_pattern.replace("{}", f"{current_sequence:02d}") + file_ext # 应用字幕命名规则 save_name = apply_subtitle_naming_rule(save_name, task) # 检查是否需要重命名,支持忽略后缀选项 name_conflict = False if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名部分,不比较扩展名 save_name_base = os.path.splitext(save_name)[0] name_conflict = any( os.path.splitext(existing_name)[0] == save_name_base for existing_name in dir_file_name_list ) else: # 不忽略后缀模式:完整文件名必须匹配 name_conflict = save_name in dir_file_name_list if save_name != dir_file["file_name"] and not name_conflict: # 收集重命名对,包含原始文件信息以便排序 renamed_pairs.append((dir_file, save_name, current_sequence)) dir_file_name_list.append(save_name) # 确保按照序号顺序执行重命名操作 renamed_pairs.sort(key=lambda x: x[2]) is_rename_count = 0 rename_logs = [] # 初始化重命名日志列表 # 执行重命名,并按顺序打印 for dir_file, save_name, _ in renamed_pairs: try: rename_return = self.rename(dir_file["fid"], save_name) # 防止网络问题导致的错误 if isinstance(rename_return, dict) and rename_return.get("code") == 0: rename_log = f"重命名: {dir_file['file_name']} → {save_name}" rename_logs.append(rename_log) # 移除直接打印的部分,由do_save负责打印 # print(rename_log) is_rename_count += 1 # 更新重命名记录到数据库(只更新renamed_to字段) self.update_transfer_record( task=task, file_info=dir_file, renamed_to=save_name ) else: error_msg = rename_return.get("message", "未知错误") rename_log = f"重命名: {dir_file['file_name']} → {save_name} 失败,{error_msg}" rename_logs.append(rename_log) # 移除直接打印的部分,由do_save负责打印 # print(rename_log) except Exception as e: rename_log = f"重命名出错: {dir_file['file_name']} → {save_name},错误: {str(e)}" rename_logs.append(rename_log) # 移除直接打印的部分,由do_save负责打印 # print(rename_log) return is_rename_count > 0, rename_logs # 检查是否为剧集命名模式 elif task.get("use_episode_naming") and task.get("episode_naming"): # 使用剧集命名模式 episode_pattern = task["episode_naming"] regex_pattern = task.get("regex_pattern") # 初始化变量 already_renamed_files = set() # 用于防止重复重命名 # 获取目录文件列表 - 添加这行代码初始化dir_file_list savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}") if not self.savepath_fid.get(savepath): # 路径已存在,直接设置fid savepath_fids = self.get_fids([savepath]) if not savepath_fids: # print(f"保存路径不存在,准备新建:{savepath}") mkdir_result = self.mkdir(savepath) if mkdir_result["code"] == 0: self.savepath_fid[savepath] = mkdir_result["data"]["fid"] # print(f"保存路径新建成功:{savepath}") else: # print(f"保存路径新建失败:{mkdir_result['message']}") return False, [] else: self.savepath_fid[savepath] = savepath_fids[0]["fid"] dir_file_list = self.ls_dir(self.savepath_fid[savepath]) # 构建目标目录中所有文件的查重索引(按大小和修改时间) dir_files_map = {} for dir_file in dir_file_list: if not dir_file["dir"]: # 仅处理文件 file_size = dir_file.get("size", 0) file_ext = os.path.splitext(dir_file["file_name"])[1].lower() update_time = dir_file.get("updated_at", 0) # 创建大小+扩展名的索引,用于快速查重 key = f"{file_size}_{file_ext}" if key not in dir_files_map: dir_files_map[key] = [] dir_files_map[key].append({ "file_name": dir_file["file_name"], "updated_at": update_time, }) # 实现序号提取函数 def extract_episode_number_local(filename): # 使用全局的统一提取函数,直接使用全局CONFIG_DATA if 'CONFIG_DATA' not in globals() or not CONFIG_DATA: return extract_episode_number(filename) return extract_episode_number(filename, config_data=CONFIG_DATA) # 找出已命名的文件列表,避免重复转存 existing_episode_numbers = set() for dir_file in dir_file_list: if not dir_file["dir"]: try: # 对于剧集命名模式,直接使用extract_episode_number函数提取剧集号 episode_num = extract_episode_number_local(dir_file["file_name"]) if episode_num is not None: existing_episode_numbers.add(episode_num) except: pass # 检查是否需要从分享链接获取数据 if task.get("shareurl"): # 如果任务已经有 shareurl_ban,说明分享已失效,不需要再尝试获取分享详情 if task.get("shareurl_ban"): return False, [] try: # 提取链接参数 pwd_id, passcode, pdir_fid, paths = self.extract_url(task["shareurl"]) if not pwd_id: print(f"提取链接参数失败,请检查分享链接是否有效") return False, [] # 获取分享详情 is_sharing, stoken = self.get_stoken(pwd_id, passcode) if not is_sharing: # 如果任务已经有 shareurl_ban,说明已经在 do_save_task 中处理过了,不需要重复输出 if not task.get("shareurl_ban"): print(f"分享详情获取失败: {stoken}") return False, [] # 获取分享文件列表 share_file_list = self.get_detail(pwd_id, stoken, pdir_fid)["list"] if not share_file_list: # 如果任务已经有 shareurl_ban,说明已经在 do_save_task 中处理过了,不需要重复输出 if not task.get("shareurl_ban"): print("分享为空,文件已被分享者删除") return False, [] # 在剧集命名模式中,需要先对文件列表进行排序,然后再应用起始文件过滤 # 使用全局排序函数进行排序(倒序,最新的在前) share_file_list = sorted(share_file_list, key=sort_file_by_name, reverse=True) # 预先过滤分享文件列表,去除已存在的文件 filtered_share_files = [] start_fid = task.get("startfid", "") start_file_found = False for share_file in share_file_list: if share_file["dir"]: # 处理子目录 if task.get("update_subdir") and re.search(task["update_subdir"], share_file["file_name"]): filtered_share_files.append(share_file) continue # 检查文件ID是否存在于转存记录中 file_id = share_file.get("fid", "") if file_id and self.check_file_exists_in_records(file_id, task): # 文件ID已存在于记录中,跳过处理 continue # 改进的起始文件过滤逻辑 if start_fid: if share_file["fid"] == start_fid: start_file_found = True # 找到起始文件,但不包含起始文件本身,只处理比它更新的文件 continue elif start_file_found: # 已经找到起始文件,跳过后续(更旧的)文件 continue # 如果还没找到起始文件,说明当前文件比起始文件更新,需要处理 else: # 没有设置起始文件,处理所有文件 pass # 从共享文件中提取剧集号 episode_num = extract_episode_number_local(share_file["file_name"]) is_duplicate = False # 通过文件名判断是否已存在(新的查重逻辑) if not is_duplicate: # 如果没有重命名,判断原文件名是否已存在 original_name = share_file["file_name"] # 如果有剧集号,判断重命名后的文件名是否已存在 file_ext = os.path.splitext(original_name)[1] # 构建可能的新文件名 if episode_num is not None: if episode_pattern == "[]": new_name = f"{episode_num:02d}{file_ext}" else: new_name = episode_pattern.replace("[]", f"{episode_num:02d}") + file_ext # 应用字幕命名规则 new_name = apply_subtitle_naming_rule(new_name, task) else: new_name = None # 根据是否忽略后缀进行检查 if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名,不比较扩展名 original_name_base = os.path.splitext(original_name)[0] # 检查原文件名是否存在 exists_original = any(os.path.splitext(dir_file["file_name"])[0] == original_name_base for dir_file in dir_file_list) # 如果有新文件名,检查新文件名是否存在 exists_new = False if new_name: new_name_base = os.path.splitext(new_name)[0] exists_new = any(os.path.splitext(dir_file["file_name"])[0] == new_name_base for dir_file in dir_file_list) is_duplicate = exists_original or exists_new else: # 不忽略后缀模式:文件名和扩展名都要一致 exists_original = any(dir_file["file_name"] == original_name for dir_file in dir_file_list) # 如果有新文件名,检查新文件名是否存在 exists_new = False if new_name: exists_new = any(dir_file["file_name"] == new_name for dir_file in dir_file_list) is_duplicate = exists_original or exists_new # 只处理非重复文件 if not is_duplicate: filtered_share_files.append(share_file) # 实现高级排序算法 def sort_by_episode(file): if file["dir"]: return (float('inf'), 0) filename = file["file_name"] # 优先匹配S01E01格式 match_s_e = re.search(r'[Ss](\d+)[Ee](\d+)', filename) if match_s_e: season = int(match_s_e.group(1)) episode = int(match_s_e.group(2)) return (season * 1000 + episode, 0) # 使用统一的剧集提取函数 episode_num = extract_episode_number_local(filename) if episode_num is not None: return (episode_num, 0) # 无法识别,回退到修改时间排序 return (float('inf'), file.get("last_update_at", 0)) # 过滤出文件并排序 files_to_process = [f for f in filtered_share_files if not f["dir"]] sorted_files = sorted(files_to_process, key=sort_by_episode) # 要保存的文件列表 need_save_list = [] # 生成文件名并添加到列表 for share_file in sorted_files: episode_num = extract_episode_number_local(share_file["file_name"]) if episode_num is not None: # 生成新文件名 file_ext = os.path.splitext(share_file["file_name"])[1] if episode_pattern == "[]": # 对于单独的[],直接使用数字序号作为文件名 save_name = f"{episode_num:02d}{file_ext}" else: save_name = episode_pattern.replace("[]", f"{episode_num:02d}") + file_ext # 应用字幕命名规则 save_name = apply_subtitle_naming_rule(save_name, task) # 检查过滤词 should_filter = False if task.get("filterwords"): # 使用高级过滤函数检查文件名 temp_file_list = [{"file_name": share_file["file_name"]}] if advanced_filter_files(temp_file_list, task["filterwords"]): # 检查目标文件名 temp_save_list = [{"file_name": save_name}] if not advanced_filter_files(temp_save_list, task["filterwords"]): should_filter = True else: should_filter = True # 只处理不需要过滤的文件 if not should_filter: # 添加到保存列表 share_file["save_name"] = save_name share_file["original_name"] = share_file["file_name"] need_save_list.append(share_file) else: # 无法提取集号,使用原文件名(仍然检查过滤词) # 检查过滤词 should_filter = False if task.get("filterwords"): # 使用高级过滤函数检查文件名 temp_file_list = [{"file_name": share_file["file_name"]}] if not advanced_filter_files(temp_file_list, task["filterwords"]): should_filter = True # 只处理不需要过滤的文件 if not should_filter: share_file["save_name"] = share_file["file_name"] share_file["original_name"] = share_file["file_name"] need_save_list.append(share_file) # 保存文件 if need_save_list: fid_list = [item["fid"] for item in need_save_list] fid_token_list = [item["share_fid_token"] for item in need_save_list] save_file_return = self.save_file( fid_list, fid_token_list, self.savepath_fid[savepath], pwd_id, stoken ) if save_file_return["code"] == 0: task_id = save_file_return["data"]["task_id"] query_task_return = self.query_task(task_id) if query_task_return["code"] == 0: # 进行重命名操作,确保文件按照预览名称保存 time.sleep(1) # 等待文件保存完成 # 保存转存记录到数据库 for saved_item in need_save_list: if not saved_item.get("dir", False): # 只记录文件,不记录文件夹 # 转存时先用原文件名记录,重命名后再更新 self.create_transfer_record( task=task, file_info=saved_item, renamed_to=saved_item["file_name"] # 转存时使用原文件名 ) # 刷新目录列表以获取新保存的文件 fresh_dir_file_list = self.ls_dir(self.savepath_fid[savepath]) # 创建一个映射来存储原始文件名到保存项的映射 original_name_to_item = {} for saved_item in need_save_list: # 使用文件名前缀作为键,处理可能的文件名变化 file_prefix = saved_item["original_name"].split(".")[0] original_name_to_item[file_prefix] = saved_item # 同时保存完整文件名的映射 original_name_to_item[saved_item["original_name"]] = saved_item # 创建一个列表来收集所有重命名操作 rename_operations = [] # 首先尝试使用剧集号进行智能匹配 for dir_file in fresh_dir_file_list: if dir_file["dir"]: continue # 从文件名中提取剧集号 episode_num = extract_episode_number_local(dir_file["file_name"]) if episode_num is None: continue # 查找对应的目标文件 for saved_item in need_save_list: saved_episode_num = extract_episode_number_local(saved_item["original_name"]) if saved_episode_num == episode_num: # 匹配到对应的剧集号 target_name = saved_item["save_name"] # 确保目标名称不重复 if target_name not in [f["file_name"] for f in fresh_dir_file_list]: # 收集重命名操作而不是立即执行 rename_operations.append((dir_file, target_name, episode_num)) break else: # 如果目标文件名已存在,尝试加上序号 name_base, ext = os.path.splitext(target_name) alt_name = f"{name_base} ({episode_num}){ext}" if alt_name not in [f["file_name"] for f in fresh_dir_file_list]: # 收集重命名操作而不是立即执行 rename_operations.append((dir_file, alt_name, episode_num)) break # 对于未能通过剧集号匹配的文件,尝试使用文件名匹配 for dir_file in fresh_dir_file_list: if dir_file["dir"]: continue # 如果已经有重命名操作,跳过 if any(op[0]["fid"] == dir_file["fid"] for op in rename_operations): continue # 尝试精确匹配 if dir_file["file_name"] in original_name_to_item: saved_item = original_name_to_item[dir_file["file_name"]] target_name = saved_item["save_name"] episode_num = extract_episode_number_local(saved_item["original_name"]) or 9999 if target_name not in [f["file_name"] for f in fresh_dir_file_list]: # 收集重命名操作 rename_operations.append((dir_file, target_name, episode_num)) continue # 尝试模糊匹配(使用文件名前缀) dir_file_prefix = dir_file["file_name"].split(".")[0] for prefix, saved_item in list(original_name_to_item.items()): if prefix in dir_file_prefix or dir_file_prefix in prefix: # 找到相似的文件名 target_name = saved_item["save_name"] episode_num = extract_episode_number_local(saved_item["original_name"]) or 9999 if target_name not in [f["file_name"] for f in fresh_dir_file_list]: # 收集重命名操作 rename_operations.append((dir_file, target_name, episode_num)) original_name_to_item.pop(prefix, None) # 避免重复使用 break # 按剧集号排序重命名操作 rename_operations.sort(key=lambda x: x[2]) # 执行排序后的重命名操作,但不立即打印日志 renamed_count = 0 rename_logs = [] # 收集重命名日志 for dir_file, target_name, _ in rename_operations: rename_result = self.rename(dir_file["fid"], target_name) if rename_result["code"] == 0: # 收集日志但不打印 log_message = f"重命名: {dir_file['file_name']} → {target_name}" rename_logs.append(log_message) renamed_count += 1 # 更新文件列表中的文件名,防止重名判断出错 for df in fresh_dir_file_list: if df["fid"] == dir_file["fid"]: df["file_name"] = target_name break # 不在这里直接调用update_transfer_record,而是在do_save中统一处理 # self.update_transfer_record( # task=task, # file_info=dir_file, # renamed_to=target_name # ) else: # 收集错误日志但不打印 error_log = f"重命名: {dir_file['file_name']} → {target_name} 失败,{rename_result['message']}" rename_logs.append(error_log) # 不要立即返回,继续执行本地文件重命名逻辑 # 更新dir_file_list以包含新转存的文件 dir_file_list = self.ls_dir(self.savepath_fid[savepath]) else: err_msg = query_task_return["message"] add_notify(f"❌《{task['taskname']}》转存失败: {err_msg}\n") else: add_notify(f"❌《{task['taskname']}》转存失败: {save_file_return['message']}\n") else: # print("没有需要保存的新文件") pass except Exception as e: add_notify(f"❌《{task['taskname']}》处理分享链接时发生错误: {str(e)}\n") # 对本地已有文件进行重命名(即使没有分享链接或处理失败也执行) is_rename_count = 0 renamed_files = {} # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) if task.get("filterwords"): # 记录过滤前的文件总数 original_total_count = len(dir_file_list) # 使用高级过滤函数处理保留词和过滤词 dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) # 使用一个列表收集所有需要重命名的操作 rename_operations = [] rename_logs = [] # 收集重命名日志 # 筛选出需要重命名的文件 for dir_file in dir_file_list: if dir_file["dir"]: continue # 检查是否需要重命名 episode_num = extract_episode_number_local(dir_file["file_name"]) if episode_num is not None: # 根据剧集命名模式生成目标文件名 file_ext = os.path.splitext(dir_file["file_name"])[1] if episode_pattern == "[]": # 使用完整的剧集号识别逻辑,而不是简单的纯数字判断 # 生成新文件名 new_name = f"{episode_num:02d}{file_ext}" # 应用字幕命名规则 new_name = apply_subtitle_naming_rule(new_name, task) # 只有当当前文件名与目标文件名不同时才重命名 if dir_file["file_name"] != new_name: rename_operations.append((dir_file, new_name, episode_num)) else: # 生成目标文件名 new_name = episode_pattern.replace("[]", f"{episode_num:02d}") + file_ext # 应用字幕命名规则 new_name = apply_subtitle_naming_rule(new_name, task) # 检查文件名是否已经符合目标格式 if dir_file["file_name"] != new_name: rename_operations.append((dir_file, new_name, episode_num)) # 按剧集号排序 rename_operations.sort(key=lambda x: x[2]) # 执行重命名操作,但不立即打印日志 for dir_file, new_name, _ in rename_operations: # 防止重名,支持忽略后缀选项 name_conflict = False if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名部分,不比较扩展名 new_name_base = os.path.splitext(new_name)[0] name_conflict = any( os.path.splitext(f["file_name"])[0] == new_name_base for f in dir_file_list ) else: # 不忽略后缀模式:完整文件名必须匹配 name_conflict = new_name in [f["file_name"] for f in dir_file_list] if not name_conflict: try: rename_return = self.rename(dir_file["fid"], new_name) if rename_return["code"] == 0: # 收集日志但不打印 rename_logs.append(f"重命名: {dir_file['file_name']} → {new_name}") is_rename_count += 1 # 更新dir_file_list中的文件名,防止后续重名判断出错 for df in dir_file_list: if df["fid"] == dir_file["fid"]: df["file_name"] = new_name break # 记录已重命名的文件 already_renamed_files.add(new_name) # 不在这里直接调用update_transfer_record,而是在do_save中统一处理 # self.update_transfer_record( # task=task, # file_info=dir_file, # renamed_to=new_name # ) else: # 收集错误日志但不打印 error_msg = rename_return.get("message", "未知错误") # 刷新目录列表,检查文件是否实际已重命名成功 fresh_dir_file_list = self.ls_dir(self.savepath_fid[savepath]) target_exists = any(df["file_name"] == new_name for df in fresh_dir_file_list) # 如果目标文件已存在,说明重命名已经成功或有同名文件 if target_exists: # 对于已经成功的情况,我们仍然记录成功 rename_logs.append(f"重命名: {dir_file['file_name']} → {new_name}") is_rename_count += 1 # 更新dir_file_list中的文件名 for df in dir_file_list: if df["fid"] == dir_file["fid"]: df["file_name"] = new_name break # 记录已重命名的文件 already_renamed_files.add(new_name) else: # 真正的错误情况 # 注释掉错误消息记录 # rename_logs.append(f"重命名: {dir_file['file_name']} → {new_name} 失败,{error_msg}") pass except Exception as e: # 收集错误日志但不打印 # 注释掉异常信息记录 # rename_logs.append(f"重命名出错: {dir_file['file_name']} → {new_name},错误:{str(e)}") pass else: # 检查目标文件是否已经存在且是我们想要重命名的结果 # 这可能是因为之前的操作已经成功,但API返回了错误 fresh_dir_file_list = self.ls_dir(self.savepath_fid[savepath]) if any(df["file_name"] == new_name and df["fid"] != dir_file["fid"] for df in fresh_dir_file_list): # 真正存在同名文件 # 注释掉同名文件警告信息记录 # rename_logs.append(f"重命名: {dir_file['file_name']} → {new_name} 失败,目标文件名已存在") pass else: # 目标文件可能是之前操作的结果,不显示错误 pass # 返回重命名日志和成功标志 return (is_rename_count > 0), rename_logs # 正则模式或无特殊命名模式,直接返回空结果 else: # 检查是否有正则模式的配置 pattern = task.get("pattern", "") replace = task.get("replace", "") # 如果没有设置正则匹配模式,直接返回空结果 if not pattern: return False, [] # 获取魔法正则处理后的真实规则 pattern, replace = self.magic_regex_func(pattern, replace, task["taskname"]) # 获取目录文件列表 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}{subdir_path}") if not self.savepath_fid.get(savepath): # 路径不存在,创建或获取fid savepath_fids = self.get_fids([savepath]) if not savepath_fids: # print(f"保存路径不存在,准备新建:{savepath}") mkdir_result = self.mkdir(savepath) if mkdir_result["code"] == 0: self.savepath_fid[savepath] = mkdir_result["data"]["fid"] # print(f"保存路径新建成功:{savepath}") else: # print(f"保存路径新建失败:{mkdir_result['message']}") return False, [] else: self.savepath_fid[savepath] = savepath_fids[0]["fid"] # 获取目录中的文件列表 dir_file_list = self.ls_dir(self.savepath_fid[savepath]) # 应用过滤词过滤(修复bug:为本地文件重命名添加过滤规则) if task.get("filterwords"): # 记录过滤前的文件总数 original_total_count = len(dir_file_list) # 使用高级过滤函数处理保留词和过滤词 dir_file_list = advanced_filter_files(dir_file_list, task["filterwords"]) # 使用一个列表收集所有需要重命名的操作 rename_operations = [] rename_logs = [] # 收集重命名日志 is_rename_count = 0 # 遍历目录中的文件,找出符合正则条件的 for dir_file in dir_file_list: if dir_file["dir"]: continue # 跳过文件夹 # 应用正则表达式 try: # 在应用正则表达式前,先检查文件名是否已经是符合目标格式的 orig_name = dir_file["file_name"] # 应用正则表达式获取目标文件名 new_name = re.sub(pattern, replace, orig_name) # 应用字幕命名规则 new_name = apply_subtitle_naming_rule(new_name, task) # 如果替换后的文件名没有变化,跳过 if new_name == orig_name: continue # 如果替换后的文件名是任务名的重复嵌套,跳过 # 例如:对于"你好,星期六 - 2025-04-05.mp4" -> "你好,星期六 - 你好,星期六 - 2025-04-05.mp4" if " - " in new_name: parts = new_name.split(" - ") # 检查是否有重复的部分 if len(parts) >= 2 and parts[0] == parts[1]: continue # 另一种情况:检查前缀是否已存在于文件名中 prefix = replace.split(" - ")[0] if " - " in replace else "" if prefix and prefix in orig_name: # 如果原始文件名已经包含了需要添加的前缀,跳过重命名 continue # 如果文件名发生变化,需要重命名 if new_name != orig_name: rename_operations.append((dir_file, new_name)) except Exception as e: print(f"正则替换出错: {dir_file['file_name']},错误: {str(e)}") # 按原始文件名字母顺序排序,使重命名操作有序进行 # rename_operations.sort(key=lambda x: x[0]["file_name"]) # 修改为按日期或数字排序(复用与文件树相同的排序逻辑) def extract_sort_value(file_name): # 使用全局排序函数 return sort_file_by_name(file_name) # 按目标文件名中的日期或数字进行排序,与顺序命名和剧集命名模式保持一致 rename_operations.sort(key=lambda x: extract_sort_value(x[1])) # 执行重命名操作,并收集日志 already_renamed_files = set() # 用于防止重复重命名 for dir_file, new_name in rename_operations: # 检查是否会导致重名,支持忽略后缀选项 name_conflict = False if task.get("ignore_extension", False): # 忽略后缀模式:只比较文件名部分,不比较扩展名 new_name_base = os.path.splitext(new_name)[0] name_conflict = any( os.path.splitext(f["file_name"])[0] == new_name_base for f in dir_file_list ) or any( os.path.splitext(existing_name)[0] == new_name_base for existing_name in already_renamed_files ) else: # 不忽略后缀模式:完整文件名必须匹配 name_conflict = new_name in [f["file_name"] for f in dir_file_list] or new_name in already_renamed_files if not name_conflict: try: rename_return = self.rename(dir_file["fid"], new_name) if rename_return["code"] == 0: # 收集日志但不打印 log_message = f"重命名: {dir_file['file_name']} → {new_name}" rename_logs.append(log_message) is_rename_count += 1 # 更新dir_file_list中的文件名,防止后续重名判断出错 for df in dir_file_list: if df["fid"] == dir_file["fid"]: df["file_name"] = new_name break # 记录已重命名的文件 already_renamed_files.add(new_name) else: # 收集错误日志但不打印 error_msg = rename_return.get("message", "未知错误") error_log = f"重命名: {dir_file['file_name']} → {new_name} 失败,{error_msg}" rename_logs.append(error_log) except Exception as e: # 收集错误日志但不打印 error_log = f"重命名出错: {dir_file['file_name']} → {new_name},错误: {str(e)}" rename_logs.append(error_log) else: # 重名警告但不打印 warning_log = f"重命名: {dir_file['file_name']} → {new_name} 失败,目标文件名已存在" rename_logs.append(warning_log) # 返回重命名日志和成功标志 return (is_rename_count > 0), rename_logs def verify_account(account): # 验证账号 print(f"▶️ 验证第 {account.index} 个账号") if "__uid" not in account.cookie: return False else: account_info = account.init() if not account_info: add_notify(f"👤 第 {account.index} 个账号登录失败,cookie 无效 ❌") return False else: print(f"👤 账号昵称: {account_info['nickname']} ✅") return True def format_bytes(size_bytes: int) -> str: units = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB") i = 0 while size_bytes >= 1024 and i < len(units) - 1: size_bytes /= 1024 i += 1 return f"{size_bytes:.2f} {units[i]}" def do_sign(account): if not account.mparam: print("⏭️ 移动端参数未设置,跳过签到") print() return # 每日领空间 growth_info = account.get_growth_info() if growth_info: growth_message = f"💾 {'88VIP' if growth_info['88VIP'] else '普通用户'}: 总空间 {format_bytes(growth_info['total_capacity'])},签到累计获得 {format_bytes(growth_info['cap_composition'].get('sign_reward', 0))}" if growth_info["cap_sign"]["sign_daily"]: sign_message = f"📅 签到记录: 今日已签到 +{int(growth_info['cap_sign']['sign_daily_reward']/1024/1024)}MB,连签进度({growth_info['cap_sign']['sign_progress']}/{growth_info['cap_sign']['sign_target']})✅" message = f"{sign_message}\n{growth_message}" print(message) else: sign, sign_return = account.get_growth_sign() if sign: sign_message = f"📅 执行签到: 今日签到 +{int(sign_return/1024/1024)}MB,连签进度({growth_info['cap_sign']['sign_progress']+1}/{growth_info['cap_sign']['sign_target']})✅" message = f"{sign_message}\n{growth_message}" if ( str( CONFIG_DATA.get("push_config", {}).get("QUARK_SIGN_NOTIFY") ).lower() == "false" or os.environ.get("QUARK_SIGN_NOTIFY") == "false" ): print(message) else: if account.nickname: message = message.replace("今日", f"{account.nickname} 今日") add_notify(message) else: print(f"📅 签到异常: {sign_return}") print() def do_save(account, tasklist=[], ignore_execution_rules=False): print(f"🧩 载入插件") plugins, CONFIG_DATA["plugins"], task_plugins_config = Config.load_plugins( CONFIG_DATA.get("plugins", {}) ) print(f"转存账号: {account.nickname}") # 获取全部保存目录fid account.update_savepath_fid(tasklist) # 创建已发送通知跟踪集合,避免重复显示通知 sent_notices = set() def is_time(task): # 若为手动单任务运行并明确要求忽略执行周期/进度限制,则始终执行 if ignore_execution_rules: return True # 获取任务的执行周期模式,优先使用任务自身的execution_mode,否则使用系统配置的execution_mode execution_mode = task.get("execution_mode") or CONFIG_DATA.get("execution_mode", "manual") # 按任务进度执行(自动) if execution_mode == "auto": try: # 从task_metrics表获取任务进度 cal_db = CalendarDB() task_name = task.get("taskname") or task.get("task_name") or "" if task_name: metrics = cal_db.get_task_metrics(task_name) if metrics and metrics.get("progress_pct") is not None: progress_pct = int(metrics.get("progress_pct", 0)) # 如果任务进度100%,则跳过 if progress_pct >= 100: return False # 如果任务进度不是100%,则需要运行 return True else: # 没有任务进度数据,退回按自选周期执行的逻辑 execution_mode = "manual" except Exception as e: # 获取任务进度失败,退回按自选周期执行的逻辑 execution_mode = "manual" # 按自选周期执行(自选)- 原有逻辑 if execution_mode == "manual": return ( not task.get("enddate") or ( datetime.now().date() <= datetime.strptime(task["enddate"], "%Y-%m-%d").date() ) ) and ( "runweek" not in task # 星期一为0,星期日为6 or (datetime.today().weekday() + 1 in task.get("runweek")) ) # 默认返回True(兼容未知模式) return True # 执行任务 for index, task in enumerate(tasklist): # 检查环境变量获取真实的任务索引(用于显示) if len(tasklist) == 1 and os.environ.get("ORIGINAL_TASK_INDEX"): try: display_index = int(os.environ.get("ORIGINAL_TASK_INDEX")) except (ValueError, TypeError): display_index = index + 1 else: display_index = index + 1 print() print(f"#{str(display_index).zfill(2)}------------------") print(f"任务名称: {task['taskname']}") print(f"分享链接: {task['shareurl']}") print(f"保存路径: {task['savepath']}") # 根据命名模式显示不同信息 if task.get("use_sequence_naming") and task.get("sequence_naming"): print(f"顺序命名: {task['sequence_naming']}") elif task.get("use_episode_naming") and task.get("episode_naming"): print(f"剧集命名: {task['episode_naming']}") else: # 正则命名模式 if task.get("pattern") is not None: # 修改为判断是否为None,而非是否为真值 print(f"正则匹配: {task['pattern']}") if task.get("replace") is not None: # 显示替换规则,即使为空字符串 print(f"正则替换: {task['replace']}") if task.get("update_subdir"): print(f"更新目录: {task['update_subdir']}") # 获取任务的执行周期模式,优先使用任务自身的execution_mode,否则使用系统配置的execution_mode execution_mode = task.get("execution_mode") or CONFIG_DATA.get("execution_mode", "manual") # 根据执行周期模式显示日志 if execution_mode == "auto": # 按任务进度执行(自动) try: # 检查是否有任务进度数据 cal_db = CalendarDB() task_name = task.get("taskname") or task.get("task_name") or "" if task_name: metrics = cal_db.get_task_metrics(task_name) if metrics and metrics.get("progress_pct") is not None: # 有任务进度数据,显示自动模式 print(f"执行周期: 自动") else: # 没有任务进度数据,退回按自选周期执行,显示自选周期信息 if task.get("runweek") or task.get("enddate"): print( f"执行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')} (自动模式,无进度数据,退回自选周期)" ) else: print(f"执行周期: 自动 (无进度数据,退回自选周期)") else: # 没有任务名称,退回按自选周期执行 if task.get("runweek") or task.get("enddate"): print( f"执行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')} (自动模式,无任务名称,退回自选周期)" ) else: print(f"执行周期: 自动 (无任务名称,退回自选周期)") except Exception: # 获取任务进度失败,退回按自选周期执行 if task.get("runweek") or task.get("enddate"): print( f"执行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')} (自动模式,获取进度失败,退回自选周期)" ) else: print(f"执行周期: 自动 (获取进度失败,退回自选周期)") else: # 按自选周期执行(自选)- 原有逻辑 if task.get("runweek") or task.get("enddate"): print( f"执行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')}" ) print() # 判断任务周期 if not is_time(task): if execution_mode == "auto": # 按任务进度执行时的跳过提示 try: cal_db = CalendarDB() task_name = task.get("taskname") or task.get("task_name") or "" if task_name: metrics = cal_db.get_task_metrics(task_name) if metrics and metrics.get("progress_pct") is not None: progress_pct = int(metrics.get("progress_pct", 0)) print(f"任务进度已达 {progress_pct}%,跳过") else: print(f"任务不在执行周期内,跳过") else: print(f"任务不在执行周期内,跳过") except Exception: print(f"任务不在执行周期内,跳过") else: # 按自选周期执行时的跳过提示(原有逻辑) print(f"任务不在执行周期内,跳过") else: # 保存之前的通知信息 global NOTIFYS notifys_before = NOTIFYS.copy() # 执行保存任务 is_new_tree = account.do_save_task(task) # 检查是否需要清除重复的通知 # 由于在修改顺序,现在不需要特殊处理通知 is_special_sequence = (task.get("use_sequence_naming") and task.get("sequence_naming")) or (task.get("use_episode_naming") and task.get("episode_naming")) # 对于正则命名模式,也将其视为特殊序列 is_regex_mode = not is_special_sequence and task.get("pattern") is not None # 执行重命名任务,但收集日志而不是立即打印 is_rename, rename_logs = account.do_rename_task(task) # 处理子目录重命名 - 如果配置了更新目录且使用正则命名模式 if task.get("update_subdir") and task.get("pattern") is not None: # 获取保存路径 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") if account.savepath_fid.get(savepath): # 递归处理所有符合条件的子目录 def process_subdirs(current_path, relative_path=""): # 获取当前目录的文件列表 current_fid = account.savepath_fid.get(current_path) if not current_fid: # 尝试获取目录的fid path_fids = account.get_fids([current_path]) if path_fids: current_fid = path_fids[0]["fid"] account.savepath_fid[current_path] = current_fid else: print(f"无法获取目录fid: {current_path}") return current_dir_files = account.ls_dir(current_fid) # 处理当前目录 if relative_path: # 对当前目录执行重命名操作 subtask = task.copy() if not subtask.get("pattern"): subtask["pattern"] = ".*" subtask["replace"] = "" subdir_is_rename, subdir_rename_logs = account.do_rename_task(subtask, relative_path) # 合并日志 if subdir_is_rename and subdir_rename_logs: clean_logs = [] for log in subdir_rename_logs: if "失败" not in log: clean_logs.append(log) rename_logs.extend(clean_logs) nonlocal is_rename is_rename = is_rename or subdir_is_rename # 找出符合更新目录规则的子目录 subdirs = [] for file in current_dir_files: if file["dir"]: # 如果是根目录,检查是否符合更新目录的规则 if not relative_path and re.search(task["update_subdir"], file["file_name"]): subdirs.append(file) # 如果已经在某个更新目录内,则所有子目录都需要处理 elif relative_path: subdirs.append(file) # 对每个子目录递归调用 for subdir in subdirs: # 构建子目录完整路径 subdir_full_path = f"{current_path}/{subdir['file_name']}" subdir_relative_path = f"{relative_path}/{subdir['file_name']}" # 递归处理子目录 process_subdirs(subdir_full_path, subdir_relative_path) # 从根目录开始递归处理 process_subdirs(savepath) # 简化日志处理 - 只保留成功的重命名消息 if rename_logs: success_logs = [] for log in rename_logs: if "失败" not in log: success_logs.append(log) # 完全替换日志,只显示成功部分 rename_logs = success_logs # 对于顺序命名模式,需要重新创建文件树以显示实际的文件名 # 这确保显示的是实际存在的文件名,而不是预期的文件名 if task.get("shareurl") and rename_logs and is_rename and (task.get("use_sequence_naming") or task.get("use_episode_naming")): # 获取当前目录 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") if account.savepath_fid.get(savepath): # 创建新的Tree对象 new_tree = Tree() # 创建根节点 new_tree.create_node( savepath, "root", data={ "is_dir": True, }, ) # 获取实际的文件名映射 actual_file_names = account.get_actual_file_names_from_directory(task, rename_logs) # 从重命名日志中提取预期的文件名映射 expected_renamed_files = {} for log in rename_logs: # 格式:重命名: 旧名 → 新名 match = re.search(r'重命名: (.*?) → (.+?)($|\s|,|失败)', log) if match: old_name = match.group(1).strip() expected_new_name = match.group(2).strip() expected_renamed_files[old_name] = expected_new_name # 获取文件列表,只添加重命名的文件 fresh_dir_file_list = account.ls_dir(account.savepath_fid[savepath]) # 添加实际存在的文件到树中 for file in fresh_dir_file_list: if not file["dir"]: # 只处理文件 # 检查这个文件是否是当次转存的文件 is_transferred_file = False # 方法1:检查文件名是否在实际重命名结果中 if file["file_name"] in actual_file_names.values(): is_transferred_file = True # 方法2:检查文件名是否在预期重命名结果中(用于重命名失败的情况) elif file["file_name"] in expected_renamed_files.values(): is_transferred_file = True # 方法3:检查文件名是否是原始文件名(重命名失败保持原名) elif file["file_name"] in expected_renamed_files.keys(): is_transferred_file = True if is_transferred_file: new_tree.create_node( file["file_name"], # 使用实际存在的文件名 file["fid"], parent="root", data={ "is_dir": False, "path": f"{savepath}/{file['file_name']}", }, ) # 如果树的大小大于1(有文件),则设置为新的Tree对象 if new_tree.size() > 1: is_new_tree = new_tree # 添加生成文件树的功能(无论是否是顺序命名模式) # 如果is_new_tree返回了Tree对象,则打印文件树 if is_new_tree and isinstance(is_new_tree, Tree) and is_new_tree.size() > 1: # 获取所有文件(非目录)节点 file_nodes = [node for node in is_new_tree.all_nodes_itr() if node.data.get("is_dir") == False] # 计算文件数量 file_count = len(file_nodes) # 创建一个映射列表,包含需要显示的文件名 display_files = [] # 检查是否有更新目录,并获取目录下的文件 update_subdir = task.get("update_subdir") # 如果有设置更新目录,查找对应目录下的文件 if update_subdir: # 检查文件树中是否有更新目录的节点 update_dir_nodes = [] update_subdir_files = [] # 遍历所有节点,查找对应的目录节点 for node in is_new_tree.all_nodes_itr(): if node.data.get("is_dir", False) and node.tag.lstrip("📁") == update_subdir: update_dir_nodes.append(node) # 如果找到更新目录节点,收集其子节点 if update_dir_nodes: for dir_node in update_dir_nodes: # 获取目录节点下的所有文件子节点 for node in is_new_tree.all_nodes_itr(): if (not node.data.get("is_dir", False) and node.predecessor == dir_node.identifier): update_subdir_files.append(node) # 按文件名排序 if is_special_sequence: # 对于顺序命名模式,直接使用文件树中的实际文件名 if rename_logs: # 直接显示文件树中的实际文件名(已经是重命名后的结果) # 按照文件名中的序号进行排序 def extract_sequence_number(node): filename = remove_file_icons(node.tag) # 尝试从文件名中提取序号,如 "乘风2025 - S06E01.flac" -> 1 import re match = re.search(r'E(\d+)', filename) if match: return int(match.group(1)) # 如果没有找到E序号,尝试其他模式 match = re.search(r'(\d+)', filename) if match: return int(match.group(1)) return 0 # 按序号排序 sorted_nodes = sorted(file_nodes, key=extract_sequence_number) for node in sorted_nodes: # 获取实际文件名(去除已有图标) actual_filename = remove_file_icons(node.tag) # 获取适当的图标 icon = get_file_icon(actual_filename, is_dir=node.data.get("is_dir", False)) # 添加到显示列表 display_files.append((f"{icon} {actual_filename}", node)) else: # 如果没有重命名日志,使用原来的顺序命名逻辑 if task.get("use_sequence_naming") and task.get("sequence_naming"): # 顺序命名模式预览 sequence_pattern = task["sequence_naming"] # 对于每个文件,生成其重命名后的名称 for i, node in enumerate(file_nodes): # 提取序号(从1开始) file_num = i + 1 # 获取原始文件的扩展名 orig_filename = remove_file_icons(node.tag) file_ext = os.path.splitext(orig_filename)[1] # 生成新的文件名(使用顺序命名模式) if sequence_pattern == "{}": # 对于单独的{},直接使用数字序号作为文件名 new_filename = f"{file_num:02d}{file_ext}" else: new_filename = sequence_pattern.replace("{}", f"{file_num:02d}") + file_ext # 获取适当的图标 icon = get_file_icon(orig_filename, is_dir=node.data.get("is_dir", False)) # 添加到显示列表 display_files.append((f"{icon} {new_filename}", node)) # 按数字排序 display_files.sort(key=lambda x: int(os.path.splitext(remove_file_icons(x[0]))[0]) if os.path.splitext(remove_file_icons(x[0]))[0].isdigit() else float('inf')) # 对于剧集命名模式 elif task.get("use_episode_naming") and task.get("episode_naming"): # 从重命名日志提取新旧文件名 (备用) renamed_files = {} for log in rename_logs: # 格式:重命名: 旧名 → 新名 match = re.search(r'重命名: (.*?) → (.+?)($|\s|,|失败)', log) if match: old_name = match.group(1).strip() new_name = match.group(2).strip() renamed_files[old_name] = new_name # 使用已知的剧集命名模式来生成新文件名 episode_pattern = task["episode_naming"] # 创建剧集号提取函数 def extract_episode_number_local(filename): # 使用全局的统一提取函数 return extract_episode_number(filename, episode_patterns=account.episode_patterns) # 只显示重命名的文件 for node in file_nodes: # 获取原始文件名(去除已有图标) orig_filename = remove_file_icons(node.tag) # 检查此文件是否在重命名日志中 if orig_filename in renamed_files: # 使用重命名后的文件名 new_filename = renamed_files[orig_filename] # 获取适当的图标 icon = get_file_icon(new_filename, is_dir=node.data.get("is_dir", False)) # 添加到显示列表 display_files.append((f"{icon} {new_filename}", node)) # 如果没有找到任何文件要显示,使用原始文件名 if not display_files: for node in sorted(file_nodes, key=lambda node: node.tag): # 获取原始文件名(去除已有图标) orig_filename = remove_file_icons(node.tag) # 优先使用存储在data中的图标,否则重新计算 icon = node.data.get("icon") if node.data else get_file_icon(orig_filename, is_dir=node.data.get("is_dir", False) if node.data else False) display_files.append((f"{icon} {orig_filename}", node)) else: # 其他模式:显示原始文件名 display_files = [] # 获取所有节点(包括目录节点) all_nodes = [node for node in is_new_tree.all_nodes_itr()] # 获取保存路径的最后一部分目录名(如"/测试/魔法"取"魔法") save_path_basename = os.path.basename(task.get("savepath", "").rstrip("/")) # 创建一个保存目录结构的字典 dir_structure = {"root": []} # 首先处理所有目录节点,构建目录结构 dir_nodes = [node for node in all_nodes if node.data and node.data.get("is_dir", False) and node.identifier != "root"] # 如果有设置更新目录但在树中没找到,尝试查找目录 update_dir_node = None if update_subdir: for node in dir_nodes: name = node.tag.lstrip("📁") if name == update_subdir: update_dir_node = node break # 如果树中没有找到更新目录节点,但设置了更新目录,尝试单独处理 if not update_dir_node: savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") update_subdir_path = f"{savepath}/{update_subdir}" # 检查更新目录是否存在于账户的目录缓存中 if update_subdir_path in account.savepath_fid: # 获取此目录下的文件,单独处理显示 try: update_subdir_fid = account.savepath_fid[update_subdir_path] update_subdir_files = account.ls_dir(update_subdir_fid) # 如果目录存在但没有在文件树中,这可能是首次执行的情况 # 添加一个虚拟目录节点到树中 class VirtualDirNode: def __init__(self, name, dir_id): self.tag = f"📁{name}" self.identifier = dir_id self.data = {"is_dir": True} # 创建虚拟目录节点 update_dir_node = VirtualDirNode(update_subdir, f"virtual_{update_subdir}") dir_nodes.append(update_dir_node) # 为每个文件创建虚拟节点 virtual_file_nodes = [] for file in update_subdir_files: if not file.get("dir", False): class VirtualFileNode: def __init__(self, name, file_id, dir_id): self.tag = name self.identifier = file_id self.predecessor = dir_id self.data = {"is_dir": False} # 创建虚拟文件节点,关联到虚拟目录 virtual_node = VirtualFileNode( file["file_name"], f"virtual_file_{file['fid']}", update_dir_node.identifier ) virtual_file_nodes.append(virtual_node) # 将虚拟文件节点添加到all_nodes if virtual_file_nodes: all_nodes.extend(virtual_file_nodes) except Exception as e: print(f"获取更新目录信息时出错: {str(e)}") # 恢复目录结构构建 for node in sorted(dir_nodes, key=lambda node: node.tag): # 获取原始文件名(去除已有图标) orig_filename = node.tag.lstrip("📁") # 确保只显示目录名,而不是完整路径 if "/" in orig_filename: # 只取路径的最后一部分作为显示名 orig_filename = orig_filename.split("/")[-1] # 跳过与保存路径目录名相同的目录 if orig_filename == save_path_basename: continue # 获取父节点ID parent_id = node.predecessor if hasattr(node, 'predecessor') and node.predecessor != "root" else "root" # 确保父节点键存在于字典中 if parent_id not in dir_structure: dir_structure[parent_id] = [] # 添加目录节点到结构中 dir_structure[parent_id].append({ "id": node.identifier, "name": f"📁{orig_filename}", "is_dir": True }) # 为该目录创建一个空列表,用于存放其子项 dir_structure[node.identifier] = [] # 然后处理所有文件节点和虚拟文件节点 all_file_nodes = [node for node in all_nodes if hasattr(node, 'data') and not node.data.get("is_dir", False)] for node in sorted(all_file_nodes, key=lambda node: node.tag): # 获取原始文件名(去除已有图标) orig_filename = remove_file_icons(node.tag) # 添加适当的图标 icon = get_file_icon(orig_filename, is_dir=False) # 获取父节点ID parent_id = node.predecessor if hasattr(node, 'predecessor') and node.predecessor != "root" else "root" # 确保父节点键存在于字典中 if parent_id not in dir_structure: dir_structure[parent_id] = [] # 添加文件节点到结构中 dir_structure[parent_id].append({ "id": node.identifier, "name": f"{icon} {orig_filename}", "is_dir": False }) # 添加成功通知,带文件数量图标 # 这个通知会在下面的新逻辑中添加,这里注释掉 # add_notify(f"✅《{task['taskname']}》新增文件:") # add_notify(f"/{task['savepath']}") # 移除调试信息 # 获取更新目录名称 update_subdir = task.get("update_subdir") # 创建一个列表来存储所有匹配的更新目录节点及其文件 update_dir_nodes = [] files_by_dir = {} # 默认根目录 files_by_dir["root"] = [] # 查找所有匹配的更新目录节点 dir_nodes = [node for node in is_new_tree.all_nodes_itr() if node.data.get("is_dir") == True and node.identifier != "root"] if update_subdir: for node in dir_nodes: dir_name = node.tag.lstrip("📁") # 使用正则表达式匹配目录名 if re.search(update_subdir, dir_name): update_dir_nodes.append(node) # 为每个匹配的目录创建空文件列表 files_by_dir[node.identifier] = [] # 从文件的路径信息中提取父目录 for node in file_nodes: if hasattr(node, 'data') and node.data and 'path' in node.data: path = node.data['path'] path_parts = path.strip('/').split('/') # 确定文件应该属于哪个目录 if len(path_parts) > 1 and update_subdir: # 获取保存路径 save_path = task.get("savepath", "").rstrip("/") save_path_parts = save_path.split("/") save_path_basename = save_path_parts[-1] if save_path_parts else "" # 去除路径中的保存路径部分,只保留相对路径 # 首先查找保存路径在完整路径中的位置 relative_path_start = 0 for i, part in enumerate(path_parts): if i < len(path_parts) - 1 and part == save_path_basename: relative_path_start = i + 1 break # 提取相对路径部分,不含文件名 relative_path_parts = path_parts[relative_path_start:-1] # 如果没有相对路径部分(直接在根目录下的文件) if not relative_path_parts: files_by_dir["root"].append(node) continue # 检查第一级子目录是否是更新目录之一 first_level_dir = relative_path_parts[0] parent_found = False for dir_node in update_dir_nodes: dir_name = dir_node.tag.lstrip("📁") # 只看第一级目录是否匹配更新目录规则 if dir_name == first_level_dir: # 文件属于此更新目录,直接添加到对应目录下 files_by_dir[dir_node.identifier].append(node) parent_found = True # 为了在树显示中定位该文件,设置完整路径信息 if len(relative_path_parts) > 1: # 如果是多级嵌套目录,设置嵌套路径标记 node.nested_path = "/".join(relative_path_parts[1:]) break if not parent_found: # 尝试检查文件的直接父目录 parent_dir_name = path_parts[-2] for dir_node in update_dir_nodes: dir_name = dir_node.tag.lstrip("📁") if parent_dir_name == dir_name: files_by_dir[dir_node.identifier].append(node) parent_found = True break if not parent_found: # 如果没有找到匹配的父目录,将文件添加到根目录 files_by_dir["root"].append(node) else: # 文件属于根目录 files_by_dir["root"].append(node) else: # 没有路径信息,默认添加到根目录 files_by_dir["root"].append(node) # 排序函数,使用文件节点作为输入 def sort_nodes(nodes): return sorted(nodes, key=lambda node: sort_file_by_name(remove_file_icons(node.tag))) # 初始化最终显示文件的字典 final_display_files = { "root": [], "subdirs": {} } # 创建集合来跟踪当次新增的文件和文件夹 current_added_files = set() current_added_dirs = set() # 跟踪子目录和根目录是否有新增文件 has_update_in_root = False has_update_in_subdir = False # 记录是否是首次执行 is_first_run = True if task.get("last_run_time"): is_first_run = False # 先检查是否有新增的目录 for dir_node in dir_nodes: dir_name = dir_node.tag.lstrip("📁") # 检查是否是指定的更新目录 if update_subdir and re.search(update_subdir, dir_name): current_added_dirs.add(dir_name) has_update_in_subdir = True # 检查根目录是否有新增文件 root_new_files = [] for node in files_by_dir["root"]: file_name = remove_file_icons(node.tag) # 判断是否为新增文件 is_new_file = False # 1. 检查是否在当前转存的文件列表中 if hasattr(node, 'data') and 'created_at' in node.data: current_time = int(time.time()) time_threshold = 600 # 10分钟 = 600秒 if current_time - node.data['created_at'] < time_threshold: is_new_file = True # 2. 节点本身是从当次转存的文件树中获取的 elif hasattr(is_new_tree, 'nodes') and node.identifier in is_new_tree.nodes: is_new_file = True # 3. 首次运行任务时,视为所有文件都是新增的 elif is_first_run: is_new_file = True if is_new_file: root_new_files.append(node) current_added_files.add(file_name) has_update_in_root = True # 记录到最终显示的文件列表中 final_display_files["root"].append(node) # 创建多目录下的更新文件字典 subdir_new_files = {} # 首次运行时,记录哪些子目录是新添加的 new_added_dirs = set() # 对每个已识别的更新目录,检查是否有新文件 for dir_node in update_dir_nodes: dir_name = dir_node.tag.lstrip("📁") dir_id = dir_node.identifier dir_files = files_by_dir.get(dir_id, []) # 初始化该目录的新文件列表 subdir_new_files[dir_id] = [] # 首次运行时,记录所有符合更新目录规则的目录 if is_first_run: new_added_dirs.add(dir_name) has_update_in_subdir = True # 检查该目录下的文件是否是新文件 for file_node in dir_files: file_name = remove_file_icons(file_node.tag) # 判断是否为新增文件 is_new_file = False if hasattr(file_node, 'data') and 'created_at' in file_node.data: current_time = int(time.time()) time_threshold = 600 # 10分钟 = 600秒 if current_time - file_node.data['created_at'] < time_threshold: is_new_file = True elif hasattr(is_new_tree, 'nodes') and file_node.identifier in is_new_tree.nodes: is_new_file = True # 首次运行任务时,视为所有文件都是新增的 elif is_first_run: is_new_file = True if is_new_file: subdir_new_files[dir_id].append(file_node) current_added_files.add(file_name) has_update_in_subdir = True # 记录到最终显示的文件列表中 if dir_id not in final_display_files["subdirs"]: final_display_files["subdirs"][dir_id] = [] final_display_files["subdirs"][dir_id].append(file_node) # 如果已识别的目录中没有新增文件,尝试进一步查找符合update_subdir规则的目录 if update_subdir and not has_update_in_subdir: savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") # 获取保存路径下的所有目录 try: all_dirs = account.ls_dir(account.savepath_fid[savepath]) # 筛选出符合更新规则的目录 for dir_item in all_dirs: if dir_item.get("dir", False): dir_name = dir_item["file_name"] if re.search(update_subdir, dir_name): # 检查该目录是否已经处理过 if not any(node.tag.lstrip("📁") == dir_name for node in update_dir_nodes): # 获取目录的文件列表 update_subdir_path = f"{savepath}/{dir_name}" if update_subdir_path in account.savepath_fid: try: update_subdir_fid = account.savepath_fid[update_subdir_path] update_subdir_files = account.ls_dir(update_subdir_fid) # 创建虚拟目录节点 class VirtualDirNode: def __init__(self, name, dir_id): self.tag = f"📁{name}" self.identifier = dir_id self.data = {"is_dir": True} # 创建虚拟目录节点 virtual_dir_node = VirtualDirNode(dir_name, f"virtual_{dir_name}") update_dir_nodes.append(virtual_dir_node) # 初始化新文件列表 subdir_new_files[virtual_dir_node.identifier] = [] # 为每个文件创建虚拟节点 for file in update_subdir_files: if not file.get("dir", False): class VirtualFileNode: def __init__(self, name, file_id, dir_id): self.tag = name self.identifier = file_id self.predecessor = dir_id self.data = {"is_dir": False} # 创建虚拟文件节点,关联到虚拟目录 virtual_node = VirtualFileNode( file["file_name"], f"virtual_file_{file['fid']}", virtual_dir_node.identifier ) # 首次运行时,将所有文件视为新文件 if is_first_run: subdir_new_files[virtual_dir_node.identifier].append(virtual_node) has_update_in_subdir = True except Exception as e: print(f"获取目录 {dir_name} 文件列表时出错: {str(e)}") except Exception as e: print(f"获取目录列表时出错: {str(e)}") # 对所有目录中的文件进行排序 for dir_id in subdir_new_files: if subdir_new_files[dir_id]: subdir_new_files[dir_id] = sort_nodes(subdir_new_files[dir_id]) # 记录本次执行时间 task["last_run_time"] = int(time.time()) # 显示文件树规则: # 1. 只有根目录有更新:只显示根目录 # 2. 只有子目录有更新:只显示子目录 # 3. 根目录和子目录都有更新:显示两者 # 4. 都没有更新:不显示任何内容 # 如果没有任何更新,不显示任何内容 if not has_update_in_root and not has_update_in_subdir: # 不添加任何通知 pass else: # 添加基本通知 add_notify(f"✅《{task['taskname']}》新增文件:") savepath = task['savepath'] add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 修正首次运行时对子目录的处理 - 只有在首次运行且有新增的子目录时才显示子目录内容 if has_update_in_root and has_update_in_subdir and is_first_run and len(new_added_dirs) == 0: # 虽然标记为首次运行,但没有新增子目录,不应展示子目录内容 has_update_in_subdir = False # 构建完整的目录树结构(支持多层级嵌套) def build_directory_tree(): # 创建目录树结构 dir_tree = {"root": {"dirs": {}, "files": []}} # 获取保存路径的最后一部分目录名(用于过滤) save_path = task.get("savepath", "").rstrip("/") save_path_parts = save_path.split("/") save_path_basename = save_path_parts[-1] if save_path_parts else "" # 处理所有目录节点 for dir_node in update_dir_nodes: dir_id = dir_node.identifier dir_name = dir_node.tag.lstrip("📁") # 跳过与保存路径相同的目录 if dir_name == save_path_basename or dir_name == save_path: continue # 如果目录名包含完整路径,检查是否与保存路径相关 if "/" in dir_name: # 检查是否以路径开头 if dir_name.startswith("/"): continue # 检查是否是保存路径或其子路径 path_parts = dir_name.split("/") # 跳过包含保存路径的完整路径 if any(part == save_path_basename for part in path_parts): continue # 分割路径,处理可能的多级目录 path_parts = dir_name.split("/") # 从根节点开始构建路径 current = dir_tree["root"] for i, part in enumerate(path_parts): # 跳过空部分和保存路径部分 if not part or part == save_path_basename: continue if part not in current["dirs"]: # 创建新的目录节点 current["dirs"][part] = { "dirs": {}, "files": [], "dir_id": dir_id if i == len(path_parts) - 1 else None } current = current["dirs"][part] # 添加该目录的文件 if dir_id in subdir_new_files: # 处理嵌套目录下的文件 nested_files = [] non_nested_files = [] for file_node in subdir_new_files[dir_id]: if hasattr(file_node, 'nested_path') and file_node.nested_path: # 处理嵌套文件 nested_path_parts = file_node.nested_path.split('/') # 创建或获取子目录结构 sub_current = current for i, part in enumerate(nested_path_parts): if part not in sub_current["dirs"]: # 创建新的子目录结构 sub_current["dirs"][part] = { "dirs": {}, "files": [], "dir_id": None # 虚拟目录暂时没有ID } sub_current = sub_current["dirs"][part] # 添加文件到嵌套目录 sub_current["files"].append(file_node) else: # 不是嵌套的文件,直接添加到当前目录 non_nested_files.append(file_node) # 设置当前目录的非嵌套文件 current["files"] = non_nested_files # 添加根目录文件 if has_update_in_root and final_display_files["root"]: dir_tree["root"]["files"] = final_display_files["root"] return dir_tree # 递归显示目录树 def display_tree(node, prefix="", is_last=True, depth=0): # 获取目录和文件列表 dirs = sorted(node["dirs"].items()) files = node.get("files", []) # 根目录文件特殊处理(如果路径以"/"开头,检查是否和当前保存路径相关) save_path = task.get("savepath", "").rstrip("/") save_path_parts = save_path.split("/") save_path_basename = save_path_parts[-1] if save_path else "" # 过滤目录 - 如果只有根目录有更新,且不是首次运行或没有新增目录,则不显示子目录 if depth == 0 and has_update_in_root and (not has_update_in_subdir or (is_first_run and len(new_added_dirs) == 0)): # 在根目录级别,如果只有根目录有更新,则过滤掉所有子目录 dirs = [] # 计算总项数(目录+文件) total_items = len(dirs) + len(files) current_item = 0 # 处理目录 for i, (dir_name, dir_data) in enumerate(dirs): # 检查目录是否在新增目录列表中或有子文件更新 if is_first_run and dir_name not in new_added_dirs and len(dir_data.get("files", [])) == 0: continue current_item += 1 is_dir_last = current_item == total_items # 过滤条件: # 1. 目录名不为空 # 2. 不是保存路径本身 # 3. 不是以"/"开头的完整路径 # 4. 不是保存路径的基本名称 if (dir_name and dir_name != save_path and not dir_name.startswith("/") and dir_name != save_path_basename): dir_prefix = prefix + ("└── " if is_dir_last else "├── ") add_notify(format_file_display(dir_prefix, "📁", dir_name)) # 计算子项的前缀,保持树形结构清晰 # 第一个缩进标记使用点号,后续使用空格 if prefix == "": # 第一层缩进使用点号开头 new_prefix = "· " if is_dir_last else "│ " else: # 后续层级开头保持前缀不变,尾部添加新的缩进标记 if is_dir_last: new_prefix = prefix + " " # 最后一项,空格缩进 else: new_prefix = prefix + "│ " # 非最后一项,使用竖线 # 递归显示子目录 display_tree(dir_data, new_prefix, is_dir_last, depth + 1) # 处理文件 sorted_files = sort_nodes(files) if files else [] for j, file_node in enumerate(sorted_files): current_item += 1 is_file_last = current_item == total_items # 显示文件 file_prefix = prefix + ("└── " if is_file_last else "├── ") file_name = remove_file_icons(file_node.tag) icon = get_file_icon(file_name, is_dir=False) add_notify(format_file_display(file_prefix, icon, file_name)) # 构建并显示目录树 if has_update_in_root or has_update_in_subdir: directory_tree = build_directory_tree() display_tree(directory_tree["root"]) add_notify("") # 处理重命名日志,避免显示不必要的路径信息 # 注意:重命名日志将在文件树显示后统一处理和打印 add_notify("") # 如果是剧集命名模式并且成功进行了重命名,单独显示排序好的文件列表 elif is_rename and task.get("use_episode_naming") and task.get("episode_naming"): # 重新获取文件列表 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") dir_file_list = account.ls_dir(account.savepath_fid[savepath]) # 过滤出非目录的文件 file_nodes = [f for f in dir_file_list if not f["dir"]] # 获取实际的文件名映射 actual_file_names = account.get_actual_file_names_from_directory(task, rename_logs) # 从重命名日志提取预期的新旧文件名映射 expected_renamed_files = {} for log in rename_logs: # 格式:重命名: 旧名 → 新名 if "重命名:" in log and " → " in log: # 先分割出"重命名:"后面的部分 parts = log.split("重命名:", 1)[1].strip() # 再按箭头分割 if " → " in parts: old_name, expected_new_name = parts.split(" → ", 1) # 只处理失败信息,不截断正常文件名 if " 失败," in expected_new_name: expected_new_name = expected_new_name.split(" 失败,")[0] # 去除首尾空格 old_name = old_name.strip() expected_new_name = expected_new_name.strip() expected_renamed_files[old_name] = expected_new_name # 确保至少显示实际存在的文件 display_files = [] # 添加所有实际存在的转存文件 for old_name in expected_renamed_files.keys(): # 获取实际的文件名 actual_name = actual_file_names.get(old_name, expected_renamed_files[old_name]) # 检查文件是否实际存在于目录中 if any(f["file_name"] == actual_name for f in file_nodes): if actual_name not in display_files: display_files.append(actual_name) # 此外,检查是否有新的文件节点(比较节点时间) if not display_files and is_new_tree and hasattr(is_new_tree, 'nodes'): # 如果有转存文件树,从中提取文件 tree_nodes = is_new_tree.nodes.values() for node in tree_nodes: if hasattr(node, 'data') and not node.data.get('is_dir', False): file_path = node.data.get('path', '') if file_path: file_name = os.path.basename(file_path) if file_name not in display_files: display_files.append(file_name) # 还需要检查是否有打印到控制台的转存文件信息(情况:转存后立即重命名) # 无论display_files是否为空都执行此代码,确保能提取到重命名的文件 for log in rename_logs: if "重命名:" in log and " → " in log: parts = log.split(" → ", 1) if len(parts) > 1: new_name = parts[1].strip() # 过滤掉可能的结束标记,但要确保完整保留文件名 if "\n" in new_name: new_name = new_name.split("\n")[0].strip() # 只有当文件名包含明确的分隔符时才进行分割 # 例如"黑镜 - S07E02.mkv"不应该被分割 if "," in new_name: new_name = new_name.split(",")[0].strip() # 确保不要错误地只提取文件名的一部分(如"黑镜") if " " in new_name and "." in new_name: # 如果包含空格和扩展名 # 检查这是不是一个完整文件名 if re.search(r'\.\w+$', new_name): # 检查是否以扩展名结尾 # 这是一个完整的文件名,不做进一步分割 pass else: # 不是以扩展名结尾,可能需要进一步处理 new_name = new_name.split(" ")[0].strip() if new_name and new_name not in display_files: # 额外检查,确保提取的是完整文件名 if "." in new_name: # 通常文件名应包含扩展名 display_files.append(new_name) # 如果通过重命名和文件树都没找到文件,使用最新时间排序的文件 if not display_files and file_nodes: # 查找目录中修改时间最新的文件(可能是刚刚转存的) today = datetime.now().strftime('%Y-%m-%d') recent_files = [] # 定义并初始化recent_files变量 # 首先尝试通过修改日期过滤当天的文件 for file in file_nodes: # 如果有时间戳,转换为日期字符串 if 'updated_at' in file and file['updated_at']: try: # 检查时间戳是否在合理范围内 (1970-2100年) timestamp = file['updated_at'] if timestamp > 4102444800: # 2100年的时间戳 # 可能是毫秒级时间戳,尝试转换为秒级 timestamp = timestamp / 1000 # 再次检查时间戳是否在合理范围内 if 0 < timestamp < 4102444800: update_time = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d') if update_time == today: recent_files.append(file) else: print(f"警告: 文件 {file.get('file_name', '未知')} 的时间戳 {file['updated_at']} 超出范围") except (ValueError, OSError, OverflowError) as e: print(f"警告: 处理文件 {file.get('file_name', '未知')} 的时间戳时出错: {e}") # 如果没有找到当天的文件,至少显示一个最新的文件 if not recent_files and file_nodes: # 定义安全的排序键函数 def safe_timestamp_key(x): try: timestamp = x.get('updated_at', 0) # 如果时间戳太大,可能是毫秒级时间戳 if timestamp > 4102444800: # 2100年的时间戳 timestamp = timestamp / 1000 # 再次检查范围 if timestamp < 0 or timestamp > 4102444800: return 0 # 无效时间戳返回0 return timestamp except (ValueError, TypeError): return 0 # 无效返回0 try: # 按修改时间排序,使用安全的排序函数 recent_files = sorted(file_nodes, key=safe_timestamp_key, reverse=True) except Exception as e: print(f"警告: 文件排序时出错: {e}") # 如果排序出错,直接使用原始列表 recent_files = file_nodes # 只取第一个作为显示 if recent_files: try: display_files.append(recent_files[0]['file_name']) except (IndexError, KeyError) as e: print(f"警告: 获取文件名时出错: {e}") # 如果出错,尝试添加第一个文件(如果有) if file_nodes: try: display_files.append(file_nodes[0]['file_name']) except (KeyError, IndexError): print("警告: 无法获取有效的文件名") # 添加成功通知 - 修复问题:确保在有文件时添加通知 if display_files: add_notify(f"✅《{task['taskname']}》新增文件:") savepath = task['savepath'] add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 创建episode_pattern函数用于排序 def extract_episode_number_local(filename): # 使用全局的统一提取函数,但优先尝试从episode_naming模式中提取 episode_pattern = task["episode_naming"] # 优先尝试全局函数提取 ep_num = extract_episode_number(filename) if ep_num is not None: return ep_num # 如果全局函数无法提取,尝试从episode_naming模式中提取 if "[]" in episode_pattern: pattern_parts = episode_pattern.split("[]") if len(pattern_parts) == 2: prefix, suffix = pattern_parts if prefix and filename.startswith(prefix): number_part = filename[len(prefix):].split(suffix)[0] if suffix else filename[len(prefix):] if number_part.isdigit(): return int(number_part) # 尝试转换中文数字 else: arabic_num = chinese_to_arabic(number_part) if arabic_num is not None: return arabic_num # 如果所有方法都失败,返回float('inf') return float('inf') # 按剧集号排序 display_files.sort(key=extract_episode_number_local) # 打印文件列表 for idx, file_name in enumerate(display_files): prefix = "├── " if idx < len(display_files) - 1 else "└── " # 查找文件信息用于获取图标 file_info = next((f for f in file_nodes if f["file_name"] == file_name or (f["file_name"] in renamed_files and renamed_files[f["file_name"]] == file_name)), None) if file_info is None: # 如果找不到对应信息,可能是已重命名文件,使用默认图标 icon = get_file_icon(file_name, is_dir=False) else: icon = get_file_icon(file_name, is_dir=file_info.get("dir", False)) add_notify(format_file_display(prefix, icon, file_name)) # 确保只有在有文件时才添加空行 if display_files: add_notify("") # 添加正则命名模式的文件树显示逻辑 elif is_rename and not is_special_sequence and task.get("pattern") is not None: # 重新获取文件列表 savepath = re.sub(r"/{2,}", "/", f"/{task['savepath']}") dir_file_list = account.ls_dir(account.savepath_fid[savepath]) # 过滤出非目录的文件 file_nodes = [f for f in dir_file_list if not f["dir"]] # 获取实际的文件名映射 actual_file_names = account.get_actual_file_names_from_directory(task, rename_logs) # 从重命名日志提取预期的新旧文件名映射 expected_renamed_files = {} for log in rename_logs: # 格式:重命名: 旧名 → 新名 match = re.search(r'重命名: (.*?) → (.+?)($|\s|,|失败)', log) if match: old_name = match.group(1).strip() expected_new_name = match.group(2).strip() expected_renamed_files[old_name] = expected_new_name # 只显示实际存在的转存文件 display_files = [] for old_name in expected_renamed_files.keys(): # 获取实际的文件名 actual_name = actual_file_names.get(old_name, expected_renamed_files[old_name]) # 检查文件是否实际存在于目录中 if any(f["file_name"] == actual_name for f in file_nodes): if actual_name not in display_files: display_files.append(actual_name) # 如果没有找到任何文件要显示,使用原始逻辑 if not display_files: # 创建一个映射列表,包含所有文件 display_files = [file["file_name"] for file in file_nodes] # 添加成功通知 add_notify(f"✅《{task['taskname']}》新增文件:") savepath = task['savepath'] add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 打印文件列表 for idx, file_name in enumerate(display_files): prefix = "├── " if idx < len(display_files) - 1 else "└── " file_info = file_nodes[next((i for i, f in enumerate(file_nodes) if f["file_name"] == file_name), 0)] icon = get_file_icon(file_name, is_dir=file_info.get("dir", False)) add_notify(format_file_display(prefix, icon, file_name)) add_notify("") # 打印重命名日志(文件树之后) if rename_logs: # 处理重命名日志,更新数据库记录 account.process_rename_logs(task, rename_logs) # 控制台视觉分隔:若前面未通过 add_notify("") 打印过空行,则补打一行空行 try: if not (NOTIFYS and NOTIFYS[-1] == ""): print() except Exception: # 兜底:若 NOTIFYS 异常,仍保证有一行空行 print() # 对剧集命名模式和其他模式统一处理重命名日志 # 按sort_file_by_name函数的多级排序逻辑排序重命名日志 sorted_rename_logs = [] for log in rename_logs: # 提取新文件名(格式:重命名: 旧名 → 新名) # 使用更精确的字符串分割方法,确保能捕获完整的文件名 if "重命名:" in log and " → " in log: parts = log.split("重命名:", 1)[1].strip() if " → " in parts: _, new_name = parts.split(" → ", 1) # 只处理失败信息,不截断正常文件名 if " 失败," in new_name: new_name = new_name.split(" 失败,")[0] # 去除首尾空格 new_name = new_name.strip() # 使用sort_file_by_name函数获取排序值 sort_tuple = sort_file_by_name(new_name) sorted_rename_logs.append((sort_tuple, log)) else: # 没找到箭头或格式不符的日志放在最后 sorted_rename_logs.append(((float('inf'), float('inf'), float('inf'), 0), log)) # 按sort_file_by_name返回的排序元组排序 sorted_rename_logs.sort(key=lambda x: x[0]) # 打印排序后的日志 for _, log in sorted_rename_logs: print(log) # 补充任务的插件配置 def merge_dicts(a, b): result = a.copy() for key, value in b.items(): if ( key in result and isinstance(result[key], dict) and isinstance(value, dict) ): result[key] = merge_dicts(result[key], value) elif key not in result: result[key] = value return result task["addition"] = merge_dicts( task.get("addition", {}), task_plugins_config ) # 为任务添加剧集模式配置 if task.get("use_episode_naming") and task.get("episode_naming"): task["config_data"] = { "episode_patterns": CONFIG_DATA.get("episode_patterns", []) } # 调用插件 if is_new_tree or is_rename: print() print(f"🧩 调用插件") for plugin_name, plugin in plugins.items(): if plugin.is_active and (is_new_tree or is_rename): task = ( plugin.run(task, account=account, tree=is_new_tree, rename_logs=rename_logs) or task ) elif is_new_tree is False: # 明确没有新文件 print(f"任务完成: 没有新的文件需要转存") print() def main(): global CONFIG_DATA start_time = datetime.now() print(f"===============程序开始===============") print(f"⏰ 执行时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}") # 读取启动参数 config_path = sys.argv[1] if len(sys.argv) > 1 else "quark_config.json" # 从环境变量中获取 TASKLIST tasklist_from_env = [] if tasklist_json := os.environ.get("TASKLIST"): try: tasklist_from_env = json.loads(tasklist_json) except Exception as e: print(f"从环境变量解析任务列表失败 {e}") # 检查本地文件是否存在,如果不存在就下载 if not os.path.exists(config_path): if os.environ.get("QUARK_COOKIE"): print( f"⚙️ 读取到 QUARK_COOKIE 环境变量,仅签到领空间。如需执行转存,请删除该环境变量后配置 {config_path} 文件" ) cookie_val = os.environ.get("QUARK_COOKIE") cookie_form_file = False else: print(f"⚙️ 配置文件 {config_path} 不存在❌,正远程从下载配置模版") config_url = f"{GH_PROXY}https://raw.githubusercontent.com/Cp0204/quark_auto_save/main/quark_config.json" if Config.download_file(config_url, config_path): print("⚙️ 配置模版下载成功✅,请到程序目录中手动配置") return else: print(f"⚙️ 正从 {config_path} 文件中读取配置") CONFIG_DATA = Config.read_json(config_path) Config.breaking_change_update(CONFIG_DATA) cookie_val = CONFIG_DATA.get("cookie") if not CONFIG_DATA.get("magic_regex"): CONFIG_DATA["magic_regex"] = MAGIC_REGEX cookie_form_file = True # 获取cookie cookies = Config.get_cookies(cookie_val) if not cookies: print("❌ cookie 未配置") return accounts = [Quark(cookie, index) for index, cookie in enumerate(cookies)] # 签到 print() print(f"===============签到任务===============") if tasklist_from_env: verify_account(accounts[0]) else: for account in accounts: verify_account(account) do_sign(account) print() # 转存 if accounts[0].is_active and cookie_form_file: print(f"===============转存任务===============") # 任务列表 if tasklist_from_env: # 若通过环境变量传入任务列表,视为手动运行,可由外层控制是否忽略执行周期/进度限制 ignore_execution_rules = os.environ.get("IGNORE_EXECUTION_RULES", "").lower() in ["1", "true", "yes"] do_save(accounts[0], tasklist_from_env, ignore_execution_rules=ignore_execution_rules) else: # 定时任务或命令行全量运行,始终遵循执行周期/进度规则 do_save(accounts[0], CONFIG_DATA.get("tasklist", []), ignore_execution_rules=False) print() # 通知 if NOTIFYS: notify_body = "\n".join(NOTIFYS) print(f"===============推送通知===============") send_ql_notify("【夸克自动转存】", notify_body) print() else: # 如果没有通知内容,显示统一提示 print(f"===============推送通知===============") print("📭 本次运行没有新的转存,未推送通知") print() if cookie_form_file: # 更新配置 Config.write_json(config_path, CONFIG_DATA) print(f"===============程序结束===============") duration = datetime.now() - start_time print(f"😃 运行时长: {round(duration.total_seconds(), 2)}s") print() if __name__ == "__main__": main()