Compare commits

..

No commits in common. "d66a4177e65479d110f7cc2ba8d77283007070b6" and "2a77fe8988c49c0175ad6c24e7543919dcacde5d" have entirely different histories.

5 changed files with 275 additions and 2284 deletions

1555
app/run.py

File diff suppressed because it is too large Load Diff

View File

@ -3,34 +3,6 @@ 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"):
@ -42,19 +14,10 @@ class RecordDB:
# 确保目录存在
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
# 创建数据库连接设置超时时间为5秒
self.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=5.0
)
# 创建数据库连接
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
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 (
@ -83,12 +46,8 @@ class RecordDB:
def close(self):
if self.conn:
try:
self.conn.close()
except Exception:
pass
self.conn.close()
@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):
"""添加一条转存记录"""
@ -103,7 +62,6 @@ 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字段
@ -165,7 +123,6 @@ 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):
"""获取转存记录列表,支持分页、排序和筛选
@ -266,7 +223,6 @@ 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()
@ -278,15 +234,13 @@ 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):
"""根据保存路径查询记录
@ -326,18 +280,8 @@ class CalendarDB:
def init_db(self):
os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
# 创建数据库连接设置超时时间为5秒
self.conn = sqlite3.connect(
self.db_path,
check_same_thread=False,
timeout=5.0
)
self.conn = sqlite3.connect(self.db_path, check_same_thread=False)
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('''
@ -359,10 +303,6 @@ 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('''
@ -394,64 +334,18 @@ 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:
try:
self.conn.close()
except Exception:
pass
self.conn.close()
# shows
@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):
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=""):
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, is_custom_poster)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO shows (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id) DO UPDATE SET
name=excluded.name,
year=excluded.year,
@ -460,12 +354,10 @@ 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,
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))
content_type=excluded.content_type
''', (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type))
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()
@ -486,7 +378,6 @@ 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()
@ -504,7 +395,6 @@ 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()
@ -514,7 +404,6 @@ 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()
@ -525,7 +414,6 @@ 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,))
@ -535,7 +423,6 @@ 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,))
@ -544,7 +431,6 @@ 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 字段则补充
@ -565,7 +451,6 @@ 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))
@ -576,7 +461,6 @@ 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('''
@ -592,7 +476,6 @@ 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('''
@ -613,7 +496,6 @@ 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()
@ -635,91 +517,7 @@ 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()
@ -727,7 +525,6 @@ 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()
@ -735,7 +532,6 @@ 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()
@ -746,7 +542,6 @@ 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()
@ -761,67 +556,7 @@ 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()
@ -831,7 +566,6 @@ 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()
@ -839,57 +573,18 @@ 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()
@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):
"""更新节目的海报路径和自定义标记"""
def update_show_poster(self, tmdb_id: int, poster_local_path: str):
"""更新节目的海报路径"""
cursor = self.conn.cursor()
cursor.execute('UPDATE shows SET poster_local_path=?, is_custom_poster=? WHERE tmdb_id=?', (poster_local_path, is_custom_poster, tmdb_id))
cursor.execute('UPDATE shows SET poster_local_path=? WHERE tmdb_id=?', (poster_local_path, 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,29 +4617,6 @@ 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;
@ -6189,12 +6166,6 @@ 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 {
@ -8327,16 +8298,4 @@ 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,22 +487,20 @@
<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://github.com/x1ao4/quark-auto-save-x/wiki/定时规则" title="设置任务的定时、延迟规则与执行周期模式查阅Wiki了解详情"><i class="bi bi-question-circle"></i></a>
<a target="_blank" href="https://tool.lu/crontab/" title="Crontab执行时间计算器"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
<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="row mb-2">
<div class="col-sm-6 pr-1">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<a href="https://tool.lu/crontab/" target="_blank" rel="noopener noreferrer" class="crontab-link" title="点击查看Crontab执行时间计算器">Crontab</a>
</span>
<span class="input-group-text">Crontab</span>
</div>
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填" title="支持标准Crontab表达式例如 0 * * * * 表示在每个整点执行一次">
<input type="text" v-model="formData.crontab" class="form-control" placeholder="必填">
</div>
</div>
<div class="col-lg-4 col-md-6 col-12 mb-2 mb-lg-0">
<div class="col-sm-6 pl-1">
<div class="input-group" title="添加随机延迟时间定时任务将在0到设定秒数之间随机延迟执行。建议值036000表示不延迟">
<div class="input-group-prepend">
<span class="input-group-text">延迟执行</span>
@ -513,17 +511,6 @@
</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了解详情">
@ -827,11 +814,11 @@
</div>
</div>
<div class="row title" title="设置任务列表、追剧日历等页面的任务按钮和相关信息的显示及排序方式,支持拖拽模块调整显示顺序,个别项目的禁用状态将被应用到所有页面查阅Wiki了解详情">
<div class="row title" title="设置任务列表页面的任务信息和任务按钮的显示及排序方式,支持拖拽模块调整显示顺序,个别项目的禁用状态将被应用到所有页面">
<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://github.com/x1ao4/quark-auto-save-x/wiki/显示设置"><i class="bi bi-question-circle"></i></a>
<a href="#"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
@ -870,11 +857,11 @@
</div>
<!-- 性能设置 -->
<div class="row title" title="配置文件加载、数据缓存和自动刷新的关键参数以兼顾性能与效率查阅Wiki了解详情">
<div class="row title" title="调整文件整理页面的请求参数和缓存时长可提升大文件夹的加载速度和数据刷新效率。合理配置可减少API请求次数同时保证数据及时更新">
<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://github.com/x1ao4/quark-auto-save-x/wiki/性能设置"><i class="bi bi-question-circle"></i></a>
<a href="#"><i class="bi bi-question-circle"></i></a>
</span>
</div>
</div>
@ -923,14 +910,6 @@
</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">
@ -1246,16 +1225,12 @@
<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" :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>
<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>
</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" :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>
<input class="form-check-input" type="checkbox" v-model="task.runweek" :value="index+1">
<label class="form-check-label" v-html="day"></label>
</div>
</div>
</div>
@ -2671,16 +2646,12 @@
<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" :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>
<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>
</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" :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>
<input class="form-check-input" type="checkbox" v-model="createTask.taskData.runweek" :value="index+1">
<label class="form-check-label" v-html="day"></label>
</div>
</div>
</div>
@ -2928,9 +2899,7 @@
},
performance: {
// 追剧日历刷新周期默认6小时21600秒
calendar_refresh_interval_seconds: 21600,
// 已播出集数刷新时间24小时制格式HH:MM默认00:00
aired_refresh_time: "00:00"
calendar_refresh_interval_seconds: 21600
},
plugin_config_mode: {
aria2: "independent",
@ -2969,8 +2938,7 @@
sequence_naming: "",
use_sequence_naming: false,
episode_naming: "",
use_episode_naming: false,
execution_mode: null // 将在openCreateTaskModal中设置为formData.execution_mode
use_episode_naming: false
},
run_log: "",
taskDirs: [""],
@ -3269,15 +3237,6 @@
// 任务列表自动检测更新相关
tasklistAutoWatchTimer: null,
tasklistLatestFilesSignature: '',
// 全局 SSE 单例与监听标志
appSSE: null,
appSSEInitialized: false,
calendarSSEListenerAdded: false,
tasklistSSEListenerAdded: false,
// 存放已绑定的事件处理器,便于避免重复绑定
onCalendarChangedHandler: null,
onTasklistChangedHandler: null,
// 兼容旧字段(不再使用独立 SSE 实例)
tasklistSSE: null,
calendarSSE: null,
// 创建任务相关数据
@ -3617,8 +3576,6 @@
activeTab(newValue, oldValue) {
// 如果切换到任务列表页面,则刷新任务最新信息和元数据
if (newValue === 'tasklist') {
// 确保全局 SSE 已建立
try { this.ensureGlobalSSE(); } catch (e) {}
this.loadTaskLatestInfo();
this.loadTasklistMetadata();
// 启动任务列表的后台监听
@ -3629,8 +3586,6 @@
}
// 切换到追剧日历:立刻检查一次并启动后台监听;离开则停止监听
if (newValue === 'calendar') {
// 确保全局 SSE 已建立
try { this.ensureGlobalSSE(); } catch (e) {}
// 立即检查一次若已初始化过监听直接调用tick引用
// 先本地读取一次,立刻应用“已转存”状态(不依赖轮询)
try { this.loadCalendarEpisodesLocal && this.loadCalendarEpisodesLocal(); } catch (e) {}
@ -3678,9 +3633,6 @@
this.fetchUserInfo(); // 获取用户信息
this.fetchAccountsDetail(); // 获取账号详细信息
// 应用级别:在挂载时确保全局 SSE 建立一次
try { this.ensureGlobalSSE(); } catch (e) {}
// 迁移旧的localStorage数据到新格式为每个账号单独存储目录
this.migrateFileManagerFolderData();
@ -3870,88 +3822,6 @@
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 {
@ -4215,7 +4085,34 @@
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 || {};
const transferred = Number(sc.transferred_count || 0);
// 优先使用 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 aired = Number(sc.aired_count || 0);
const total = Number(sc.total_count || 0);
if (transferred === 0 && aired === 0 && total === 0) return null;
@ -4287,7 +4184,33 @@
getTaskTransferredCount(task) {
try {
if (!task) return 0;
return Number((task && task.season_counts && task.season_counts.transferred_count) || 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);
} catch (e) { return 0; }
},
// 获取管理视图卡片展示用:已播出集数(直接来自任务元数据)
@ -5256,10 +5179,24 @@
return;
}
// 获取季数:仅使用任务匹配到的季(未匹配则不继续)
const season_number = task.matched_latest_season_number;
// 获取最新季数,优先使用任务中的 season_number否则从数据库获取
let season_number = task.season_number;
if (!season_number) {
this.showToast('该任务未匹配季信息');
// 从数据库获取最新季数
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('无法获取季数信息');
return;
}
@ -5312,8 +5249,7 @@
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.matched_latest_season_number || '';
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 matchedName = task.matched_show_name || '';
const matchedYear = task.matched_year || '';
@ -5676,7 +5612,7 @@
this.calendarAutoWatchTimer = null;
}
} else {
if (!this.appSSE && !this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
if (!this.calendarSSE && !this.calendarAutoWatchTimer && this.calendarAutoWatchTickRef) {
const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000;
this.calendarAutoWatchTimer = setInterval(this.calendarAutoWatchTickRef, baseIntervalMs);
this.calendarAutoWatchTickRef();
@ -5688,10 +5624,19 @@
window.addEventListener('focus', this.calendarAutoWatchFocusHandler);
document.addEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
// 建立/复用全局 SSE 连接(成功建立后停用轮询,失败时回退轮询)
// 建立 SSE 连接,实时感知日历数据库变化(成功建立后停用轮询,失败时回退轮询)
try {
this.ensureGlobalSSE();
if (this.appSSE && !this.calendarSSEListenerAdded) {
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) {}
};
const onChanged = async (ev) => {
try {
// 解析变更原因(后端通过 SSE data 传递)
@ -5760,11 +5705,6 @@
}
} 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();
@ -5772,10 +5712,21 @@
try { await this.loadTodayUpdatesLocal(); } catch (e) {}
} catch (e) {}
};
this.onCalendarChangedHandler = onChanged;
this.appSSE.addEventListener('calendar_changed', onChanged);
this.calendarSSEListenerAdded = true;
// onopen/onerror 已集中在 ensureGlobalSSE无需重复设置
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) {}
};
}
} catch (e) {
// 忽略 SSE 失败,继续使用轮询
@ -5800,7 +5751,10 @@
document.removeEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler);
this.calendarAutoWatchVisibilityHandler = null;
}
// 不再关闭全局 SSE仅移除本地监听如有需要
if (this.calendarSSE) {
try { this.calendarSSE.close(); } catch (e) {}
this.calendarSSE = null;
}
} catch (e) {
// ignore
}
@ -6898,15 +6852,11 @@
// 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) {
@ -7105,18 +7055,11 @@
}
// 确保性能设置存在并补默认值(单位:秒)
if (!config_data.performance) {
config_data.performance = { calendar_refresh_interval_seconds: 21600, aired_refresh_time: "00:00" };
config_data.performance = { calendar_refresh_interval_seconds: 21600 };
} 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;
@ -7936,18 +7879,6 @@
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;
@ -9415,9 +9346,17 @@
// 建立 SSE 连接,实时感知任务列表变化(成功建立后停用轮询,失败时回退轮询)
try {
// 使用全局 SSE 单例
this.ensureGlobalSSE();
if (this.appSSE && !this.tasklistSSEListenerAdded) {
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) {}
};
const onTasklistChanged = async (ev) => {
try {
// 解析变更原因(后端通过 SSE data 传递)
@ -9462,10 +9401,37 @@
}
} catch (e) {}
};
this.onTasklistChangedHandler = onTasklistChanged;
this.appSSE.addEventListener('calendar_changed', onTasklistChanged);
this.tasklistSSEListenerAdded = true;
// onopen/onerror 已集中在 ensureGlobalSSE无需重复设置
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) {}
};
}
} catch (e) {
// 忽略 SSE 失败,继续使用轮询
@ -9711,31 +9677,6 @@
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;
@ -9901,31 +9842,6 @@
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;
@ -12116,8 +12032,6 @@
this.createTask.error = null;
// 使用 newTask 的完整结构初始化,再由智能填充覆盖
this.createTask.taskData = { ...this.newTask };
// 应用系统配置的执行周期默认选项
this.createTask.taskData.execution_mode = this.formData.execution_mode || 'manual';
// 存储影视作品数据,并提取年份信息
const movieData = { ...item };
@ -12159,8 +12073,6 @@
// 重置任务数据为默认值,使用 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) {
@ -12213,10 +12125,6 @@
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, CalendarDB
from app.sdk.db import RecordDB
except ImportError:
# 如果直接运行脚本,路径可能不同
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
from app.sdk.db import RecordDB, CalendarDB
from app.sdk.db import RecordDB
except ImportError:
# 定义一个空的RecordDB类以防止导入失败
class RecordDB:
@ -35,30 +35,6 @@ 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):
"""
@ -1026,13 +1002,10 @@ 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)
@ -1041,20 +1014,12 @@ 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
@ -2048,21 +2013,9 @@ 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}")
@ -2122,10 +2075,6 @@ 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}")
@ -3792,9 +3741,6 @@ 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"])
@ -3805,17 +3751,13 @@ class Quark:
# 获取分享详情
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}")
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("分享为空,文件已被分享者删除")
print("分享为空,文件已被分享者删除")
return False, []
# 在剧集命名模式中,需要先对文件列表进行排序,然后再应用起始文件过滤
@ -4479,47 +4421,17 @@ def do_save(account, tasklist=[]):
sent_notices = set()
def is_time(task):
# 获取任务的执行周期模式优先使用任务自身的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"))
return (
not task.get("enddate")
or (
datetime.now().date()
<= datetime.strptime(task["enddate"], "%Y-%m-%d").date()
)
# 默认返回True兼容未知模式
return True
) and (
"runweek" not in task
# 星期一为0星期日为6
or (datetime.today().weekday() + 1 in task.get("runweek"))
)
# 执行任务
for index, task in enumerate(tasklist):
@ -4550,74 +4462,14 @@ def do_save(account, tasklist=[]):
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')}"
)
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"任务不在执行周期内,跳过")
print(f"任务不在运行周期内,跳过")
else:
# 保存之前的通知信息
global NOTIFYS
@ -5757,14 +5609,6 @@ 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 = []
@ -5840,6 +5684,7 @@ 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
@ -5878,7 +5723,6 @@ def main():
return
accounts = [Quark(cookie, index) for index, cookie in enumerate(cookies)]
# 签到
print()
print(f"===============签到任务===============")
if tasklist_from_env:
verify_account(accounts[0])