Compare commits

...

29 Commits

Author SHA1 Message Date
x1ao4
d66a4177e6
Merge pull request #72 from x1ao4/dev
新增按任务进度执行的执行周期模式,修复并优化了其他若干问题
2025-11-17 14:54:35 +08:00
x1ao4
b420029771 为定时运行全部任务添加自动重试机制
- 当定时任务执行失败(返回非零退出码)时,自动重试一次,提高任务执行成功率。
- 重试前等待 5 秒,避免立即重试。
- 保留所有原有功能和日志记录。
2025-11-16 11:54:24 +08:00
x1ao4
e69ad46ddd 优化选择文件夹模态框的文件名排序逻辑
- 为 source/target/move 三个模态框调整文件名排序优先级
- 文件:日期 > 期数 > 上中下 > 拼音 > 修改日期
- 文件夹:日期 > 上中下 > 拼音 > 修改日期
- 拼音排序键提前到修改日期之前,便于按拼音排序
2025-11-16 11:23:41 +08:00
x1ao4
4950b53e03 修复会话持久化问题并增强安全性
- 修复用户每天需要重新登录的问题,确保登录状态保持31天
- 添加 before_request 钩子确保已登录用户的会话保持永久状态
- 增强会话 cookie 安全性:添加 HTTPONLY 和 SAMESITE 标志
- 实现动态 SESSION_COOKIE_SECURE 设置,自动适配 HTTP/HTTPS 环境
- 支持反向代理场景,自动检测 X-Forwarded-Proto 和 X-Forwarded-Scheme 头

修改内容:
- 添加 SESSION_COOKIE_HTTPONLY 和 SESSION_COOKIE_SAMESITE 配置
- 实现动态 HTTPS 检测和 SESSION_COOKIE_SECURE 设置
- 添加 make_session_permanent() 钩子函数确保会话持久化

安全性:
- 防止 XSS 攻击(HTTPONLY)
- 防止 CSRF 攻击(SAMESITE)
- HTTPS 环境下自动启用 Secure 标志

兼容性:
- 同时支持 HTTP 和 HTTPS 环境
- 支持 Nginx、Apache 等反向代理场景
- 不影响现有登录、登出和验证功能
2025-11-16 10:40:00 +08:00
x1ao4
7a15bb1758 修复定时运行全部任务完成后已转存集数和当日更新标识的热更新问题
- 修复 recompute_task_metrics_and_notify 中 season_metrics 的 transferred_count 未更新的 bug
- 在定时任务执行完毕后发送 crontab_task_completed SSE 通知
- 前端添加对 crontab_task_completed 事件的处理,确保刷新今日更新数据
2025-11-15 20:21:19 +08:00
x1ao4
3e5ce67eab 为显示设置和性能设置添加 Wiki 链接 2025-11-14 20:30:38 +08:00
x1ao4
ba829ffacc 新增按任务进度执行的执行周期模式
新增功能:
- 支持两种执行周期判断模式:按自选周期执行(自选)和按任务进度执行(自动)
- 系统配置添加执行周期设置选项,支持批量应用到所有任务
- 任务配置支持单独设置执行周期模式,覆盖系统默认值
- 按任务进度执行时,根据任务进度是否 100% 智能判断是否跳过
- 优化日志输出,准确反映实际应用的执行周期设置

技术实现:
- 新增 CalendarDB.get_task_metrics() 方法获取任务进度
- 修改 is_time() 函数支持两种执行周期判断模式
- 系统配置默认值为 manual,保持向后兼容
- 手动运行任务不受执行周期设置影响

涉及文件:
- app/sdk/db.py: 新增 get_task_metrics 方法
- app/run.py: 添加 execution_mode 默认值初始化
- app/templates/index.html: UI 修改和批量选择功能
- quark_auto_save.py: 核心判断逻辑和日志输出优化
2025-11-14 15:47:53 +08:00
x1ao4
fb794ac08f 优化 SQLite 数据库并发访问,解决 database is locked 错误
- 启用 WAL 模式(Write-Ahead Logging),大幅提升并发读写性能
- 为所有数据库操作添加自动重试机制(最多 3 次,指数退避)
- 设置连接超时时间(5 秒)和忙等待超时(5 秒),避免长时间阻塞
- 优化连接关闭逻辑,确保异常情况下也能正确释放资源
2025-11-13 10:54:12 +08:00
x1ao4
20add4c668 调整手动运行任务开始通知格式并新增结束成功通知
- 开始通知改为 “>>> 开始执行手动运行任务 [名称]”
- 任务结束后根据退出码输出成功或非零退出码警告
- 不影响定时任务日志行为
2025-11-11 17:49:30 +08:00
x1ao4
099581956e 补全定时任务的开始和结束日志输出
为 "播出集数自动刷新" 和 "追剧日历自动刷新" 添加完整的开始/结束日志
2025-11-11 11:00:14 +08:00
x1ao4
ff5a4cba9a 允许 APScheduler 在主机睡眠后补偿执行
- 全局启用 misfire_grace_time=None 与 coalesce=True,容忍长时间暂停并仅补偿一次
- 对 run_python 与日历刷新任务显式设置 misfire/coalesce/max_instances=1
- 避免睡眠唤醒后持续出现 missed,确保后续按期执行
- 不修改业务逻辑与数据,兼容现有行为
2025-11-10 20:30:28 +08:00
x1ao4
a05ed17596 统一推送与控制台空行分隔,优化可读性 2025-11-10 18:27:14 +08:00
x1ao4
e7be8f5738 统一节目集数统计逻辑,改用后端 season_counts 提供数据
- 使用后端注入的 season_counts 展示已转存/已播出/总集数
- 移除前端基于 progressByTaskName/episodes 的本地推导分支
- 保留 episodes/progressByTaskName 用于非统计渲染与标识
- 提升渲染性能与前后端口径一致性
2025-11-10 17:28:54 +08:00
x1ao4
c5df061549 修复失效信息重复显示和日志连续空行问题
- 修复 do_rename_task 中失效信息重复显示的问题
- 修复日志输出中连续空行的问题
2025-11-08 18:02:20 +08:00
x1ao4
7a5d47b214 重构定时任务运行机制,支持自动清理与异常恢复
- 为定时运行全部任务(`run_python`)实现进程清理逻辑:
  * 每次运行前检查上一次任务是否仍在运行
  * 若仍在运行则强制终止并清理进程对象
  * 在 `finally` 块中统一清除进程引用,防止任务阻塞
- 为追剧日历刷新任务(`run_calendar_refresh_all_internal_wrapper`)添加相同的检查逻辑:
  * 检测上一次线程是否仍在运行
  * 若仍在运行则记录警告并继续执行新任务
  * 确保线程异常退出后能自动清理
