mirror of
https://github.com/Cp0204/quark-auto-save.git
synced 2026-01-12 07:10:44 +08:00
Compare commits
29 Commits
2a77fe8988
...
d66a4177e6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d66a4177e6 | ||
|
|
b420029771 | ||
|
|
e69ad46ddd | ||
|
|
4950b53e03 | ||
|
|
7a15bb1758 | ||
|
|
3e5ce67eab | ||
|
|
ba829ffacc | ||
|
|
fb794ac08f | ||
|
|
20add4c668 | ||
|
|
099581956e | ||
|
|
ff5a4cba9a | ||
|
|
a05ed17596 | ||
|
|
e7be8f5738 | ||
|
|
c5df061549 | ||
|
|
7a5d47b214 | ||
|
|
b7a6b1e35f | ||
|
|
d213b62071 | ||
|
|
98c0d9f938 | ||
|
|
08553f99ca | ||
|
|
874a09d57b | ||
|
|
eb74b6314a | ||
|
|
4240ff9066 | ||
|
|
7d5f297b70 | ||
|
|
e4c14ef3a1 | ||
|
|
643ee8e592 | ||
|
|
593ff1f728 | ||
|
|
080c73fc07 | ||
|
|
2d944600e6 | ||
|
|
3898c6d41c |
1555
app/run.py
1555
app/run.py
File diff suppressed because it is too large
Load Diff
333
app/sdk/db.py
333
app/sdk/db.py
@ -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()
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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到设定秒数之间随机延迟执行。建议值:0–3600,0表示不延迟">
|
||||
<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;
|
||||
// 统一 onopen:SSE 成功后停止两侧轮询
|
||||
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');
|
||||
|
||||
@ -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])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user