新增文件整理功能,优化命名规则切换和部分排序逻辑

Merge pull request #29 from x1ao4/dev
This commit is contained in:
x1ao4 2025-06-29 23:53:27 +08:00 committed by GitHub
commit 0e458e934e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 4355 additions and 406 deletions

View File

@ -11,6 +11,7 @@
- **WebUI**:对整个 WebUI 进行了重塑增加了更多实用功能如文件选择和预览界面的排序功能、资源搜索的过滤功能、TMDB 和豆瓣搜索功能、页面视图切换功能、账号设置功能等等。
- **查重逻辑**:支持优先通过历史转存记录查重,对于有转存记录的文件,即使删除网盘文件,也不会重复转存。
- **Aria2**:支持成功添加 Aria2 下载任务后自动删除夸克网盘内对应的文件,清理网盘空间。
- **文件整理**:支持浏览和管理多个夸克账号的网盘文件,支持批量重命名(支持应用完整的命名、过滤规则和撤销重命名等操作)、删除文件等操作。
本项目修改后的版本为个人需求定制版,目的是满足我自己的使用需求,某些(我不用的)功能可能会因为修改而出现 BUG不一定会被修复。若你要使用本项目请知晓本人不是程序员我无法保证本项目的稳定性如果你在使用过程中发现了 BUG可以在 Issues 中提交,但不保证每个 BUG 都能被修复,请谨慎使用,风险自担。
@ -39,6 +40,7 @@
- [x] 转存后文件名整理(正则命名、**顺序命名**、**剧集命名**
- [x] 可选忽略文件后缀
- [x] **数据库记录所有转存历史(支持查看、查询和删除记录)**
- [x] **文件整理(支持浏览和管理多个夸克账号的网盘文件)**
- 任务管理
- [x] 支持多组任务
@ -54,7 +56,7 @@
- 其它
- [x] 每日签到领空间 <sup>[?](https://github.com/x1ao4/quark-auto-save-x/wiki/使用技巧集锦#每日签到领空间)</sup>
- [x] 支持多个通知推送渠道 <sup>[?](https://github.com/x1ao4/quark-auto-save-x/wiki/通知推送服务配置)</sup>
- [x] 支持多账号(多账号签到,仅首账号转存)
- [x] 支持多账号(多账号签到、**文件管理**,仅首账号转存)
- [x] 支持网盘文件下载、strm 文件生成等功能 <sup>[?](https://github.com/x1ao4/quark-auto-save-x/wiki/插件配置)</sup>
## 部署

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@ class RecordDB:
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
# 创建数据库连接
self.conn = sqlite3.connect(self.db_path)
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
cursor = self.conn.cursor()
# 创建表,如果不存在
@ -49,13 +49,14 @@ class RecordDB:
self.conn.close()
def add_record(self, task_name, original_name, renamed_to, file_size, modify_date,
duration="", resolution="", file_id="", file_type="", save_path=""):
duration="", resolution="", file_id="", file_type="", save_path="", transfer_time=None):
"""添加一条转存记录"""
cursor = self.conn.cursor()
now_ms = int(time.time() * 1000) if transfer_time is None else transfer_time
cursor.execute(
"INSERT INTO transfer_records (transfer_time, task_name, original_name, renamed_to, file_size, "
"duration, resolution, modify_date, file_id, file_type, save_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(int(time.time()), task_name, original_name, renamed_to, file_size,
(now_ms, task_name, original_name, renamed_to, file_size,
duration, resolution, modify_date, file_id, file_type, save_path)
)
self.conn.commit()
@ -123,7 +124,7 @@ class RecordDB:
return 0
def get_records(self, page=1, page_size=20, sort_by="transfer_time", order="desc",
task_name_filter="", keyword_filter=""):
task_name_filter="", keyword_filter="", exclude_task_names=None):
"""获取转存记录列表,支持分页、排序和筛选
Args:
@ -133,6 +134,7 @@ class RecordDB:
order: 排序方向asc/desc
task_name_filter: 任务名称筛选条件精确匹配
keyword_filter: 关键字筛选条件模糊匹配任务名
exclude_task_names: 需要排除的任务名称列表
"""
cursor = self.conn.cursor()
offset = (page - 1) * page_size
@ -159,6 +161,10 @@ class RecordDB:
params.append(f"%{keyword_filter}%")
params.append(f"%{keyword_filter}%")
if exclude_task_names:
where_clauses.append("task_name NOT IN ({})".format(",".join(["?" for _ in exclude_task_names])))
params.extend(exclude_task_names)
where_clause = " AND ".join(where_clauses)
where_sql = f"WHERE {where_clause}" if where_clause else ""

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
// 与后端 quark_auto_save.py 的 sort_file_by_name 完全一致的排序逻辑
// 用于前端文件列表排序
function chineseToArabic(chinese) {
// 简单实现,支持一到一万
const cnNums = {
'零': 0, '一': 1, '二': 2, '两': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9,
'十': 10, '百': 100, '千': 1000, '万': 10000
};
let result = 0, unit = 1, num = 0;
for (let i = chinese.length - 1; i >= 0; i--) {
const char = chinese[i];
if (cnNums[char] >= 10) {
unit = cnNums[char];
if (unit === 10 && (i === 0 || cnNums[chinese[i - 1]] === undefined)) {
num = 1;
}
} else if (cnNums[char] !== undefined) {
num = cnNums[char];
result += num * unit;
}
}
return result || null;
}
function sortFileByName(file) {
// 兼容 dict 或字符串
let filename = typeof file === 'object' ? (file.file_name || '') : file;
let update_time = typeof file === 'object' ? (file.updated_at || 0) : 0;
let file_name_without_ext = filename.replace(/\.[^/.]+$/, '');
let date_value = Infinity, episode_value = Infinity, segment_value = 0;
// 1. 日期提取
let match;
// YYYY-MM-DD
match = filename.match(/((?:19|20)\d{2})[-./\s](\d{1,2})[-./\s](\d{1,2})/);
if (match) {
date_value = parseInt(match[1]) * 10000 + parseInt(match[2]) * 100 + parseInt(match[3]);
}
// YY-MM-DD
if (date_value === Infinity) {
match = filename.match(/((?:19|20)?\d{2})[-./\s](\d{1,2})[-./\s](\d{1,2})/);
if (match && match[1].length === 2) {
let year = parseInt('20' + match[1]);
date_value = year * 10000 + parseInt(match[2]) * 100 + parseInt(match[3]);
}
}
// YYYYMMDD
if (date_value === Infinity) {
match = filename.match(/((?:19|20)\d{2})(\d{2})(\d{2})/);
if (match) {
date_value = parseInt(match[1]) * 10000 + parseInt(match[2]) * 100 + parseInt(match[3]);
}
}
// YYMMDD
if (date_value === Infinity) {
match = filename.match(/(?<!\d)(\d{2})(\d{2})(\d{2})(?!\d)/);
if (match) {
let month = parseInt(match[2]), day = parseInt(match[3]);
if (1 <= month && month <= 12 && 1 <= day && day <= 31) {
let year = parseInt('20' + match[1]);
date_value = year * 10000 + month * 100 + day;
}
}
}
// MM/DD/YYYY
if (date_value === Infinity) {
match = filename.match(/(\d{1,2})[-./\s](\d{1,2})[-./\s]((?:19|20)\d{2})/);
if (match) {
let month = parseInt(match[1]), day = parseInt(match[2]), year = parseInt(match[3]);
if (month > 12) [month, day] = [day, month];
date_value = year * 10000 + month * 100 + day;
}
}
// MM-DD
if (date_value === Infinity) {
match = filename.match(/(?<!\d)(\d{1,2})[-./\s](\d{1,2})(?!\d)/);
if (match) {
let month = parseInt(match[1]), day = parseInt(match[2]);
if (month > 12) [month, day] = [day, month];
date_value = 20000000 + month * 100 + day;
}
}
// 2. 期数/集数
// 第X期/集/话
match = filename.match(/第(\d+)[期集话]/);
if (match) episode_value = parseInt(match[1]);
// 第[中文数字]期/集/话
if (episode_value === Infinity) {
match = filename.match(/第([一二三四五六七八九十百千万零两]+)[期集话]/);
if (match) {
let arabic = chineseToArabic(match[1]);
if (arabic !== null) episode_value = arabic;
}
}
// X集/期/话
if (episode_value === Infinity) {
match = filename.match(/(\d+)[期集话]/);
if (match) episode_value = parseInt(match[1]);
}
// [中文数字]集/期/话
if (episode_value === Infinity) {
match = filename.match(/([一二三四五六七八九十百千万零两]+)[期集话]/);
if (match) {
let arabic = chineseToArabic(match[1]);
if (arabic !== null) episode_value = arabic;
}
}
// S01E01
if (episode_value === Infinity) {
match = filename.match(/[Ss](\d+)[Ee](\d+)/);
if (match) episode_value = parseInt(match[2]);
}
// E01/EP01
if (episode_value === Infinity) {
match = filename.match(/[Ee][Pp]?(\d+)/);
if (match) episode_value = parseInt(match[1]);
}
// 1x01
if (episode_value === Infinity) {
match = filename.match(/(\d+)[Xx](\d+)/);
if (match) episode_value = parseInt(match[2]);
}
// [数字]或【数字】
if (episode_value === Infinity) {
match = filename.match(/\[(\d+)\]|【(\d+)】/);
if (match) episode_value = parseInt(match[1] || match[2]);
}
// 纯数字文件名
if (episode_value === Infinity) {
if (/^\d+$/.test(file_name_without_ext)) {
episode_value = parseInt(file_name_without_ext);
} else {
match = filename.match(/(\d+)/);
if (match) episode_value = parseInt(match[1]);
}
}
// 3. 上中下
if (/[上][集期话部篇]?|[集期话部篇]上/.test(filename)) segment_value = 1;
else if (/[中][集期话部篇]?|[集期话部篇]中/.test(filename)) segment_value = 2;
else if (/[下][集期话部篇]?|[集期话部篇]下/.test(filename)) segment_value = 3;
return [date_value, episode_value, segment_value, update_time];
}
// 用法:
// arr.sort((a, b) => {
// const ka = sortFileByName(a), kb = sortFileByName(b);
// for (let i = 0; i < ka.length; ++i) {
// if (ka[i] !== kb[i]) return ka[i] > kb[i] ? 1 : -1;
// }
// return 0;
// });

File diff suppressed because it is too large Load Diff

View File

@ -16,6 +16,9 @@ class Alist:
# 缓存参数
storage_mount_path = None
quark_root_dir = None
# 多账号支持
storage_mount_paths = []
quark_root_dirs = []
def __init__(self, **kwargs):
"""初始化AList插件"""
@ -28,8 +31,17 @@ class Alist:
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.plugin_name} 模块缺少必要参数: {key}")
pass # 不显示缺少参数的提示
# 处理多账号配置支持数组形式的storage_id
if isinstance(self.storage_id, list):
self.storage_ids = self.storage_id
# 为了向后兼容使用第一个ID作为默认值
self.storage_id = self.storage_ids[0] if self.storage_ids else ""
else:
# 单一配置转换为数组格式
self.storage_ids = [self.storage_id] if self.storage_id else []
# 检查基本配置
if not self.url or not self.token or not self.storage_id:
return
@ -43,27 +55,56 @@ class Alist:
# 验证AList连接
if self.get_info():
# 解析存储ID
success, result = self.storage_id_to_path(self.storage_id)
if success:
self.storage_mount_path, self.quark_root_dir = result
# 确保路径格式正确
if self.quark_root_dir != "/":
if not self.quark_root_dir.startswith("/"):
self.quark_root_dir = f"/{self.quark_root_dir}"
self.quark_root_dir = self.quark_root_dir.rstrip("/")
if not self.storage_mount_path.startswith("/"):
self.storage_mount_path = f"/{self.storage_mount_path}"
self.storage_mount_path = self.storage_mount_path.rstrip("/")
# 解析所有存储ID
for i, storage_id in enumerate(self.storage_ids):
success, result = self.storage_id_to_path(storage_id)
if success:
mount_path, root_dir = result
# 确保路径格式正确
if root_dir != "/":
if not root_dir.startswith("/"):
root_dir = f"/{root_dir}"
root_dir = root_dir.rstrip("/")
if not mount_path.startswith("/"):
mount_path = f"/{mount_path}"
mount_path = mount_path.rstrip("/")
self.storage_mount_paths.append(mount_path)
self.quark_root_dirs.append(root_dir)
if i == 0:
# 设置默认值(向后兼容)
self.storage_mount_path = mount_path
self.quark_root_dir = root_dir
else:
print(f"AList 刷新: 存储ID [{i}] {storage_id} 解析失败")
# 添加空值保持索引对应
self.storage_mount_paths.append("")
self.quark_root_dirs.append("")
# 只要有一个存储ID解析成功就激活插件
if any(self.storage_mount_paths):
self.is_active = True
else:
print(f"AList 刷新: 存储信息解析失败")
print(f"AList 刷新: 所有存储ID解析失败")
else:
print(f"AList 刷新: 服务器连接失败")
def get_storage_config(self, account_index=0):
"""根据账号索引获取对应的存储配置"""
if account_index < len(self.storage_mount_paths) and account_index < len(self.quark_root_dirs):
return self.storage_mount_paths[account_index], self.quark_root_dirs[account_index]
else:
# 如果索引超出范围,使用第一个配置作为默认值
if self.storage_mount_paths and self.quark_root_dirs:
return self.storage_mount_paths[0], self.quark_root_dirs[0]
else:
return "", ""
def run(self, task, **kwargs):
"""
插件主入口当有新文件保存时触发刷新AList目录

View File

@ -46,12 +46,10 @@ class Alist_strm_gen:
missing_configs.append(key)
if missing_configs:
print(f"{self.plugin_name} 模块缺少必要参数: {', '.join(missing_configs)}")
return
return # 不显示缺少参数的提示
if not self.url or not self.token or not self.storage_id:
print(f"{self.plugin_name} 模块配置不完整,请检查配置")
return
return # 不显示配置不完整的提示
# 检查 strm_save_dir 是否存在
if not os.path.exists(self.strm_save_dir):

View File

@ -18,11 +18,29 @@ class Plex:
if key in kwargs:
setattr(self, key, kwargs[key])
else:
print(f"{self.__class__.__name__} 模块缺少必要参数: {key}")
pass # 不显示缺少参数的提示
# 处理多账号配置支持数组形式的quark_root_path
if isinstance(self.quark_root_path, list):
self.quark_root_paths = self.quark_root_path
# 为了向后兼容,使用第一个路径作为默认值
self.quark_root_path = self.quark_root_paths[0] if self.quark_root_paths else ""
else:
# 单一配置转换为数组格式
self.quark_root_paths = [self.quark_root_path] if self.quark_root_path else []
if self.url and self.token and self.quark_root_path:
if self.get_info():
self.is_active = True
def get_quark_root_path(self, account_index=0):
"""根据账号索引获取对应的quark_root_path"""
if account_index < len(self.quark_root_paths):
return self.quark_root_paths[account_index]
else:
# 如果索引超出范围,使用第一个路径作为默认值
return self.quark_root_paths[0] if self.quark_root_paths else ""
def run(self, task, **kwargs):
if task.get("savepath"):
# 检查是否已缓存库信息
@ -59,10 +77,8 @@ class Plex:
try:
for library in self._libraries:
for location in library.get("Location", []):
if (
os.path.commonpath([folder_path, location["path"]])
== location["path"]
):
location_path = location.get("path", "")
if folder_path.startswith(location_path):
refresh_url = f"{self.url}/library/sections/{library['key']}/refresh?path={folder_path}"
refresh_response = requests.get(refresh_url, headers=headers)
if refresh_response.status_code == 200:

View File

@ -229,6 +229,9 @@ def extract_episode_number(filename, episode_patterns=None, config_data=None):
Returns:
int: 提取到的剧集号如果无法提取则返回None
"""
# 首先去除文件扩展名
file_name_without_ext = os.path.splitext(filename)[0]
# 预处理:排除文件名中可能是日期的部分,避免误识别
date_patterns = [
# YYYY-MM-DD 或 YYYY.MM.DD 或 YYYY/MM/DD 或 YYYY MM DD格式四位年份
@ -245,10 +248,10 @@ def extract_episode_number(filename, episode_patterns=None, config_data=None):
r'(?<!\d)(\d{1,2})[-./\s](\d{1,2})(?!\d)',
]
# 从文件名中移除日期部分,创建一个不含日期的文件名副本用于提取剧集号
filename_without_dates = filename
# 从不含扩展名的文件名中移除日期部分
filename_without_dates = file_name_without_ext
for pattern in date_patterns:
matches = re.finditer(pattern, filename)
matches = re.finditer(pattern, filename_without_dates)
for match in matches:
# 检查匹配的内容是否确实是日期
date_str = match.group(0)
@ -369,13 +372,9 @@ def extract_episode_number(filename, episode_patterns=None, config_data=None):
except:
continue
# 如果从不含日期的文件名中没有找到剧集号,尝试从原始文件名中提取
# 这是为了兼容某些特殊情况,但要检查提取的数字不是日期
file_name_without_ext = os.path.splitext(filename)[0]
# 如果文件名是纯数字,且不是日期格式,则可能是剧集号
if file_name_without_ext.isdigit() and not is_date_format(file_name_without_ext):
return int(file_name_without_ext)
if filename_without_dates.isdigit() and not is_date_format(filename_without_dates):
return int(filename_without_dates)
# 最后尝试提取任何数字,但要排除日期可能性
num_match = re.search(r'(\d+)', filename_without_dates)
@ -933,6 +932,9 @@ class Quark:
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 = {
@ -941,7 +943,7 @@ class Quark:
"uc_param_str": "",
"pdir_fid": pdir_fid,
"_page": page,
"_size": "50",
"_size": str(page_size),
"_fetch_total": "1",
"_fetch_sub_dirs": "0",
"_sort": "file_type:asc,updated_at:desc",
@ -959,6 +961,48 @@ class Quark:
break
return file_list
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 = {