- 优化异常处理,保证无论任务正常、异常或被系统终止,下次调度均可正常运行
- 避免 “maximum number of running instances reached” 等并发冲突问题
2025-11-08 16:11:57 +08:00
x1ao4
b7a6b1e35f 强化 “播出集数刷新时间” 限制,杜绝提前计入
- enrich_tasks_with_calendar_meta:未到刷新时间时,无条件用有效日期(昨日)统计覆盖缓存的 aired_count
- 已过刷新时间:仅在缓存非当日时用有效日期(今日)回填,确保日切后统一生效
- 修复部分节目 “提前计入已播” 且随后 “逐步恢复” 的展示不一致问题
- DB 统计口径保持 air_date<=有效日期,无前端改动
2025-11-07 19:05:41 +08:00
x1ao4
d213b62071 提升播出集数自动刷新任务的可靠性:失败按需重试
- 每日任务到点执行;若失败或当日标记未写入,则安排 10 分钟后一次性重试,直至成功为止(成功后清理重试任务)
- 启动与首次建立日历 SSE 时各进行一次按需检查(仅在已到刷新点且当日未生效时触发)
- 使用轻量标记文件 config/.aired_done 记录当日有效日期,无需修改数据库结构
2025-11-06 17:16:41 +08:00
x1ao4
98c0d9f938 修复已播出集数刷新时间限制未生效问题并优化相关功能
- 修复已播出集数刷新时间限制未生效问题,统一所有计算位置使用有效日期
- 配置保存时检测刷新时间变化,立即重新计算并热更新
- 优化日志输出,仅在配置改变时显示,避免重复信息
2025-11-06 02:42:44 +08:00
x1ao4
08553f99ca 在追剧日历自动刷新任务中新增了节目状态变更检测与热更新功能
- 在 run_calendar_refresh_all_internal 中检测 TMDB 节目状态变更(本地化)
- 变更时更新 shows.status 与 last_refreshed_at,保持其他字段不变
- 新增通知事件 status_updated,触发前后端热更新
- 保持原有集数据刷新与 auto_refresh 通知逻辑不变
2025-11-05 21:34:15 +08:00
x1ao4
874a09d57b 避免子进程误报 “播出集数自动刷新” 时间
- 引入 `IS_FLASK_SERVER_PROCESS` 仅在主进程打印启动提示
- 缓存上次时间,仅在时间变更时输出一次
- 解决手动运行子进程误打印为 00:00 的问题
2025-11-05 21:14:47 +08:00
x1ao4
eb74b6314a 启动与配置更新时稳定注册定时任务并优化日志
- 启动流程在 reload_tasks() 后重启 “追剧日历自动刷新/播出集数自动刷新” 任务,避免被清空
- 新增播出集数自动刷新任务的启动说明日志,显示执行时间
- 合并调度日志为 “已启动定时运行全部任务,定时规则 …”
- 降低 apscheduler 日志级别至 WARNING,去除 “Adding job tentatively …” 等噪音
2025-11-05 21:05:55 +08:00
x1ao4
4240ff9066 新增已播出集数刷新时间限制与自定义配置
- 新增性能设置项 "播出集数刷新时间"(24 小时制,默认 00:00)
- 访问时强制刷新受限于用户设置的刷新时间
- 仅在刷新时间之后允许访问时强制刷新已播出集数
- 已转存集数和节目总集数保持实时热更新,不受限制
- 向后兼容:配置缺失或解析失败时默认允许刷新
2025-11-05 01:40:38 +08:00
x1ao4
7d5f297b70 落库缓存计数与进度,前后端同步热更新
- 新增 season_metrics/task_metrics 表,缓存已转存/已播出/总集数与 progress_pct
- 统一进度口径:已转存 ÷ min(已播出, 总集数),越界钳制
- 季数据刷新/改绑/改季时写回季级缓存并通知前端
- 转存记录新增/删除后重算任务与季级缓存并通知前端
- 聚合接口优先读缓存,缺失回退计算并回填
2025-11-05 00:52:24 +08:00
x1ao4
e4c14ef3a1 同步删除 TMDB 不存在的季内集,保持本地 DB 与 TMDB 一致
在 CalendarDB 新增 prune_season_episodes_not_in;在季数据拉取/刷新路径统一调用,先删后写,消除 “幽灵集” 导致的计数/展示偏差
2025-11-04 18:47:22 +08:00
x1ao4
643ee8e592 修正模态框资源搜索下拉框 Spinner 的左边距 2025-10-15 20:30:23 +08:00
x1ao4
593ff1f728 资源聚合去重时保留多来源,展示为 “CloudSaver · PanSou”
来源徽标支持多来源显示,多来源统一使用 multi-source class
2025-10-15 19:45:19 +08:00
x1ao4
080c73fc07 引入全局 SSE 单例,统一 onopen/onerror,任务列表与日历共用连接,切页不丢事件
- 进入日历页即时 tick 与本地补拉,消除 60s 等待
- 校正可见性回退判断为 appSSE;清理重复 SSE 回调设置
- 保留原轮询兜底与可见性恢复逻辑,确保 SSE 异常时行为一致
2025-10-15 19:10:11 +08:00
x1ao4
2d944600e6 优化海报管理功能,支持用户自定义海报保护和自动清理孤立文件
- 新增 is_custom_poster 数据库字段,标记用户自定义海报
- 实现自定义海报保护机制,防止被 TMDB 自动更新覆盖
- 添加孤立文件自动清理功能,优化存储空间管理
- 优化海报更新逻辑,支持旧文件删除和新文件下载
- 提供手动清理孤立文件的 API 接口
- 保持向后兼容,旧版本数据无缝升级

解决用户自定义海报被覆盖和孤立文件积累的问题
2025-10-12 16:28:12 +08:00
x1ao4
3898c6d41c 修复追剧日历页面当日更新标识热更新延迟的问题
- 问题:追剧日历页面当日更新标识热更新有60秒延迟,任务列表页面能实时更新
- 原因:转存记录创建/更新时未触发SSE通知,前端只能依赖轮询检测变化
- 解决:
  * 后端:转存记录创建/更新时触发SSE通知 (transfer_record_created/updated)
  * 后端:批量重命名完成时触发SSE通知 (batch_rename_completed)
  * 前端:SSE处理中优先更新今日更新数据
  * 修复:添加安全的导入函数避免模块导入错误
- 效果:实现追剧日历页面当日更新标识实时热更新,与任务列表页面体验一致

涉及文件:
- quark_auto_save.py: 添加SSE通知触发和安全导入函数
- app/run.py: 批量重命名SSE通知
- app/templates/index.html: 前端SSE处理优化
2025-10-12 14:57:49 +08:00
5 changed files with 2284 additions and 275 deletions

1555
app/run.py

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,34 @@ import json
import sqlite3
import time
from datetime import datetime
from functools import wraps
# 数据库操作重试装饰器
def retry_on_locked(max_retries=3, base_delay=0.1):
"""数据库操作重试装饰器,用于处理 database is locked 错误"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_retries):
try:
return func(*args, **kwargs)
except sqlite3.OperationalError as e:
if "database is locked" in str(e).lower():
last_exception = e
if attempt < max_retries - 1:
# 指数退避:延迟时间递增
delay = base_delay * (2 ** attempt)
time.sleep(delay)
continue
raise
except Exception:
raise
# 如果所有重试都失败,抛出最后一个异常
if last_exception:
raise last_exception
return wrapper
return decorator
class RecordDB:
def __init__(self, db_path="config/data.db"):
@ -14,10 +42,19 @@ class RecordDB:
# 确保目录存在
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
# 创建数据库连接
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
# 创建数据库连接设置超时时间为5秒
self.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=5.0
)
cursor = self.conn.cursor()
# 启用 WAL 模式,提高并发性能
cursor.execute('PRAGMA journal_mode=WAL')
cursor.execute('PRAGMA synchronous=NORMAL') # 平衡性能和安全性
cursor.execute('PRAGMA busy_timeout=5000') # 设置忙等待超时为5秒
# 创建表,如果不存在
cursor.execute('''
CREATE TABLE IF NOT EXISTS transfer_records (
@ -46,8 +83,12 @@ class RecordDB:
def close(self):
if self.conn:
self.conn.close()
try:
self.conn.close()
except Exception:
pass
@retry_on_locked(max_retries=3, base_delay=0.1)
def add_record(self, task_name, original_name, renamed_to, file_size, modify_date,
duration="", resolution="", file_id="", file_type="", save_path="", transfer_time=None):
"""添加一条转存记录"""
@ -62,6 +103,7 @@ class RecordDB:
self.conn.commit()
return cursor.lastrowid
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_renamed_to(self, file_id, original_name, renamed_to, task_name="", save_path=""):
"""更新最近一条记录的renamed_to字段
@ -123,6 +165,7 @@ class RecordDB:
return 0
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_records(self, page=1, page_size=20, sort_by="transfer_time", order="desc",
task_name_filter="", keyword_filter="", exclude_task_names=None):
"""获取转存记录列表,支持分页、排序和筛选
@ -223,6 +266,7 @@ class RecordDB:
}
}
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_record_by_id(self, record_id):
"""根据ID获取特定记录"""
cursor = self.conn.cursor()
@ -234,13 +278,15 @@ class RecordDB:
return dict(zip(columns, record))
return None
@retry_on_locked(max_retries=3, base_delay=0.1)
def delete_record(self, record_id):
"""删除特定记录"""
cursor = self.conn.cursor()
cursor.execute("DELETE FROM transfer_records WHERE id = ?", (record_id,))
self.conn.commit()
return cursor.rowcount
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_records_by_save_path(self, save_path, include_subpaths=False):
"""根据保存路径查询记录
@ -280,8 +326,18 @@ class CalendarDB:
def init_db(self):
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
# 创建数据库连接设置超时时间为5秒
self.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=5.0
)
cursor = self.conn.cursor()
# 启用 WAL 模式,提高并发性能
cursor.execute('PRAGMA journal_mode=WAL')
cursor.execute('PRAGMA synchronous=NORMAL') # 平衡性能和安全性
cursor.execute('PRAGMA busy_timeout=5000') # 设置忙等待超时为5秒
# shows
cursor.execute('''
@ -303,6 +359,10 @@ class CalendarDB:
columns = [column[1] for column in cursor.fetchall()]
if 'content_type' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN content_type TEXT')
# 检查 is_custom_poster 字段是否存在,如果不存在则添加
if 'is_custom_poster' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN is_custom_poster INTEGER DEFAULT 0')
# seasons
cursor.execute('''
@ -334,18 +394,64 @@ class CalendarDB:
)
''')
# season_metrics缓存每季的三项计数
cursor.execute('''
CREATE TABLE IF NOT EXISTS season_metrics (
tmdb_id INTEGER,
season_number INTEGER,
transferred_count INTEGER,
aired_count INTEGER,
total_count INTEGER,
progress_pct INTEGER,
updated_at INTEGER,
PRIMARY KEY (tmdb_id, season_number)
)
''')
# 迁移:如缺少 progress_pct 列则新增
try:
cursor.execute('PRAGMA table_info(season_metrics)')
cols = [c[1] for c in cursor.fetchall()]
if 'progress_pct' not in cols:
cursor.execute('ALTER TABLE season_metrics ADD COLUMN progress_pct INTEGER')
except Exception:
pass
# task_metrics缓存每任务的转存进度
cursor.execute('''
CREATE TABLE IF NOT EXISTS task_metrics (
task_name TEXT PRIMARY KEY,
tmdb_id INTEGER,
season_number INTEGER,
transferred_count INTEGER,
progress_pct INTEGER,
updated_at INTEGER
)
''')
# 迁移:如缺少 progress_pct 列则新增
try:
cursor.execute('PRAGMA table_info(task_metrics)')
cols = [c[1] for c in cursor.fetchall()]
if 'progress_pct' not in cols:
cursor.execute('ALTER TABLE task_metrics ADD COLUMN progress_pct INTEGER')
except Exception:
pass
self.conn.commit()
def close(self):
if self.conn:
self.conn.close()
try:
self.conn.close()
except Exception:
pass
# shows
def upsert_show(self, tmdb_id:int, name:str, year:str, status:str, poster_local_path:str, latest_season_number:int, last_refreshed_at:int=0, bound_task_names:str="", content_type:str=""):
@retry_on_locked(max_retries=3, base_delay=0.1)
def upsert_show(self, tmdb_id:int, name:str, year:str, status:str, poster_local_path:str, latest_season_number:int, last_refreshed_at:int=0, bound_task_names:str="", content_type:str="", is_custom_poster:int=0):
cursor = self.conn.cursor()
cursor.execute('''
INSERT INTO shows (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO shows (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type, is_custom_poster)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id) DO UPDATE SET
name=excluded.name,
year=excluded.year,
@ -354,10 +460,12 @@ class CalendarDB:
latest_season_number=excluded.latest_season_number,
last_refreshed_at=excluded.last_refreshed_at,
bound_task_names=excluded.bound_task_names,
content_type=excluded.content_type
''', (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type))
content_type=excluded.content_type,
is_custom_poster=excluded.is_custom_poster
''', (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type, is_custom_poster))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def bind_task_to_show(self, tmdb_id:int, task_name:str):
"""绑定任务到节目,在 bound_task_names 字段中记录任务名"""
cursor = self.conn.cursor()
@ -378,6 +486,7 @@ class CalendarDB:
return True
return False
@retry_on_locked(max_retries=3, base_delay=0.1)
def unbind_task_from_show(self, tmdb_id:int, task_name:str):
"""从节目解绑任务,更新 bound_task_names 列表"""
cursor = self.conn.cursor()
@ -395,6 +504,7 @@ class CalendarDB:
return True
return False
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_bound_tasks_for_show(self, tmdb_id:int):
"""获取绑定到指定节目的任务列表"""
cursor = self.conn.cursor()
@ -404,6 +514,7 @@ class CalendarDB:
return []
return row[0].split(',')
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_show_by_task_name(self, task_name:str):
"""根据任务名查找绑定的节目"""
cursor = self.conn.cursor()
@ -414,6 +525,7 @@ class CalendarDB:
columns = [c[0] for c in cursor.description]
return dict(zip(columns, row))
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_show(self, tmdb_id:int):
cursor = self.conn.cursor()
cursor.execute('SELECT * FROM shows WHERE tmdb_id=?', (tmdb_id,))
@ -423,6 +535,7 @@ class CalendarDB:
columns = [c[0] for c in cursor.description]
return dict(zip(columns, row))
@retry_on_locked(max_retries=3, base_delay=0.1)
def delete_show(self, tmdb_id:int):
cursor = self.conn.cursor()
cursor.execute('DELETE FROM episodes WHERE tmdb_id=?', (tmdb_id,))
@ -431,6 +544,7 @@ class CalendarDB:
self.conn.commit()
# seasons
@retry_on_locked(max_retries=3, base_delay=0.1)
def upsert_season(self, tmdb_id:int, season_number:int, episode_count:int, refresh_url:str, season_name:str=""):
cursor = self.conn.cursor()
# 迁移:如缺少 season_name 字段则补充
@ -451,6 +565,7 @@ class CalendarDB:
''', (tmdb_id, season_number, season_name, episode_count, refresh_url))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_season(self, tmdb_id:int, season_number:int):
cursor = self.conn.cursor()
cursor.execute('SELECT * FROM seasons WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number))
@ -461,6 +576,7 @@ class CalendarDB:
return dict(zip(columns, row))
# episodes
@retry_on_locked(max_retries=3, base_delay=0.1)
def upsert_episode(self, tmdb_id:int, season_number:int, episode_number:int, name:str, overview:str, air_date:str, runtime, ep_type:str, updated_at:int):
cursor = self.conn.cursor()
cursor.execute('''
@ -476,6 +592,7 @@ class CalendarDB:
''', (tmdb_id, season_number, episode_number, name, overview, air_date, runtime, ep_type, updated_at))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def list_latest_season_episodes(self, tmdb_id:int, latest_season:int):
cursor = self.conn.cursor()
cursor.execute('''
@ -496,6 +613,7 @@ class CalendarDB:
} for r in rows
]
@retry_on_locked(max_retries=3, base_delay=0.1)
def list_all_latest_episodes(self):
"""返回所有已知剧目的最新季的所有集(扁平列表,供前端汇总显示)"""
cursor = self.conn.cursor()
@ -517,7 +635,91 @@ class CalendarDB:
result.append(item)
return result
# --------- 孤儿数据清理seasons / episodes / season_metrics / task_metrics ---------
@retry_on_locked(max_retries=3, base_delay=0.1)
def cleanup_orphan_data(self, valid_task_pairs, valid_task_names):
"""清理不再与任何任务对应的数据
参数:
valid_task_pairs: 由当前任务映射得到的 (tmdb_id, season_number) 元组列表
valid_task_names: 当前存在的任务名列表
规则:
- task_metrics: 删除 task_name 不在当前任务列表中的记录
- seasons/episodes: 仅保留出现在 valid_task_pairs 内的季与对应所有集其余删除
- season_metrics: 仅保留出现在 valid_task_pairs 内的记录其余删除
"""
try:
cursor = self.conn.cursor()
# 1) 清理 task_metrics孤立任务
try:
if not valid_task_names:
cursor.execute('DELETE FROM task_metrics')
else:
placeholders = ','.join(['?'] * len(valid_task_names))
cursor.execute(f"DELETE FROM task_metrics WHERE task_name NOT IN ({placeholders})", valid_task_names)
except Exception:
pass
# 2) 清理 seasons/episodes 与 season_metrics仅保留任务声明的 (tmdb_id, season)
# 组装参数列表
pairs = [(int(tid), int(sn)) for (tid, sn) in (valid_task_pairs or []) if tid and sn]
if not pairs:
# 没有任何有效任务映射:清空 seasons/episodes/season_metrics
try:
cursor.execute('DELETE FROM episodes')
cursor.execute('DELETE FROM seasons')
cursor.execute('DELETE FROM season_metrics')
except Exception:
pass
else:
# 批量删除不在 pairs 中的记录
# 为 (tmdb_id, season_number) 对构造占位符
tuple_placeholders = ','.join(['(?, ?)'] * len(pairs))
flat_params = []
for tid, sn in pairs:
flat_params.extend([tid, sn])
# episodes 先删(避免残留)
try:
cursor.execute(
f'''DELETE FROM episodes
WHERE (tmdb_id, season_number) NOT IN ({tuple_placeholders})''',
flat_params
)
except Exception:
pass
# seasons 再删
try:
cursor.execute(
f'''DELETE FROM seasons
WHERE (tmdb_id, season_number) NOT IN ({tuple_placeholders})''',
flat_params
)
except Exception:
pass
# season_metrics 最后删
try:
cursor.execute(
f'''DELETE FROM season_metrics
WHERE (tmdb_id, season_number) NOT IN ({tuple_placeholders})''',
flat_params
)
except Exception:
pass
self.conn.commit()
return True
except Exception:
# 静默失败,避免影响核心流程
return False
# 内容类型管理方法
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_show_content_type(self, tmdb_id:int, content_type:str):
"""更新节目的内容类型"""
cursor = self.conn.cursor()
@ -525,6 +727,7 @@ class CalendarDB:
self.conn.commit()
return cursor.rowcount > 0
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_show_content_type(self, tmdb_id:int):
"""获取节目的内容类型"""
cursor = self.conn.cursor()
@ -532,6 +735,7 @@ class CalendarDB:
row = cursor.fetchone()
return row[0] if row else None
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_shows_by_content_type(self, content_type:str):
"""根据内容类型获取节目列表"""
cursor = self.conn.cursor()
@ -542,6 +746,7 @@ class CalendarDB:
columns = [c[0] for c in cursor.description]
return [dict(zip(columns, row)) for row in rows]
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_all_content_types(self):
"""获取所有已使用的内容类型"""
cursor = self.conn.cursor()
@ -556,7 +761,67 @@ class CalendarDB:
# 再更新内容类型
self.update_show_content_type(tmdb_id, content_type)
# --------- 统计缓存season_metrics / task_metrics ---------
@retry_on_locked(max_retries=3, base_delay=0.1)
def upsert_season_metrics(self, tmdb_id:int, season_number:int, transferred_count, aired_count, total_count, progress_pct, updated_at:int):
"""写入/更新季级指标缓存。允许某些字段传 None保持原值不变。"""
cursor = self.conn.cursor()
cursor.execute('''
INSERT INTO season_metrics (tmdb_id, season_number, transferred_count, aired_count, total_count, progress_pct, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id, season_number) DO UPDATE SET
transferred_count=COALESCE(excluded.transferred_count, transferred_count),
aired_count=COALESCE(excluded.aired_count, aired_count),
total_count=COALESCE(excluded.total_count, total_count),
progress_pct=COALESCE(excluded.progress_pct, progress_pct),
updated_at=MAX(COALESCE(excluded.updated_at, 0), COALESCE(updated_at, 0))
''', (tmdb_id, season_number, transferred_count, aired_count, total_count, progress_pct, updated_at))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_season_metrics(self, tmdb_id:int, season_number:int):
cursor = self.conn.cursor()
cursor.execute('SELECT transferred_count, aired_count, total_count, progress_pct, updated_at FROM season_metrics WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number))
row = cursor.fetchone()
if not row:
return None
return {
'transferred_count': row[0],
'aired_count': row[1],
'total_count': row[2],
'progress_pct': row[3],
'updated_at': row[4],
}
@retry_on_locked(max_retries=3, base_delay=0.1)
def upsert_task_metrics(self, task_name:str, tmdb_id:int, season_number:int, transferred_count:int, progress_pct, updated_at:int):
cursor = self.conn.cursor()
cursor.execute('''
INSERT INTO task_metrics (task_name, tmdb_id, season_number, transferred_count, progress_pct, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(task_name) DO UPDATE SET
tmdb_id=excluded.tmdb_id,
season_number=excluded.season_number,
transferred_count=excluded.transferred_count,
progress_pct=COALESCE(excluded.progress_pct, progress_pct),
updated_at=excluded.updated_at
''', (task_name, tmdb_id, season_number, transferred_count, progress_pct, updated_at))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_task_metrics(self, task_name:str):
"""获取任务的进度指标"""
cursor = self.conn.cursor()
cursor.execute('SELECT progress_pct FROM task_metrics WHERE task_name=?', (task_name,))
row = cursor.fetchone()
if not row:
return None
return {
'progress_pct': row[0],
}
# --------- 扩展:管理季与集清理/更新工具方法 ---------
@retry_on_locked(max_retries=3, base_delay=0.1)
def purge_other_seasons(self, tmdb_id: int, keep_season_number: int):
"""清除除指定季之外的所有季与对应集数据"""
cursor = self.conn.cursor()
@ -566,6 +831,7 @@ class CalendarDB:
cursor.execute('DELETE FROM seasons WHERE tmdb_id=? AND season_number != ?', (tmdb_id, keep_season_number))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def delete_season(self, tmdb_id: int, season_number: int):
"""删除指定季及其所有集数据"""
cursor = self.conn.cursor()
@ -573,18 +839,57 @@ class CalendarDB:
cursor.execute('DELETE FROM seasons WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def prune_season_episodes_not_in(self, tmdb_id: int, season_number: int, valid_episode_numbers):
"""删除本地该季中不在 valid_episode_numbers 列表内的所有集
参数:
tmdb_id: 节目 TMDB ID
season_number: 季编号
valid_episode_numbers: 允许保留的集号列表
"""
try:
cursor = self.conn.cursor()
# 当 TMDB 返回空集时,表示该季应无集,直接清空该季的 episodes
if not valid_episode_numbers:
cursor.execute('DELETE FROM episodes WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number))
self.conn.commit()
return
# 动态占位符
placeholders = ','.join(['?'] * len(valid_episode_numbers))
params = [tmdb_id, season_number] + [int(x) for x in valid_episode_numbers]
cursor.execute(
f'SELECT episode_number FROM episodes WHERE tmdb_id=? AND season_number=? AND episode_number NOT IN ({placeholders})',
params
)
to_delete = [row[0] for row in (cursor.fetchall() or [])]
if to_delete:
placeholders_del = ','.join(['?'] * len(to_delete))
params_del = [tmdb_id, season_number] + to_delete
cursor.execute(
f'DELETE FROM episodes WHERE tmdb_id=? AND season_number=? AND episode_number IN ({placeholders_del})',
params_del
)
self.conn.commit()
except Exception:
# 静默失败,避免影响主流程
pass
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_show_latest_season_number(self, tmdb_id: int, latest_season_number: int):
"""更新 shows.latest_season_number"""
cursor = self.conn.cursor()
cursor.execute('UPDATE shows SET latest_season_number=? WHERE tmdb_id=?', (latest_season_number, tmdb_id))
self.conn.commit()
def update_show_poster(self, tmdb_id: int, poster_local_path: str):
"""更新节目的海报路径"""
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_show_poster(self, tmdb_id: int, poster_local_path: str, is_custom_poster: int = 0):
"""更新节目的海报路径和自定义标记"""
cursor = self.conn.cursor()
cursor.execute('UPDATE shows SET poster_local_path=? WHERE tmdb_id=?', (poster_local_path, tmdb_id))
cursor.execute('UPDATE shows SET poster_local_path=?, is_custom_poster=? WHERE tmdb_id=?', (poster_local_path, is_custom_poster, tmdb_id))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_all_shows(self):
"""获取所有节目"""
cursor = self.conn.cursor()

View File

@ -4617,6 +4617,29 @@ table.selectable-records .expand-button:hover {
margin-top: 12px !important; /* 从默认的20px减少到12px上移8px */
}
/* 定时规则设置样式 */
.crontab-setting-row > [class*='col-'] {
padding-left: 4px !important;
padding-right: 4px !important;
}
.crontab-setting-row {
margin-left: -4px !important;
margin-right: -4px !important;
}
/* 修复定时规则选项下边距当选项变为多行时最后一个选项的mb-2不应叠加到row mb-2上 */
.crontab-setting-row > [class*='col-']:last-child {
margin-bottom: 0 !important;
}
/* 在中等屏幕和小屏幕上,确保行与行之间的间距正确 */
@media (max-width: 991.98px) {
.crontab-setting-row > [class*='col-'].mb-2 {
margin-bottom: 0.5rem !important;
}
.crontab-setting-row > [class*='col-']:last-child.mb-2 {
margin-bottom: 0 !important;
}
}
/* 任务单元基础样式 */
.task {
position: relative;
@ -6166,6 +6189,12 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
color: #fff !important;
}
/* 调整模态框内“正在验证/搜索中”行的转圈图标左边距,仅影响图标本身 */
#createTaskModal .task-suggestions .dropdown-item.text-muted .spinner-border-sm {
margin-left: 0; /* 在不改变文本左距的情况下将图标视觉左距校正至约8px */
margin-right: 0; /* 图标与文字间距 */
}
/* 创建任务模态框移动端响应式样式 */
@media (max-width: 767.98px) {
#createTaskModal .form-group.row .col-sm-2 {
@ -8298,4 +8327,16 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
padding-top: 0; /* 严格控制视觉高度为 32px */
padding-bottom: 0;
box-sizing: border-box; /* 高度包含内边距与边框,避免被额外撑高 */
}
/* 定时规则 Crontab 标题链接样式 */
.crontab-link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
.crontab-link:hover {
color: var(--focus-border-color);
text-decoration: none;
}

View File

@ -487,20 +487,22 @@
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">定时规则</h2>
<span class="badge badge-pill badge-light">
<a target="_blank" href="https://tool.lu/crontab/" title="Crontab执行时间计算器"><i class="bi bi-question-circle"></i></a>
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/定时规则" title="设置任务的定时、延迟规则与执行周期模式查阅Wiki了解详情"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<div class="row mb-2">
<div class="col-sm-6 pr-1">
<div class="row mb-2 crontab-setting-row">
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">Crontab</span>
<span class="input-group-text">
<a href="https://tool.lu/crontab/" target="_blank" rel="noopener noreferrer" class="crontab-link" title="点击查看Crontab执行时间计算器">Crontab</a>
</span>
</div>
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填">
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填" title="支持标准Crontab表达式例如 0 * * * * 表示在每个整点执行一次">
</div>
</div>
<div class="col-sm-6 pl-1">
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
<div class="input-group" title="添加随机延迟时间定时任务将在0到设定秒数之间随机延迟执行。建议值036000表示不延迟">
<div class="input-group-prepend">
<span class="input-group-text">延迟执行</span>
@ -511,6 +513,17 @@
</div>
</div>
</div>
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
<div class="input-group" title="执行周期判断方式按自选周期执行自选使用任务的执行周期和截止日期判断按任务进度执行自动根据任务进度是否100%判断100%则跳过">
<div class="input-group-prepend">
<span class="input-group-text">执行周期</span>
</div>
<select v-model="formData.execution_mode" class="form-control" @change="onExecutionModeChange">
<option value="manual">按自选周期执行(自选)</option>
<option value="auto">按任务进度执行(自动)</option>
</select>
</div>
</div>
</div>
<div class="row title" title="通知推送支持多个渠道查阅Wiki了解详情">
@ -814,11 +827,11 @@
</div>
</div>
<div class="row title" title="设置任务列表页面的任务信息和任务按钮的显示及排序方式,支持拖拽模块调整显示顺序,个别项目的禁用状态将被应用到所有页面">
<div class="row title" title="设置任务列表、追剧日历等页面的任务按钮和相关信息的显示及排序方式,支持拖拽模块调整显示顺序,个别项目的禁用状态将被应用到所有页面查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">显示设置</h2>
<span class="badge badge-pill badge-light">
<a href="#"><i class="bi bi-question-circle"></i></a>
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/显示设置"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
@ -857,11 +870,11 @@
</div>
<!-- 性能设置 -->
<div class="row title" title="调整文件整理页面的请求参数和缓存时长可提升大文件夹的加载速度和数据刷新效率。合理配置可减少API请求次数同时保证数据及时更新">
<div class="row title" title="配置文件加载、数据缓存和自动刷新的关键参数以兼顾性能与效率查阅Wiki了解详情">
<div class="col">
<h2 style="display: inline-block; font-size: 1.5rem;">性能设置</h2>
<span class="badge badge-pill badge-light">
<a href="#"><i class="bi bi-question-circle"></i></a>
<a target="_blank" href="https://github.com/x1ao4/quark-auto-save-x/wiki/性能设置"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
@ -910,6 +923,14 @@
</div>
</div>
</div>
<div class="col-lg-3 col-md-6 mb-2">
<div class="input-group" title="已播出集数及任务进度自动刷新时间24小时制格式HH:MM。默认值00:00每天0点">
<div class="input-group-prepend">
<span class="input-group-text">播出集数刷新时间</span>
</div>
<input type="text" class="form-control no-spinner" v-model="formData.performance.aired_refresh_time" placeholder="00:00">
</div>
</div>
</div>
<div class="row title" title="QASX API 支持第三方添加任务、开发插件及自动化操作查阅Wiki了解详情">
<div class="col">
@ -1225,12 +1246,16 @@
<label class="col-sm-2 col-form-label">执行周期</label>
<div class="col-sm-10 col-form-label">
<div class="form-check form-check-inline" title="也可用作任务总开关">
<input class="form-check-input" type="checkbox" :checked="task.runweek.length === 7" @change="toggleAllWeekdays(task)" :indeterminate.prop="task.runweek.length > 0 && task.runweek.length < 7">
<label class="form-check-label">全选</label>
<input class="form-check-input" type="checkbox" :checked="task.runweek.length === 7" @change="toggleAllWeekdays(task)" :indeterminate.prop="task.runweek.length > 0 && task.runweek.length < 7" :disabled="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'">
<label class="form-check-label" :class="{'text-muted': (task.execution_mode || formData.execution_mode || 'manual') === 'auto'}">全选</label>
</div>
<div class="form-check form-check-inline" v-for="(day, index) in weekdays" :key="index">
<input class="form-check-input" type="checkbox" v-model="task.runweek" :value="index+1">
<label class="form-check-label" v-html="day"></label>
<input class="form-check-input" type="checkbox" v-model="task.runweek" :value="index+1" :disabled="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'">
<label class="form-check-label" :class="{'text-muted': (task.execution_mode || formData.execution_mode || 'manual') === 'auto'}" v-html="day"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" :checked="(task.execution_mode || formData.execution_mode || 'manual') === 'auto'" @change="task.execution_mode = $event.target.checked ? 'auto' : 'manual'">
<label class="form-check-label">自动</label>
</div>
</div>
</div>
@ -2646,12 +2671,16 @@
<label class="col-sm-2 col-form-label">执行周期</label>
<div class="col-sm-10 col-form-label">
<div class="form-check form-check-inline" title="也可用作任务总开关">
<input class="form-check-input" type="checkbox" :checked="createTask.taskData.runweek.length === 7" @change="toggleAllWeekdays(createTask.taskData)" :indeterminate.prop="createTask.taskData.runweek.length > 0 && createTask.taskData.runweek.length < 7">
<label class="form-check-label">全选</label>
<input class="form-check-input" type="checkbox" :checked="createTask.taskData.runweek.length === 7" @change="toggleAllWeekdays(createTask.taskData)" :indeterminate.prop="createTask.taskData.runweek.length > 0 && createTask.taskData.runweek.length < 7" :disabled="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'">
<label class="form-check-label" :class="{'text-muted': (createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'}">全选</label>
</div>
<div class="form-check form-check-inline" v-for="(day, index) in weekdays" :key="index">
<input class="form-check-input" type="checkbox" v-model="createTask.taskData.runweek" :value="index+1">
<label class="form-check-label" v-html="day"></label>
<input class="form-check-input" type="checkbox" v-model="createTask.taskData.runweek" :value="index+1" :disabled="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'">
<label class="form-check-label" :class="{'text-muted': (createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'}" v-html="day"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" :checked="(createTask.taskData.execution_mode || formData.execution_mode || 'manual') === 'auto'" @change="createTask.taskData.execution_mode = $event.target.checked ? 'auto' : 'manual'">
<label class="form-check-label">自动</label>
</div>
</div>
</div>
@ -2899,7 +2928,9 @@
},
performance: {
// 追剧日历刷新周期默认6小时21600秒
calendar_refresh_interval_seconds: 21600
calendar_refresh_interval_seconds: 21600,
// 已播出集数刷新时间24小时制格式HH:MM默认00:00
aired_refresh_time: "00:00"
},
plugin_config_mode: {
aria2: "independent",
@ -2938,7 +2969,8 @@
sequence_naming: "",
use_sequence_naming: false,
episode_naming: "",
use_episode_naming: false
use_episode_naming: false,
execution_mode: null // 将在openCreateTaskModal中设置为formData.execution_mode
},
run_log: "",
taskDirs: [""],
@ -3237,6 +3269,15 @@
// 任务列表自动检测更新相关
tasklistAutoWatchTimer: null,
tasklistLatestFilesSignature: '',
// 全局 SSE 单例与监听标志
appSSE: null,
appSSEInitialized: false,
calendarSSEListenerAdded: false,
tasklistSSEListenerAdded: false,
// 存放已绑定的事件处理器,便于避免重复绑定
onCalendarChangedHandler: null,
onTasklistChangedHandler: null,
// 兼容旧字段(不再使用独立 SSE 实例)
tasklistSSE: null,
calendarSSE: null,
// 创建任务相关数据
@ -3576,6 +3617,8 @@
activeTab(newValue, oldValue) {
// 如果切换到任务列表页面,则刷新任务最新信息和元数据
if (newValue === 'tasklist') {
// 确保全局 SSE 已建立
try { this.ensureGlobalSSE(); } catch (e) {}
this.loadTaskLatestInfo();
this.loadTasklistMetadata();
// 启动任务列表的后台监听
@ -3586,6 +3629,8 @@
}
// 切换到追剧日历:立刻检查一次并启动后台监听;离开则停止监听
if (newValue === 'calendar') {
// 确保全局 SSE 已建立
try { this.ensureGlobalSSE(); } catch (e) {}
// 立即检查一次若已初始化过监听直接调用tick引用
// 先本地读取一次,立刻应用“已转存”状态(不依赖轮询)
try { this.loadCalendarEpisodesLocal && this.loadCalendarEpisodesLocal(); } catch (e) {}
@ -3633,6 +3678,9 @@
this.fetchUserInfo(); // 获取用户信息
this.fetchAccountsDetail(); // 获取账号详细信息
// 应用级别:在挂载时确保全局 SSE 建立一次
try { this.ensureGlobalSSE(); } catch (e) {}
// 迁移旧的localStorage数据到新格式为每个账号单独存储目录
this.migrateFileManagerFolderData();
@ -3822,6 +3870,88 @@
this.stopCalendarAutoWatch();
},
methods: {
// 计算来源徽标 class多来源时使用 multi-source单来源使用小写来源名
getSourceBadgeClass(source) {
try {
if (!source) return '';
if (typeof source === 'string' && source.indexOf('·') !== -1) return 'multi-source';
return String(source).toLowerCase();
} catch (e) { return ''; }
},
// 启动任务列表轮询兜底(仅在未运行时启动)
startTasklistPollingFallback() {
try {
if (!this.tasklistAutoWatchTimer) {
this.tasklistAutoWatchTimer = setInterval(async () => {
try {
const res = await axios.get('/task_latest_info');
if (res.data && res.data.success) {
const latestFiles = res.data.data.latest_files || {};
const sig = this.calcLatestFilesSignature(latestFiles);
if (sig && sig !== this.tasklistLatestFilesSignature) {
// 更新签名,触发热更新
this.tasklistLatestFilesSignature = sig;
this.taskLatestFiles = latestFiles;
// 重新加载任务元数据,确保海报和元数据能热更新
await this.loadTasklistMetadata();
}
}
} catch (e) {
console.warn('任务列表后台监听检查失败:', e);
}
}, 60000);
}
} catch (e) {}
},
// 确保全局 SSE 单例存在(仅建立一次)
ensureGlobalSSE() {
try {
if (this.appSSEInitialized && this.appSSE) return;
if (!this.appSSE) {
this.appSSE = new EventSource('/api/calendar/stream');
}
this.appSSEInitialized = true;
// 统一 onopenSSE 成功后停止两侧轮询
this.appSSE.onopen = () => {
try {
if (this.calendarAutoWatchTimer) {
clearInterval(this.calendarAutoWatchTimer);
this.calendarAutoWatchTimer = null;
}
} catch (e) {}
try {
if (this.tasklistAutoWatchTimer) {
clearInterval(this.tasklistAutoWatchTimer);
this.tasklistAutoWatchTimer = null;
}
} catch (e) {}
};
// 统一 onerror关闭SSE并回退双侧轮询
this.appSSE.onerror = () => {
try { this.appSSE.close(); } catch (e) {}
this.appSSE = null;
this.appSSEInitialized = false;
// 日历回退:若没有轮询定时器,则恢复轮询并立即执行一次
try {
if (!this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
this.calendarAutoWatchTickRef();
}
} catch (e) {}
// 任务列表回退:若没有轮询定时器,则恢复轮询
try {
if (!this.tasklistAutoWatchTimer) {
this.startTasklistPollingFallback();
}
} catch (e) {}
};
// ping 心跳占位
try { this.appSSE.addEventListener('ping', () => {}); } catch (e) {}
} catch (e) {
// 忽略失败,后续可回退轮询
}
},
// 任务列表海报标题(悬停:#编号 任务名称 · 状态)
getTasklistPosterTitle(task, index) {
try {
@ -4085,34 +4215,7 @@
const t = this.calendar.tasks.find(x => (x.task_name || x.taskname) === taskName);
if (!t || !t.season_counts) return null;
const sc = t.season_counts || {};
// 优先使用 progressByTaskName 映射获取已转存集数(支持基于日期的集数统计)
let transferred = Number(sc.transferred_count || 0);
const byTask = this.calendar && this.calendar.progressByTaskName ? this.calendar.progressByTaskName : {};
if (byTask[taskName]) {
const prog = byTask[taskName] || {};
if (prog.episode_number != null) {
transferred = Number(prog.episode_number) || 0;
} else if (prog.air_date) {
// 仅有日期直接在本地DB剧集里找到"该节目在该日期播出的那一集"的集号
const showName = (t.matched_show_name || t.show_name || '').trim();
if (showName && Array.isArray(this.calendar.episodes) && this.calendar.episodes.length > 0) {
const date = String(prog.air_date).trim();
// 找到该节目在该日期播出的所有集,取最大集号作为"已转存集数"
const candidates = this.calendar.episodes.filter(e => {
return e && (e.show_name || '').trim() === showName && (e.air_date || '').trim() === date;
});
if (candidates.length > 0) {
const maxEp = candidates.reduce((m, e) => {
const n = parseInt(e.episode_number);
return isNaN(n) ? m : Math.max(m, n);
}, 0);
if (maxEp > 0) transferred = maxEp;
}
}
}
}
const transferred = Number(sc.transferred_count || 0);
const aired = Number(sc.aired_count || 0);
const total = Number(sc.total_count || 0);
if (transferred === 0 && aired === 0 && total === 0) return null;
@ -4184,33 +4287,7 @@
getTaskTransferredCount(task) {
try {
if (!task) return 0;
const name = task.task_name || task.taskname || '';
const byTask = this.calendar && this.calendar.progressByTaskName ? this.calendar.progressByTaskName : {};
if (name && byTask[name]) {
const prog = byTask[name] || {};
if (prog.episode_number != null) {
return Number(prog.episode_number) || 0;
}
// 仅有日期直接在本地DB剧集里找到“该节目在该日期播出的那一集”的集号
if (prog.air_date) {
const showName = (task.matched_show_name || task.show_name || '').trim();
if (showName && Array.isArray(this.calendar.episodes) && this.calendar.episodes.length > 0) {
const date = String(prog.air_date).trim();
// 找到该节目在该日期播出的所有集,取最大集号作为“已转存集数”
const candidates = this.calendar.episodes.filter(e => {
return e && (e.show_name || '').trim() === showName && (e.air_date || '').trim() === date;
});
if (candidates.length > 0) {
const maxEp = candidates.reduce((m, e) => {
const n = parseInt(e.episode_number);
return isNaN(n) ? m : Math.max(m, n);
}, 0);
if (maxEp > 0) return maxEp;
}
}
}
}
return Number((task.season_counts && task.season_counts.transferred_count) || 0);
return Number((task && task.season_counts && task.season_counts.transferred_count) || 0);
} catch (e) { return 0; }
},
// 获取管理视图卡片展示用:已播出集数(直接来自任务元数据)
@ -5179,24 +5256,10 @@
return;
}
// 获取最新季数,优先使用任务中的 season_number否则从数据库获取
let season_number = task.season_number;
// 获取季数:仅使用任务匹配到的季(未匹配则不继续)
const season_number = task.matched_latest_season_number;
if (!season_number) {
// 从数据库获取最新季数
try {
const showResponse = await axios.get('/api/calendar/show_info', {
params: { tmdb_id: task.match_tmdb_id }
});
if (showResponse.data.success && showResponse.data.data) {
season_number = showResponse.data.data.latest_season_number;
}
} catch (e) {
console.warn('获取最新季数失败:', e);
}
}
if (!season_number) {
this.showToast('无法获取季数信息');
this.showToast('该任务未匹配季信息');
return;
}
@ -5249,7 +5312,8 @@
const currentName = task.task_name || '';
const currentType = this.getContentTypeCN(task.content_type) || '';
const currentTmdbId = (task.match && task.match.tmdb_id) || task.match_tmdb_id || (task.calendar_info && task.calendar_info.match && task.calendar_info.match.tmdb_id) || '';
const currentSeason = task.season_number || task.matched_latest_season_number || (task.calendar_info && task.calendar_info.match && task.calendar_info.match.latest_season_number) || '';
// 仅使用匹配季:若未匹配则为空,由界面表现为未匹配
const currentSeason = task.matched_latest_season_number || '';
const matchedName = task.matched_show_name || '';
const matchedYear = task.matched_year || '';
@ -5612,7 +5676,7 @@
this.calendarAutoWatchTimer = null;
}
} else {
if (!this.calendarSSE && !this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
if (!this.appSSE && !this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
this.calendarAutoWatchTickRef();
@ -5624,19 +5688,10 @@
window.addEventListener('focus', this.calendarAutoWatchFocusHandler);
document.addEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
// 建立 SSE 连接,实时感知日历数据库变化(成功建立后停用轮询,失败时回退轮询)
// 建立/复用全局 SSE 连接(成功建立后停用轮询,失败时回退轮询)
try {
if (!this.calendarSSE) {
this.calendarSSE = new EventSource('/api/calendar/stream');
// SSE 打开后,停止轮询
this.calendarSSE.onopen = () => {
try {
if (this.calendarAutoWatchTimer) {
clearInterval(this.calendarAutoWatchTimer);
this.calendarAutoWatchTimer = null;
}
} catch (e) {}
};
this.ensureGlobalSSE();
if (this.appSSE && !this.calendarSSEListenerAdded) {
const onChanged = async (ev) => {
try {
// 解析变更原因(后端通过 SSE data 传递)
@ -5705,6 +5760,11 @@
}
} catch (e) {}
// 如果是转存记录相关的通知,立即刷新今日更新数据
if (changeReason === 'transfer_record_created' || changeReason === 'transfer_record_updated' || changeReason === 'batch_rename_completed' || changeReason === 'crontab_task_completed') {
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
}
// 再仅本地读取并热更新日历/海报视图
await this.loadCalendarEpisodesLocal();
this.initializeCalendarDates();
@ -5712,21 +5772,10 @@
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
} catch (e) {}
};
this.calendarSSE.addEventListener('calendar_changed', onChanged);
// 初次连接会收到一次 ping不做处理即可
this.calendarSSE.addEventListener('ping', () => {});
this.calendarSSE.onerror = () => {
try { this.calendarSSE.close(); } catch (e) {}
this.calendarSSE = null;
// 回退:若没有轮询定时器,则恢复轮询
try {
if (!this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
this.calendarAutoWatchTickRef();
}
} catch (e) {}
};
this.onCalendarChangedHandler = onChanged;
this.appSSE.addEventListener('calendar_changed', onChanged);
this.calendarSSEListenerAdded = true;
// onopen/onerror 已集中在 ensureGlobalSSE无需重复设置
}
} catch (e) {
// 忽略 SSE 失败,继续使用轮询
@ -5751,10 +5800,7 @@
document.removeEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
this.calendarAutoWatchVisibilityHandler = null;
}
if (this.calendarSSE) {
try { this.calendarSSE.close(); } catch (e) {}
this.calendarSSE = null;
}
// 不再关闭全局 SSE仅移除本地监听如有需要
} catch (e) {
// ignore
}
@ -6852,11 +6898,15 @@
// cookie兼容
if (typeof config_data.cookie === 'string')
config_data.cookie = [config_data.cookie];
// 添加星期预设
// 添加星期预设和执行周期模式
config_data.tasklist = config_data.tasklist.map(task => {
if (!task.hasOwnProperty('runweek')) {
task.runweek = [1, 2, 3, 4, 5, 6, 7];
}
// 确保execution_mode有默认值
if (!task.hasOwnProperty('execution_mode')) {
task.execution_mode = config_data.execution_mode || 'manual';
}
// 格式化已有的警告信息
if (task.shareurl_ban) {
@ -7055,11 +7105,18 @@
}
// 确保性能设置存在并补默认值(单位:秒)
if (!config_data.performance) {
config_data.performance = { calendar_refresh_interval_seconds: 21600 };
config_data.performance = { calendar_refresh_interval_seconds: 21600, aired_refresh_time: "00:00" };
} else {
if (config_data.performance.calendar_refresh_interval_seconds === undefined || config_data.performance.calendar_refresh_interval_seconds === null) {
config_data.performance.calendar_refresh_interval_seconds = 21600;
}
if (config_data.performance.aired_refresh_time === undefined || config_data.performance.aired_refresh_time === null) {
config_data.performance.aired_refresh_time = "00:00";
}
}
// 确保execution_mode有默认值
if (!config_data.execution_mode) {
config_data.execution_mode = 'manual';
}
// 后端加载配置属于系统性赋值,不应触发未保存提示
this.suppressConfigModifiedOnce = true;
@ -7879,6 +7936,18 @@
task.runweek = [1, 2, 3, 4, 5, 6, 7];
}
},
onExecutionModeChange() {
// 批量更新所有任务的execution_mode
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
this.formData.tasklist.forEach(task => {
if (!task.hasOwnProperty('execution_mode')) {
this.$set(task, 'execution_mode', this.formData.execution_mode);
} else {
task.execution_mode = this.formData.execution_mode;
}
});
}
},
searchSuggestions(index, taskname, deep = 1) {
if (taskname.length < 2) {
return;
@ -9346,17 +9415,9 @@
// 建立 SSE 连接,实时感知任务列表变化(成功建立后停用轮询,失败时回退轮询)
try {
if (!this.tasklistSSE) {
this.tasklistSSE = new EventSource('/api/calendar/stream');
// SSE 打开后,停止轮询
this.tasklistSSE.onopen = () => {
try {
if (this.tasklistAutoWatchTimer) {
clearInterval(this.tasklistAutoWatchTimer);
this.tasklistAutoWatchTimer = null;
}
} catch (e) {}
};
// 使用全局 SSE 单例
this.ensureGlobalSSE();
if (this.appSSE && !this.tasklistSSEListenerAdded) {
const onTasklistChanged = async (ev) => {
try {
// 解析变更原因(后端通过 SSE data 传递)
@ -9401,37 +9462,10 @@
}
} catch (e) {}
};
this.tasklistSSE.addEventListener('calendar_changed', onTasklistChanged);
// 初次连接会收到一次 ping不做处理即可
this.tasklistSSE.addEventListener('ping', () => {});
this.tasklistSSE.onerror = () => {
try { this.tasklistSSE.close(); } catch (e) {}
this.tasklistSSE = null;
// 回退:若没有轮询定时器,则恢复轮询
try {
if (!this.tasklistAutoWatchTimer) {
this.tasklistAutoWatchTimer = setInterval(async () => {
try {
const res = await axios.get('/task_latest_info');
if (res.data && res.data.success) {
const latestFiles = res.data.data.latest_files || {};
const sig = this.calcLatestFilesSignature(latestFiles);
if (sig && sig !== this.tasklistLatestFilesSignature) {
// 更新签名,触发热更新
this.tasklistLatestFilesSignature = sig;
this.taskLatestFiles = latestFiles;
// 重新加载任务元数据,确保海报和元数据能热更新
await this.loadTasklistMetadata();
}
}
} catch (e) {
console.warn('任务列表后台监听检查失败:', e);
}
}, 60000);
}
} catch (e) {}
};
this.onTasklistChangedHandler = onTasklistChanged;
this.appSSE.addEventListener('calendar_changed', onTasklistChanged);
this.tasklistSSEListenerAdded = true;
// onopen/onerror 已集中在 ensureGlobalSSE无需重复设置
}
} catch (e) {
// 忽略 SSE 失败,继续使用轮询
@ -9677,6 +9711,31 @@
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
}
return 0;
} else if (modalType === 'source' || modalType === 'target' || modalType === 'move') {
// 选择文件夹的模态框(选择需转存的文件夹、选择保存到的文件夹、选择移动到的文件夹)
// 文件夹排在文件前面
if (a.dir && !b.dir) return -1;
if (!a.dir && b.dir) return 1;
// 根据类型使用不同的排序键顺序
const kaRaw = sortFileByName(a), kbRaw = sortFileByName(b);
let ka, kb;
if (a.dir && b.dir) {
// 文件夹:日期、上中下、拼音、更新时间(去掉期数/集数)
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
// 调整为:[date_value, segment_value, pinyin_sort_key, update_time]
ka = [kaRaw[0], kaRaw[2], kaRaw[4], kaRaw[3]];
kb = [kbRaw[0], kbRaw[2], kbRaw[4], kbRaw[3]];
} else {
// 文件:日期、期数、上中下、拼音、更新时间
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
// 调整为:[date_value, episode_value, segment_value, pinyin_sort_key, update_time]
ka = [kaRaw[0], kaRaw[1], kaRaw[2], kaRaw[4], kaRaw[3]];
kb = [kbRaw[0], kbRaw[1], kbRaw[2], kbRaw[4], kbRaw[3]];
}
for (let i = 0; i < ka.length; ++i) {
if (ka[i] !== kb[i]) return this.fileSelect.sortOrder === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
}
return 0;
} else {
// 其他模态框:文件夹排在文件前面
if (a.dir && !b.dir) return -1;
@ -9842,6 +9901,31 @@
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
}
return 0;
} else if (modalType === 'source' || modalType === 'target' || modalType === 'move') {
// 选择文件夹的模态框(选择需转存的文件夹、选择保存到的文件夹、选择移动到的文件夹)
// 文件夹排在文件前面
if (a.dir && !b.dir) return -1;
if (!a.dir && b.dir) return 1;
// 根据类型使用不同的排序键顺序
const kaRaw = sortFileByName(a), kbRaw = sortFileByName(b);
let ka, kb;
if (a.dir && b.dir) {
// 文件夹:日期、上中下、拼音、更新时间(去掉期数/集数)
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
// 调整为:[date_value, segment_value, pinyin_sort_key, update_time]
ka = [kaRaw[0], kaRaw[2], kaRaw[4], kaRaw[3]];
kb = [kbRaw[0], kbRaw[2], kbRaw[4], kbRaw[3]];
} else {
// 文件:日期、期数、上中下、拼音、更新时间
// [date_value, episode_value, segment_value, update_time, pinyin_sort_key]
// 调整为:[date_value, episode_value, segment_value, pinyin_sort_key, update_time]
ka = [kaRaw[0], kaRaw[1], kaRaw[2], kaRaw[4], kaRaw[3]];
kb = [kbRaw[0], kbRaw[1], kbRaw[2], kbRaw[4], kbRaw[3]];
}
for (let i = 0; i < ka.length; ++i) {
if (ka[i] !== kb[i]) return order === 'asc' ? (ka[i] > kb[i] ? 1 : -1) : (ka[i] < kb[i] ? 1 : -1);
}
return 0;
} else {
// 其他模态框:文件夹排在文件前面
if (a.dir && !b.dir) return -1;
@ -12032,6 +12116,8 @@
this.createTask.error = null;
// 使用 newTask 的完整结构初始化,再由智能填充覆盖
this.createTask.taskData = { ...this.newTask };
// 应用系统配置的执行周期默认选项
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
// 存储影视作品数据,并提取年份信息
const movieData = { ...item };
@ -12073,6 +12159,8 @@
// 重置任务数据为默认值,使用 newTask 的完整结构
this.createTask.taskData = { ...this.newTask };
// 应用系统配置的执行周期默认选项
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
// 如果有上一个任务,继承保存路径和命名规则
if (this.formData.tasklist && this.formData.tasklist.length > 0) {
@ -12125,6 +12213,10 @@
this.createTask.taskData.ignore_extension = task.ignore_extension || false;
this.createTask.taskData.use_sequence_naming = task.use_sequence_naming || false;
this.createTask.taskData.use_episode_naming = task.use_episode_naming || false;
// 确保execution_mode有默认值
if (!this.createTask.taskData.execution_mode) {
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
}
// 打开模态框
$('#createTaskModal').modal('show');

View File

@ -18,12 +18,12 @@ from datetime import datetime
# 添加数据库导入
try:
from app.sdk.db import RecordDB
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
from app.sdk.db import RecordDB, CalendarDB
except ImportError:
# 定义一个空的RecordDB类以防止导入失败
class RecordDB:
@ -35,6 +35,30 @@ except ImportError:
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):
"""
@ -1002,10 +1026,13 @@ def add_notify(text):
# 检查推送通知类型配置
push_notify_type = CONFIG_DATA.get("push_notify_type", "full")
# 定义关键字(复用于过滤与分隔)
failure_keywords = ["", "", "失败", "失效", "错误", "异常", "无效", "登录失败"]
invalid_keywords = ["分享资源已失效", "分享详情获取失败", "分享为空", "文件已被分享者删除"]
# 如果设置为仅推送成功信息,则过滤掉失败和错误信息
if push_notify_type == "success_only":
# 检查是否包含失败或错误相关的关键词
failure_keywords = ["", "", "失败", "失效", "错误", "异常", "无效", "登录失败"]
if any(keyword in text for keyword in failure_keywords):
# 只打印到控制台,不添加到通知列表
print(text)
@ -1014,12 +1041,20 @@ def add_notify(text):
# 如果设置为排除失效信息,则过滤掉资源失效信息,但保留转存失败信息
elif push_notify_type == "exclude_invalid":
# 检查是否包含资源失效相关的关键词(主要是分享资源失效)
invalid_keywords = ["分享资源已失效", "分享详情获取失败", "分享为空", "文件已被分享者删除"]
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
@ -2013,9 +2048,21 @@ class Quark:
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}")
@ -2075,6 +2122,10 @@ class Quark:
# 关闭数据库连接
db.close()
# 如果更新成功触发SSE通知
if updated > 0:
notify_calendar_changed_safe('transfer_record_updated')
return updated > 0
except Exception as e:
print(f"更新转存记录失败: {e}")
@ -3741,6 +3792,9 @@ class Quark:
# 检查是否需要从分享链接获取数据
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"])
@ -3751,13 +3805,17 @@ class Quark:
# 获取分享详情
is_sharing, stoken = self.get_stoken(pwd_id, passcode)
if not is_sharing:
print(f"分享详情获取失败: {stoken}")
# 如果任务已经有 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:
print("分享为空,文件已被分享者删除")
# 如果任务已经有 shareurl_ban说明已经在 do_save_task 中处理过了,不需要重复输出
if not task.get("shareurl_ban"):
print("分享为空,文件已被分享者删除")
return False, []
# 在剧集命名模式中,需要先对文件列表进行排序,然后再应用起始文件过滤
@ -4421,17 +4479,47 @@ def do_save(account, tasklist=[]):
sent_notices = set()
def is_time(task):
return (
not task.get("enddate")
or (
datetime.now().date()
<= datetime.strptime(task["enddate"], "%Y-%m-%d").date()
# 获取任务的执行周期模式优先使用任务自身的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"))
)
) 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):
@ -4462,14 +4550,74 @@ def do_save(account, tasklist=[]):
print(f"正则替换: {task['replace']}")
if task.get("update_subdir"):
print(f"更新目录: {task['update_subdir']}")
if task.get("runweek") or task.get("enddate"):
print(
f"运行周期: WK{task.get('runweek',[])} ~ {task.get('enddate','forever')}"
)
# 获取任务的执行周期模式优先使用任务自身的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):
print(f"任务不在运行周期内,跳过")
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
@ -5609,6 +5757,14 @@ def do_save(account, tasklist=[]):
# 处理重命名日志,更新数据库记录
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 = []
@ -5684,7 +5840,6 @@ def main():
start_time = datetime.now()
print(f"===============程序开始===============")
print(f"⏰ 执行时间: {start_time.strftime('%Y-%m-%d %H:%M:%S')}")
print()
# 读取启动参数
config_path = sys.argv[1] if len(sys.argv) > 1 else "quark_config.json"
# 从环境变量中获取 TASKLIST
@ -5723,6 +5878,7 @@ def main():
return
accounts = [Quark(cookie, index) for index, cookie in enumerate(cookies)]
# 签到
print()
print(f"===============签到任务===============")
if tasklist_from_env:
verify_account(accounts[0])