diff --git a/app/run.py b/app/run.py index 92502cf..5ed3d63 100644 --- a/app/run.py +++ b/app/run.py @@ -15,6 +15,8 @@ from flask import ( ) from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger +from queue import Queue from sdk.cloudsaver import CloudSaver try: from sdk.pansou import PanSou @@ -47,6 +49,172 @@ from quark_auto_save import extract_episode_number, sort_file_by_name, chinese_t # 导入豆瓣服务 from sdk.douban_service import douban_service +# 导入追剧日历相关模块 +from utils.task_extractor import TaskExtractor +from sdk.tmdb_service import TMDBService +from sdk.db import CalendarDB +def enrich_tasks_with_calendar_meta(tasks_info: list) -> list: + """为任务列表注入日历相关元数据:节目状态、最新季处理后名称、已转存/已播出/本季总集数。 + 返回新的任务字典列表,增加以下字段: + - matched_status: 节目状态(如 播出中 等) + - latest_season_name: 最新季名称(处理后) + - season_counts: { transferred_count, aired_count, total_count } + """ + try: + if not tasks_info: + return tasks_info + db = CalendarDB() + # 预取 shows 与 seasons 数据 + cur = db.conn.cursor() + cur.execute('SELECT tmdb_id, status, latest_season_number FROM shows') + rows = cur.fetchall() or [] + show_meta = {int(r[0]): {'status': r[1], 'latest_season_number': int(r[2])} for r in rows} + + cur.execute('SELECT tmdb_id, season_number, season_name, episode_count FROM seasons') + rows = cur.fetchall() or [] + season_meta = {} + for tid, sn, sname, ecount in rows: + season_meta[(int(tid), int(sn))] = {'season_name': sname or '', 'episode_count': int(ecount or 0)} + + # 统计“已转存集数”:基于转存记录最新进度构建映射(按任务名) + transferred_by_task = {} + try: + rdb = RecordDB() + cursor = rdb.conn.cursor() + cursor.execute( + """ + SELECT task_name, MAX(transfer_time) as latest_transfer_time + FROM transfer_records + WHERE task_name NOT IN ('rename', 'undo_rename') + GROUP BY task_name + """ + ) + latest_times = cursor.fetchall() or [] + extractor = TaskExtractor() + for task_name, latest_time in latest_times: + if latest_time: + cursor.execute( + """ + SELECT renamed_to, original_name, transfer_time, modify_date + FROM transfer_records + WHERE task_name = ? AND transfer_time >= ? AND transfer_time <= ? + ORDER BY id DESC + """, + (task_name, latest_time - 60000, latest_time + 60000) + ) + files = cursor.fetchall() or [] + best = files[0][0] if files else '' + if len(files) > 1: + file_list = [{'file_name': f[0], 'original_name': f[1], 'updated_at': f[2]} for f in files] + try: + best = sorted(file_list, key=sort_file_by_name)[-1]['file_name'] + except Exception: + best = files[0][0] + if best: + name_wo_ext = os.path.splitext(best)[0] + processed = process_season_episode_info(name_wo_ext, task_name) + parsed = extractor.extract_progress_from_latest_file(processed) + if parsed and parsed.get('episode_number'): + transferred_by_task[task_name] = int(parsed['episode_number']) + rdb.close() + except Exception: + transferred_by_task = {} + + # 统计“已播出集数”:读取本地 episodes 表中有 air_date 且 <= 今天的集数 + from datetime import datetime as _dt + today = _dt.now().strftime('%Y-%m-%d') + aired_by_show_season = {} + try: + cur.execute( + """ + SELECT tmdb_id, season_number, COUNT(1) FROM episodes + WHERE air_date IS NOT NULL AND air_date != '' AND air_date <= ? + GROUP BY tmdb_id, season_number + """, + (today,) + ) + for tid, sn, cnt in (cur.fetchall() or []): + aired_by_show_season[(int(tid), int(sn))] = int(cnt or 0) + except Exception: + aired_by_show_season = {} + + # 注入到任务 + enriched = [] + for t in tasks_info: + tmdb_id = t.get('match_tmdb_id') or ((t.get('calendar_info') or {}).get('match') or {}).get('tmdb_id') + task_name = t.get('task_name') or t.get('taskname') or '' + status = '' + latest_sn = None + latest_season_name = '' + total_count = None + transferred_count = None + aired_count = None + + try: + if tmdb_id and int(tmdb_id) in show_meta: + meta = show_meta[int(tmdb_id)] + status = meta.get('status') or '' + latest_sn = int(meta.get('latest_season_number') or 0) + sm = season_meta.get((int(tmdb_id), latest_sn)) or {} + latest_season_name = sm.get('season_name') or '' + total_count = sm.get('episode_count') + aired_count = aired_by_show_season.get((int(tmdb_id), latest_sn)) + except Exception: + pass + + if task_name in transferred_by_task: + transferred_count = transferred_by_task[task_name] + + # 将状态本地化:returning_series 用本地已播/总集数判断;其余通过 TMDBService 的单表映射 + try: + raw = (status or '').strip() + key = raw.lower().replace(' ', '_') + if key == 'returning_series': + # 新规则:存在 finale 类型 且 已播出集数 ≥ 本季总集数 => 本季终;否则 播出中 + has_finale = False + try: + if tmdb_id and latest_sn: + cur.execute( + """ + SELECT 1 FROM episodes + WHERE tmdb_id=? AND season_number=? AND LOWER(COALESCE(type, '')) LIKE '%finale%' + LIMIT 1 + """, + (int(tmdb_id), int(latest_sn)) + ) + has_finale = cur.fetchone() is not None + except Exception: + has_finale = False + + try: + aired = int(aired_count or 0) + total = int(total_count or 0) + except Exception: + aired, total = 0, 0 + + status = '本季终' if (has_finale and total > 0 and aired >= total) else '播出中' + else: + try: + # 仅用到静态映射,不触发网络请求 + _svc = TMDBService(config_data.get('tmdb_api_key', '')) + status = _svc.map_show_status_cn(raw) + except Exception: + status = raw + except Exception: + pass + + t['matched_status'] = status + t['latest_season_name'] = latest_season_name + t['season_counts'] = { + 'transferred_count': transferred_count or 0, + 'aired_count': aired_count or 0, + 'total_count': total_count or 0, + } + enriched.append(t) + return enriched + except Exception: + return tasks_info + def advanced_filter_files(file_list, filterwords): """ 高级过滤函数,支持保留词和过滤词 @@ -391,6 +559,78 @@ logging.basicConfig( if not DEBUG: logging.getLogger("werkzeug").setLevel(logging.ERROR) +# 统一所有已有处理器(包括 werkzeug、apscheduler)的日志格式 +_root_logger = logging.getLogger() +_standard_formatter = logging.Formatter( + fmt="[%(asctime)s][%(levelname)s] %(message)s", + datefmt="%m-%d %H:%M:%S", +) +for _handler in list(_root_logger.handlers): + try: + _handler.setFormatter(_standard_formatter) + except Exception: + pass + +for _name in ("werkzeug", "apscheduler", "gunicorn.error", "gunicorn.access"): + _logger = logging.getLogger(_name) + for _handler in list(_logger.handlers): + try: + _handler.setFormatter(_standard_formatter) + except Exception: + pass + + +# 缓存目录:放在 /app/config/cache 下,用户无需新增映射 +CACHE_DIR = os.path.abspath(os.path.join(app.root_path, '..', 'config', 'cache')) +CACHE_IMAGES_DIR = os.path.join(CACHE_DIR, 'images') +os.makedirs(CACHE_IMAGES_DIR, exist_ok=True) + + +# --------- 追剧日历:SSE 事件中心,用于实时通知前端 DB 变化 --------- +calendar_subscribers = set() + +def notify_calendar_changed(reason: str = ""): + try: + for q in list(calendar_subscribers): + try: + q.put_nowait({'type': 'calendar_changed', 'reason': reason}) + except Exception: + pass + except Exception: + pass + +@app.route('/api/calendar/stream') +def calendar_stream(): + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + def event_stream(): + q = Queue() + calendar_subscribers.add(q) + try: + # 连接建立后立即发一条心跳,便于前端立刻触发一次检查 + yield f"event: ping\n" f"data: connected\n\n" + import time as _t + last_heartbeat = _t.time() + while True: + try: + item = q.get(timeout=15) + yield f"event: calendar_changed\n" f"data: {json.dumps(item, ensure_ascii=False)}\n\n" + except Exception: + # 心跳 + now = _t.time() + if now - last_heartbeat >= 15: + last_heartbeat = now + yield f"event: ping\n" f"data: keepalive\n\n" + except GeneratorExit: + pass + finally: + try: + calendar_subscribers.discard(q) + except Exception: + pass + + return Response(stream_with_context(event_stream()), content_type='text/event-stream;charset=utf-8') def gen_md5(string): md5 = hashlib.md5() @@ -410,9 +650,11 @@ def is_login(): return True else: return False +DEFAULT_REFRESH_SECONDS = 21600 -# 设置icon + +# 设置icon 及缓存静态映射 @app.route("/favicon.ico") def favicon(): return send_from_directory( @@ -421,6 +663,11 @@ def favicon(): mimetype="image/vnd.microsoft.icon", ) +# 将 /cache/images/* 映射到宿主缓存目录,供前端访问 +@app.route('/cache/images/') +def serve_cache_images(filename): + return send_from_directory(CACHE_IMAGES_DIR, filename) + # 登录页面 @app.route("/login", methods=["GET", "POST"]) @@ -469,12 +716,22 @@ def index(): ) +# 已移除图片代理逻辑(测试版不再需要跨域代理) + + # 获取配置数据 @app.route("/data") def get_data(): if not is_login(): return jsonify({"success": False, "message": "未登录"}) data = Config.read_json(CONFIG_PATH) + # 确保有秒级刷新默认值(不做迁移逻辑) + perf = data.get('performance') if isinstance(data, dict) else None + if not isinstance(perf, dict): + data['performance'] = {'calendar_refresh_interval_seconds': DEFAULT_REFRESH_SECONDS} + else: + if 'calendar_refresh_interval_seconds' not in perf: + data['performance']['calendar_refresh_interval_seconds'] = DEFAULT_REFRESH_SECONDS # 处理插件配置中的多账号支持字段,将数组格式转换为逗号分隔的字符串用于显示 if "plugins" in data: @@ -519,6 +776,10 @@ def get_data(): if "push_notify_type" not in data: data["push_notify_type"] = "full" + # 初始化TMDB配置(如果不存在) + if "tmdb_api_key" not in data: + data["tmdb_api_key"] = "" + # 初始化搜索来源默认结构 if "source" not in data or not isinstance(data.get("source"), dict): data["source"] = {} @@ -627,6 +888,17 @@ def update(): global config_data if not is_login(): return jsonify({"success": False, "message": "未登录"}) + # 保存前先记录旧任务名与其 tmdb 绑定,用于自动清理 + old_tasks = config_data.get("tasklist", []) + old_task_map = {} + for t in old_tasks: + name = t.get("taskname") or t.get("task_name") or "" + cal = (t.get("calendar_info") or {}) + match = (cal.get("match") or {}) + if name: + old_task_map[name] = { + "tmdb_id": match.get("tmdb_id") or cal.get("tmdb_id") + } dont_save_keys = ["task_plugins_config_default", "api_token"] for key, value in request.json.items(): if key not in dont_save_keys: @@ -647,17 +919,114 @@ def update(): ) config_data.update({key: value}) + elif key == "tmdb_api_key": + # 更新TMDB API密钥 + config_data[key] = value else: config_data.update({key: value}) # 同步更新任务的插件配置 sync_task_plugins_config() + # 保存配置的执行步骤,异步执行 + try: + prev_tmdb = (config_data.get('tmdb_api_key') or '').strip() + new_tmdb = None + for key, value in request.json.items(): + if key == 'tmdb_api_key': + new_tmdb = (value or '').strip() + break + except Exception: + prev_tmdb, new_tmdb = '', None + + import threading + def _post_update_tasks(old_task_map_snapshot, prev_tmdb_value, new_tmdb_value): + # 在保存配置时自动进行任务信息提取与TMDB匹配(若缺失则补齐) + try: + changed = ensure_calendar_info_for_tasks() + except Exception as e: + logging.warning(f"ensure_calendar_info_for_tasks 发生异常: {e}") + + # 在清理逻辑之前,先同步数据库绑定关系到任务配置 + try: + sync_task_config_with_database_bindings() + except Exception as e: + logging.warning(f"同步任务配置失败: {e}") + + # 同步内容类型数据 + try: + sync_content_type_between_config_and_database() + except Exception as e: + logging.warning(f"同步内容类型失败: {e}") + + # 基于任务变更进行自动清理:删除的任务、或 tmdb 绑定变更的旧绑定 + try: + current_tasks = config_data.get('tasklist', []) + current_map = {} + for t in current_tasks: + name = t.get('taskname') or t.get('task_name') or '' + cal = (t.get('calendar_info') or {}) + match = (cal.get('match') or {}) + if name: + current_map[name] = { + 'tmdb_id': match.get('tmdb_id') or cal.get('tmdb_id') + } + # 被删除的任务 + for name, info in (old_task_map_snapshot or {}).items(): + if name not in current_map: + tid = info.get('tmdb_id') + if tid: + purge_calendar_by_tmdb_id_internal(int(tid)) + # 绑定变更:旧与新 tmdb_id 不同,清理旧的 + for name, info in (old_task_map_snapshot or {}).items(): + if name in current_map: + old_tid = info.get('tmdb_id') + new_tid = current_map[name].get('tmdb_id') + if old_tid and old_tid != new_tid: + purge_calendar_by_tmdb_id_internal(int(old_tid)) + except Exception as e: + logging.warning(f"自动清理本地日历缓存失败: {e}") + + + # 根据最新性能配置重启自动刷新任务 + try: + restart_calendar_refresh_job() + except Exception as e: + logging.warning(f"重启追剧日历自动刷新任务失败: {e}") + + # 如果 TMDB API 从无到有,自动执行一次 bootstrap + try: + if (prev_tmdb_value == '' or prev_tmdb_value is None) and new_tmdb_value and new_tmdb_value != '': + success, msg = do_calendar_bootstrap() + logging.info(f"首次配置 TMDB API,自动初始化: {success}, {msg}") + else: + # 若此次更新产生了新的匹配绑定,也执行一次轻量初始化,确保前端能立刻读到本地数据 + try: + if 'changed' in locals() and changed: + ok, m = do_calendar_bootstrap() + logging.info(f"配置更新触发日历初始化: {ok}, {m}") + except Exception as _e: + logging.warning(f"配置更新触发日历初始化失败: {_e}") + except Exception as e: + logging.warning(f"自动初始化 bootstrap 失败: {e}") + + threading.Thread(target=_post_update_tasks, args=(old_task_map, prev_tmdb, new_tmdb), daemon=True).start() + + # 确保性能配置包含秒级字段 + if not isinstance(config_data.get('performance'), dict): + config_data['performance'] = {'calendar_refresh_interval_seconds': DEFAULT_REFRESH_SECONDS} + else: + config_data['performance'].setdefault('calendar_refresh_interval_seconds', DEFAULT_REFRESH_SECONDS) Config.write_json(CONFIG_PATH, config_data) # 更新session token,确保当前会话在用户名密码更改后仍然有效 session["token"] = get_login_token() # 重新加载任务 if reload_tasks(): + # 通知前端任务数据已更新,触发内容管理页面热更新 + try: + notify_calendar_changed('task_updated') + except Exception: + pass logging.info(f">>> 配置更新成功") return jsonify({"success": True, "message": "配置更新成功"}) else: @@ -710,6 +1079,342 @@ def run_script_now(): ) +# -------------------- 追剧日历:任务提取与匹配辅助 -------------------- +def purge_calendar_by_tmdb_id_internal(tmdb_id: int, force: bool = False) -> bool: + """内部工具:按 tmdb_id 清理 shows/seasons/episodes,并尝试删除本地海报文件 + :param force: True 时无视是否仍被其他任务引用,强制删除 + """ + try: + # 若仍有其他任务引用同一 tmdb_id,则(默认)跳过删除以避免误删共享资源;带 force 则继续 + if not force: + try: + tasks = config_data.get('tasklist', []) + for t in tasks: + cal = (t.get('calendar_info') or {}) + match = (cal.get('match') or {}) + bound = match.get('tmdb_id') or cal.get('tmdb_id') + if bound and int(bound) == int(tmdb_id): + logging.info(f"跳过清理 tmdb_id={tmdb_id}(仍被其他任务引用)") + return True + except Exception: + pass + db = CalendarDB() + # 删除数据库前,尝试删除本地海报文件 + try: + show = db.get_show(int(tmdb_id)) + poster_rel = (show or {}).get('poster_local_path') or '' + if poster_rel and poster_rel.startswith('/cache/images/'): + fname = poster_rel.replace('/cache/images/', '') + fpath = os.path.join(CACHE_IMAGES_DIR, fname) + if os.path.exists(fpath): + os.remove(fpath) + except Exception as e: + logging.warning(f"删除海报文件失败: {e}") + db.delete_show(int(tmdb_id)) + return True + except Exception as e: + logging.warning(f"内部清理失败 tmdb_id={tmdb_id}: {e}") + return False + + +def purge_orphan_calendar_shows_internal() -> int: + """删除未被任何任务引用的 shows(连带 seasons/episodes 与海报),返回清理数量""" + try: + tasks = config_data.get('tasklist', []) + referenced = set() + for t in tasks: + cal = (t.get('calendar_info') or {}) + match = (cal.get('match') or {}) + tid = match.get('tmdb_id') or cal.get('tmdb_id') + if tid: + try: referenced.add(int(tid)) + except Exception: pass + db = CalendarDB() + cur = db.conn.cursor() + try: + cur.execute('SELECT tmdb_id FROM shows') + ids = [int(r[0]) for r in cur.fetchall()] + except Exception: + ids = [] + removed = 0 + for tid in ids: + if tid not in referenced: + if purge_calendar_by_tmdb_id_internal(int(tid), force=True): + removed += 1 + return removed + except Exception: + return 0 +def sync_content_type_between_config_and_database() -> bool: + """同步任务配置和数据库之间的内容类型数据。 + 确保两个数据源的 content_type 保持一致。 + """ + try: + from app.sdk.db import CalendarDB + cal_db = CalendarDB() + + tasks = config_data.get('tasklist', []) + changed = False + synced_count = 0 + + for task in tasks: + task_name = task.get('taskname') or task.get('task_name') or '' + if not task_name: + continue + + # 获取任务配置中的内容类型 + cal = task.get('calendar_info') or {} + extracted = cal.get('extracted') or {} + config_content_type = extracted.get('content_type', '') + + # 通过任务名称查找绑定的节目 + bound_show = cal_db.get_show_by_task_name(task_name) + + if bound_show: + # 任务已绑定到数据库中的节目 + tmdb_id = bound_show['tmdb_id'] + db_content_type = bound_show.get('content_type', '') + + # 如果数据库中没有内容类型,但任务配置中有,则同步到数据库 + if not db_content_type and config_content_type: + cal_db.update_show_content_type(tmdb_id, config_content_type) + changed = True + synced_count += 1 + logging.debug(f"同步内容类型到数据库 - 任务: '{task_name}', 类型: '{config_content_type}', tmdb_id: {tmdb_id}") + + # 若两边不一致:以任务配置优先,同步到数据库 + elif db_content_type and config_content_type and db_content_type != config_content_type: + cal_db.update_show_content_type(tmdb_id, config_content_type) + changed = True + synced_count += 1 + logging.debug(f"覆盖数据库内容类型为任务配置值 - 任务: '{task_name}', 配置: '{config_content_type}', 原DB: '{db_content_type}', tmdb_id: {tmdb_id}") + # 如果数据库有类型但任务配置没有,则回填到配置 + elif db_content_type and not config_content_type: + if 'calendar_info' not in task: + task['calendar_info'] = {} + if 'extracted' not in task['calendar_info']: + task['calendar_info']['extracted'] = {} + task['calendar_info']['extracted']['content_type'] = db_content_type + changed = True + synced_count += 1 + logging.debug(f"同步内容类型到任务配置 - 任务: '{task_name}', 类型: '{db_content_type}', tmdb_id: {tmdb_id}") + else: + # 任务没有绑定到数据库中的节目,但任务配置中有内容类型 + # 这种情况需要先通过 TMDB 匹配建立绑定关系,然后同步内容类型 + if config_content_type: + # 检查任务是否有 TMDB 匹配信息 + match = cal.get('match') or {} + tmdb_id = match.get('tmdb_id') + + if tmdb_id: + # 任务有 TMDB 匹配信息,检查数据库中是否有对应的节目 + show = cal_db.get_show(tmdb_id) + if show: + # 节目存在,建立绑定关系并设置内容类型 + cal_db.bind_task_to_show(tmdb_id, task_name) + cal_db.update_show_content_type(tmdb_id, config_content_type) + changed = True + synced_count += 1 + logging.debug(f"建立绑定关系并同步内容类型 - 任务: '{task_name}', 类型: '{config_content_type}', tmdb_id: {tmdb_id}") + else: + logging.debug(f"任务有 TMDB 匹配信息但数据库中无对应节目 - 任务: '{task_name}', tmdb_id: {tmdb_id}") + else: + logging.debug(f"任务无 TMDB 匹配信息,无法建立绑定关系 - 任务: '{task_name}'") + + if changed: + logging.debug(f"内容类型同步完成,共同步了 {synced_count} 个任务") + Config.write_json(CONFIG_PATH, config_data) + + return changed + + except Exception as e: + logging.debug(f"内容类型同步失败: {e}") + return False + +def sync_task_config_with_database_bindings() -> bool: + """双向同步任务配置和数据库之间的 TMDB 匹配信息。 + 确保两个数据源的 TMDB 绑定关系保持一致。 + """ + try: + from app.sdk.db import CalendarDB + cal_db = CalendarDB() + + tasks = config_data.get('tasklist', []) + changed = False + synced_count = 0 + + for task in tasks: + task_name = task.get('taskname') or task.get('task_name') or '' + if not task_name: + continue + + # 获取任务配置中的 TMDB 匹配信息 + cal = task.get('calendar_info') or {} + match = cal.get('match') or {} + config_tmdb_id = match.get('tmdb_id') + + # 通过任务名称查找数据库中的绑定关系 + bound_show = cal_db.get_show_by_task_name(task_name) + db_tmdb_id = bound_show['tmdb_id'] if bound_show else None + + # 双向同步逻辑 + if config_tmdb_id and not db_tmdb_id: + # 任务配置中有 TMDB ID,但数据库中没有绑定关系 → 同步到数据库 + # 首先检查该 TMDB ID 对应的节目是否存在 + show = cal_db.get_show(config_tmdb_id) + if show: + # 节目存在,建立绑定关系 + cal_db.bind_task_to_show(config_tmdb_id, task_name) + changed = True + synced_count += 1 + logging.debug(f"同步绑定关系到数据库 - 任务: '{task_name}', tmdb_id: {config_tmdb_id}, 节目: '{show.get('name', '')}'") + else: + logging.debug(f"任务配置中的 TMDB ID 在数据库中不存在 - 任务: '{task_name}', tmdb_id: {config_tmdb_id}") + + elif db_tmdb_id and not config_tmdb_id: + # 数据库中有绑定关系,但任务配置中没有 TMDB ID → 同步到任务配置 + show = cal_db.get_show(db_tmdb_id) + if show: + # 确保 calendar_info 结构存在 + if 'calendar_info' not in task: + task['calendar_info'] = {} + if 'match' not in task['calendar_info']: + task['calendar_info']['match'] = {} + + # 同步数据库信息到任务配置 + latest_season_number = show.get('latest_season_number', 1) # 使用数据库中的实际季数 + task['calendar_info']['match'].update({ + 'tmdb_id': db_tmdb_id, + 'matched_show_name': show.get('name', ''), + 'matched_year': show.get('year', ''), + 'latest_season_fetch_url': f"/tv/{db_tmdb_id}/season/{latest_season_number}", + 'latest_season_number': latest_season_number + }) + + changed = True + synced_count += 1 + logging.debug(f"同步 TMDB 匹配信息到任务配置 - 任务: '{task_name}', tmdb_id: {db_tmdb_id}, 节目: '{show.get('name', '')}'") + + elif config_tmdb_id and db_tmdb_id and config_tmdb_id != db_tmdb_id: + # 两个数据源都有 TMDB ID,但不一致 → 以数据库为准(因为数据库是权威数据源) + show = cal_db.get_show(db_tmdb_id) + if show: + # 确保 calendar_info 结构存在 + if 'calendar_info' not in task: + task['calendar_info'] = {} + if 'match' not in task['calendar_info']: + task['calendar_info']['match'] = {} + + # 以数据库为准,更新任务配置 + latest_season_number = show.get('latest_season_number', 1) # 使用数据库中的实际季数 + task['calendar_info']['match'].update({ + 'tmdb_id': db_tmdb_id, + 'matched_show_name': show.get('name', ''), + 'matched_year': show.get('year', ''), + 'latest_season_fetch_url': f"/tv/{db_tmdb_id}/season/{latest_season_number}", + 'latest_season_number': latest_season_number + }) + + changed = True + synced_count += 1 + logging.debug(f"统一 TMDB 匹配信息(以数据库为准) - 任务: '{task_name}', 原配置: {config_tmdb_id}, 数据库: {db_tmdb_id}, 节目: '{show.get('name', '')}'") + + if changed: + logging.debug(f"TMDB 匹配信息双向同步完成,共同步了 {synced_count} 个任务") + Config.write_json(CONFIG_PATH, config_data) + + return changed + + except Exception as e: + logging.debug(f"TMDB 匹配信息同步失败: {e}") + return False + +def ensure_calendar_info_for_tasks() -> bool: + """为所有任务确保存在 calendar_info.extracted 与 calendar_info.match 信息。 + - extracted: 从任务保存路径/名称提取的剧名、年份、类型(只在缺失时提取) + - match: 使用 TMDB 进行匹配(只在缺失时匹配) + """ + tasks = config_data.get('tasklist', []) + if not tasks: + return + + extractor = TaskExtractor() + tmdb_api_key = config_data.get('tmdb_api_key', '') + tmdb_service = TMDBService(tmdb_api_key) if tmdb_api_key else None + + changed = False + # 简单去重缓存,避免同一名称/年份重复请求 TMDB + search_cache = {} + details_cache = {} + for task in tasks: + # 跳过标记为无需参与日历匹配的任务 + if task.get('skip_calendar') is True: + continue + task_name = task.get('taskname') or task.get('task_name') or '' + save_path = task.get('savepath', '') + + cal = task.get('calendar_info') or {} + extracted = cal.get('extracted') or {} + + # 提取 extracted(仅在缺失时) + need_extract = not extracted or not extracted.get('show_name') + if need_extract: + info = extractor.extract_show_info_from_path(save_path) + extracted = { + 'show_name': info.get('show_name', ''), + 'year': info.get('year', ''), + 'content_type': info.get('type', 'other'), + } + cal['extracted'] = extracted + changed = True + + # 匹配 TMDB(仅在缺失时) + match = cal.get('match') or {} + if tmdb_service and not match.get('tmdb_id') and extracted.get('show_name'): + try: + key = (extracted.get('show_name') or '', extracted.get('year') or '') + if key in search_cache: + search = search_cache[key] + else: + search = tmdb_service.search_tv_show(extracted.get('show_name'), extracted.get('year') or None) + search_cache[key] = search + if search and search.get('id'): + tmdb_id = search['id'] + if tmdb_id in details_cache: + details = details_cache[tmdb_id] + else: + details = tmdb_service.get_tv_show_details(tmdb_id) or {} + details_cache[tmdb_id] = details + seasons = details.get('seasons', []) + latest_season_number = 0 + for s in seasons: + sn = s.get('season_number', 0) + if sn and sn > latest_season_number: + latest_season_number = sn + if latest_season_number == 0 and seasons: + latest_season_number = seasons[-1].get('season_number', 1) + + # 使用新的方法获取中文标题,支持从别名中获取中国地区的别名 + chinese_title = tmdb_service.get_chinese_title_with_fallback(tmdb_id, extracted.get('show_name', '')) + + cal['match'] = { + 'matched_show_name': chinese_title, + 'matched_year': (details.get('first_air_date') or '')[:4], + 'tmdb_id': tmdb_id, + 'latest_season_number': latest_season_number, + 'latest_season_fetch_url': f"/tv/{tmdb_id}/season/{latest_season_number}", + } + changed = True + except Exception as e: + logging.warning(f"TMDB 匹配失败: task={task_name}, err={e}") + + if cal and task.get('calendar_info') != cal: + task['calendar_info'] = cal + + if changed: + config_data['tasklist'] = tasks + return changed + + # 刷新Plex媒体库 @app.route("/refresh_plex_library", methods=["POST"]) def refresh_plex_library(): @@ -1521,6 +2226,12 @@ def delete_file(): # 添加删除记录的信息到响应中 response["deleted_records"] = deleted_count # logging.info(f">>> 删除文件 {fid} 同时删除了 {deleted_count} 条相关记录") + # 若存在记录删除,通知前端刷新(影响:最近转存、进度、今日更新等) + try: + if deleted_count > 0: + notify_calendar_changed('delete_records') + except Exception: + pass except Exception as e: logging.error(f">>> 删除记录时出错: {str(e)}") @@ -1894,7 +2605,14 @@ def delete_history_records(): deleted_count = 0 for record_id in record_ids: deleted_count += db.delete_record(record_id) - + + # 广播通知:有记录被删除,触发前端刷新任务/日历 + try: + if deleted_count > 0: + notify_calendar_changed('delete_records') + except Exception: + pass + return jsonify({ "success": True, "message": f"成功删除 {deleted_count} 条记录", @@ -1990,6 +2708,31 @@ def get_task_latest_info(): processed_name = process_season_episode_info(file_name_without_ext, task_name) task_latest_files[task_name] = processed_name + # 注入"追剧日历"层面的签名信息,便于前端在无新增转存文件时也能检测到新增/删除剧目 + try: + caldb = CalendarDB() + cur = caldb.conn.cursor() + # shows 数量 + try: + cur.execute('SELECT COUNT(*) FROM shows') + shows_count = cur.fetchone()[0] if cur.fetchone is not None else 0 + except Exception: + shows_count = 0 + # tmdb_id 列表(排序后拼接,变化即触发前端刷新) + tmdb_sig = '' + try: + cur.execute('SELECT tmdb_id FROM shows ORDER BY tmdb_id ASC') + ids = [str(r[0]) for r in cur.fetchall()] + tmdb_sig = ','.join(ids) + except Exception: + tmdb_sig = '' + # 写入到 latest_files,以复用前端现有签名计算逻辑 + task_latest_files['__calendar_shows__'] = str(shows_count) + if tmdb_sig: + task_latest_files['__calendar_tmdb_ids__'] = tmdb_sig + except Exception: + pass + db.close() return jsonify({ @@ -2007,6 +2750,79 @@ def get_task_latest_info(): }) +# 获取当日更新的剧集(本地DB,按 transfer_records 当天记录聚合) +@app.route("/api/calendar/today_updates_local") +def get_calendar_today_updates_local(): + try: + # 计算今天 00:00:00 与 23:59:59 的时间戳(毫秒)范围 + now = datetime.now() + start_of_day = datetime(now.year, now.month, now.day) + end_of_day = datetime(now.year, now.month, now.day, 23, 59, 59) + start_ms = int(start_of_day.timestamp() * 1000) + end_ms = int(end_of_day.timestamp() * 1000) + + # 读取今日的转存记录 + rdb = RecordDB() + cur = rdb.conn.cursor() + cur.execute( + """ + SELECT task_name, renamed_to, original_name, transfer_time + FROM transfer_records + WHERE task_name NOT IN ('rename', 'undo_rename') + AND transfer_time >= ? AND transfer_time <= ? + ORDER BY id DESC + """, + (start_ms, end_ms) + ) + rows = cur.fetchall() or [] + + # 建立 task_name -> (tmdb_id, show_name) 的映射,优先使用已绑定的 shows + tmdb_map = {} + try: + cal_db = CalendarDB() + # shows 表有 bound_task_names,逐条解析绑定 + cur2 = cal_db.conn.cursor() + cur2.execute('SELECT tmdb_id, name, bound_task_names FROM shows') + for tmdb_id, name, bound in (cur2.fetchall() or []): + try: + bound_list = [] + if bound: + bound_list = [b.strip() for b in str(bound).split(',') if b and b.strip()] + for tname in bound_list: + if tname: + tmdb_map[tname] = { 'tmdb_id': int(tmdb_id), 'show_name': name or '' } + except Exception: + continue + except Exception: + tmdb_map = {} + + # 提取剧集编号/日期 + extractor = TaskExtractor() + items = [] + for task_name, renamed_to, original_name, transfer_time in rows: + # 解析进度信息 + base_name = os.path.splitext(renamed_to or '')[0] + parsed = extractor.extract_progress_from_latest_file(base_name) + season = parsed.get('season_number') + ep = parsed.get('episode_number') + air_date = parsed.get('air_date') + if not season and not ep and not air_date: + # 无法解析到有效信息则跳过 + continue + bind = tmdb_map.get(task_name, {}) + items.append({ + 'task_name': task_name or '', + 'tmdb_id': bind.get('tmdb_id'), + 'show_name': bind.get('show_name') or '', + 'season_number': season, + 'episode_number': ep, + 'air_date': air_date + }) + + return jsonify({ 'success': True, 'data': { 'items': items } }) + except Exception as e: + return jsonify({ 'success': False, 'message': f'读取当日更新失败: {str(e)}', 'data': { 'items': [] } }) + # 删除单条转存记录 @app.route("/delete_history_record", methods=["POST"]) @@ -2025,7 +2841,14 @@ def delete_history_record(): # 删除记录 deleted = db.delete_record(record_id) - + + # 广播通知:有记录被删除,触发前端刷新任务/日历 + try: + if deleted: + notify_calendar_changed('delete_records') + except Exception: + pass + if deleted: return jsonify({ "success": True, @@ -2699,6 +3522,1386 @@ def has_rename_record(): return jsonify({"has_rename": has_rename}) +# 手动同步任务配置与数据库绑定关系 +@app.route("/api/calendar/sync_task_config", methods=["POST"]) +def sync_task_config_api(): + """手动触发任务配置与数据库绑定关系的同步""" + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + try: + success = sync_task_config_with_database_bindings() + if success: + return jsonify({"success": True, "message": "同步完成"}) + else: + return jsonify({"success": False, "message": "没有需要同步的数据"}) + except Exception as e: + return jsonify({"success": False, "message": f"同步失败: {str(e)}"}) + +# 手动同步内容类型数据 +@app.route("/api/calendar/sync_content_type", methods=["POST"]) +def sync_content_type_api(): + """手动触发内容类型数据同步""" + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + try: + success = sync_content_type_between_config_and_database() + if success: + return jsonify({"success": True, "message": "内容类型同步完成"}) + else: + return jsonify({"success": False, "message": "没有需要同步的内容类型数据"}) + except Exception as e: + return jsonify({"success": False, "message": f"内容类型同步失败: {str(e)}"}) + +# 追剧日历API路由 +@app.route("/api/calendar/tasks") +def get_calendar_tasks(): + """获取追剧日历任务信息""" + try: + logging.debug("进入get_calendar_tasks函数") + logging.debug(f"config_data类型: {type(config_data)}") + logging.debug(f"config_data内容: {config_data}") + + # 获取任务列表 + tasks = config_data.get('tasklist', []) + + # 获取任务的最近转存文件信息 + db = RecordDB() + cursor = db.conn.cursor() + + # 获取所有任务的最新转存时间 + query = """ + SELECT task_name, MAX(transfer_time) as latest_transfer_time + FROM transfer_records + WHERE task_name NOT IN ('rename', 'undo_rename') + GROUP BY task_name + """ + cursor.execute(query) + latest_times = cursor.fetchall() + + task_latest_files = {} + + for task_name, latest_time in latest_times: + if latest_time: + # 获取该任务在最新转存时间附近的所有文件 + time_window = 60000 # 60秒的时间窗口(毫秒) + query = """ + SELECT renamed_to, original_name, transfer_time, modify_date + FROM transfer_records + WHERE task_name = ? AND transfer_time >= ? AND transfer_time <= ? + ORDER BY id DESC + """ + cursor.execute(query, (task_name, latest_time - time_window, latest_time + time_window)) + files = cursor.fetchall() + + if files: + if len(files) == 1: + best_file = files[0][0] # renamed_to + else: + # 如果有多个文件,使用全局排序函数进行排序 + file_list = [] + for renamed_to, original_name, transfer_time, modify_date in files: + file_info = { + 'file_name': renamed_to, + 'original_name': original_name, + 'updated_at': transfer_time + } + file_list.append(file_info) + + try: + sorted_files = sorted(file_list, key=sort_file_by_name) + best_file = sorted_files[-1]['file_name'] + except Exception: + best_file = files[0][0] + + # 去除扩展名并处理季数集数信息 + if best_file: + file_name_without_ext = os.path.splitext(best_file)[0] + processed_name = process_season_episode_info(file_name_without_ext, task_name) + task_latest_files[task_name] = processed_name + + db.close() + + # 提取任务信息 + logging.debug("开始提取任务信息") + logging.debug(f"tasks数量: {len(tasks)}") + logging.debug(f"task_latest_files数量: {len(task_latest_files)}") + + try: + extractor = TaskExtractor() + logging.debug("TaskExtractor创建成功") + tasks_info = extractor.extract_all_tasks_info(tasks, task_latest_files) + logging.debug("extract_all_tasks_info调用成功") + except Exception as e: + logging.debug(f"TaskExtractor相关操作失败: {e}") + import traceback + traceback.print_exc() + raise + + logging.debug(f"提取的任务信息数量: {len(tasks_info)}") + if tasks_info: + logging.debug(f"第一个任务信息: {tasks_info[0]}") + + # 首先同步数据库绑定关系到任务配置 + sync_task_config_with_database_bindings() + + # 同步内容类型数据 + sync_content_type_between_config_and_database() + + # 基于 tmdb 绑定补充"实际匹配结果"(show 名称/年份/本地海报) + try: + from app.sdk.db import CalendarDB + cal_db = CalendarDB() + enriched = [] + # 先获取所有 shows 数据,用于按名称匹配 + cursor = cal_db.conn.cursor() + cursor.execute('SELECT tmdb_id, name, year, poster_local_path, bound_task_names FROM shows') + all_shows = cursor.fetchall() + shows_by_name = {s[1]: {'tmdb_id': s[0], 'name': s[1], 'year': s[2], 'poster_local_path': s[3], 'bound_task_names': s[4]} for s in all_shows} + + for idx, t in enumerate(tasks_info): + # 1) 优先通过 calendar_info.match.tmdb_id 查找 + raw = tasks[idx] if idx < len(tasks) else None + cal = (raw or {}).get('calendar_info') or {} + match = cal.get('match') or {} + tmdb_id = match.get('tmdb_id') + + task_name = t.get('task_name', '').strip() + matched_show = None + + # 优先通过任务的 TMDB 匹配结果查找节目 + if tmdb_id: + try: + show = cal_db.get_show(int(tmdb_id)) or {} + if show: + matched_show = show + logging.debug(f"通过任务 TMDB 匹配结果找到节目 - 任务: '{task_name}', tmdb_id: {tmdb_id}, 节目: '{show.get('name', '')}'") + except Exception as e: + logging.debug(f"通过任务 TMDB 匹配结果查找失败 - 任务: '{task_name}', tmdb_id: {tmdb_id}, 错误: {e}") + pass + else: + logging.debug(f"任务配置中无 TMDB 匹配结果,尝试通过已绑定关系查找 - 任务: '{task_name}'") + + # 2) 如果找到匹配的节目,检查是否需要建立绑定关系 + if matched_show: + # 检查任务是否已经绑定到该节目,避免重复绑定 + bound_tasks = cal_db.get_bound_tasks_for_show(matched_show['tmdb_id']) + # 清理空格,确保比较准确 + bound_tasks_clean = [task.strip() for task in bound_tasks if task.strip()] + needs_binding = task_name not in bound_tasks_clean + + logging.debug(f"检查绑定状态 - 任务: '{task_name}', 已绑定任务: {bound_tasks_clean}, 需要绑定: {needs_binding}") + + if needs_binding: + # 建立任务与节目的绑定关系,同时设置内容类型 + task_content_type = t.get('content_type', '') + cal_db.bind_task_and_content_type(matched_show['tmdb_id'], task_name, task_content_type) + logging.debug(f"成功绑定任务到节目 - 任务: '{task_name}', 节目: '{matched_show['name']}', tmdb_id: {matched_show['tmdb_id']}") + else: + logging.debug(f"任务已绑定到节目,跳过重复绑定 - 任务: '{task_name}', 节目: '{matched_show['name']}', tmdb_id: {matched_show['tmdb_id']}") + + t['match_tmdb_id'] = matched_show['tmdb_id'] + t['matched_show_name'] = matched_show['name'] + t['matched_year'] = matched_show['year'] + t['matched_poster_local_path'] = matched_show['poster_local_path'] + # 提供最新季数用于前端展示 + try: + t['matched_latest_season_number'] = matched_show.get('latest_season_number') + except Exception: + pass + # 从数据库获取实际的内容类型(如果已设置) + db_content_type = cal_db.get_show_content_type(matched_show['tmdb_id']) + t['matched_content_type'] = db_content_type if db_content_type else t.get('content_type', '') + # 优先保持任务配置中的类型;仅当任务未设置任何类型时,回退为数据库类型 + extracted_ct = ((t.get('calendar_info') or {}).get('extracted') or {}).get('content_type') + config_ct = extracted_ct or t.get('content_type') + if not (config_ct and str(config_ct).strip()): + if t.get('matched_content_type'): + t['content_type'] = t['matched_content_type'] + else: + # 如果任务配置中没有 TMDB 匹配结果,检查是否已通过其他方式绑定到节目 + # 通过任务名称在数据库中搜索已绑定的节目 + try: + cursor.execute('SELECT tmdb_id, name, year, poster_local_path FROM shows WHERE bound_task_names LIKE ?', (f'%{task_name}%',)) + bound_show = cursor.fetchone() + if bound_show: + t['match_tmdb_id'] = bound_show[0] + t['matched_show_name'] = bound_show[1] + t['matched_year'] = bound_show[2] + t['matched_poster_local_path'] = bound_show[3] + # 查询完整信息以提供最新季数 + try: + full_show = cal_db.get_show(int(bound_show[0])) + if full_show and 'latest_season_number' in full_show: + t['matched_latest_season_number'] = full_show['latest_season_number'] + except Exception: + pass + # 从数据库获取实际的内容类型(如果已设置) + db_content_type = cal_db.get_show_content_type(bound_show[0]) + t['matched_content_type'] = db_content_type if db_content_type else t.get('content_type', '') + # 优先任务配置类型;仅在未配置类型时用数据库类型 + extracted_ct = ((t.get('calendar_info') or {}).get('extracted') or {}).get('content_type') + config_ct = extracted_ct or t.get('content_type') + if not (config_ct and str(config_ct).strip()): + if t.get('matched_content_type'): + t['content_type'] = t['matched_content_type'] + + logging.debug(f"通过已绑定关系找到节目 - 任务: '{task_name}', 节目: '{bound_show[1]}', tmdb_id: {bound_show[0]}") + else: + t['match_tmdb_id'] = None + t['matched_show_name'] = '' + t['matched_year'] = '' + t['matched_poster_local_path'] = '' + t['matched_content_type'] = t.get('content_type', '') + + logging.debug(f"任务未绑定到任何节目 - 任务: '{task_name}'") + except Exception as e: + logging.debug(f"搜索已绑定节目失败 - 任务: '{task_name}', 错误: {e}") + t['match_tmdb_id'] = None + t['matched_show_name'] = '' + t['matched_year'] = '' + t['matched_poster_local_path'] = '' + t['matched_content_type'] = t.get('content_type', '') + enriched.append(t) + tasks_info = enriched + except Exception as _e: + # 若补充失败,不影响主流程 + print(f"补充匹配结果失败: {_e}") + pass + + return jsonify({ + 'success': True, + 'data': { + # 返回全部任务(包括未匹配/未提取剧名的),用于内容管理页显示 + 'tasks': enrich_tasks_with_calendar_meta(tasks_info), + 'content_types': extractor.get_content_types_with_content([t for t in tasks_info if t.get('show_name')]) + } + }) + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'获取追剧日历任务信息失败: {str(e)}', + 'data': {'tasks': [], 'content_types': []} + }) + +@app.route("/api/calendar/episodes") +def get_calendar_episodes(): + """获取指定日期范围的剧集播出信息""" + try: + + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + content_type = request.args.get('content_type', '') + show_name = request.args.get('show_name', '') + + if not start_date or not end_date: + return jsonify({ + 'success': False, + 'message': '请提供开始和结束日期', + 'data': {'episodes': []} + }) + + # 获取TMDB配置 + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return jsonify({ + 'success': False, + 'message': 'TMDB API未配置', + 'data': {'episodes': []} + }) + + # 获取任务信息 + tasks = config_data.get('tasklist', []) + extractor = TaskExtractor() + tasks_info = extractor.extract_all_tasks_info(tasks, {}) + + # 过滤任务 + if content_type: + tasks_info = [task for task in tasks_info if task['content_type'] == content_type] + if show_name: + tasks_info = [task for task in tasks_info if show_name.lower() in task['show_name'].lower()] + + # 获取剧集信息 + tmdb_service = TMDBService(tmdb_api_key) + all_episodes = [] + + for task_info in tasks_info: + if task_info['show_name'] and task_info['year']: + show_data = tmdb_service.search_and_get_episodes( + task_info['show_name'], + task_info['year'] + ) + if show_data: + # 获取剧集的海报信息 + show_poster_path = show_data['show_info'].get('poster_path', '') + + # 过滤日期范围内的剧集 + for episode in show_data['episodes']: + # 确保episode有air_date字段 + if episode.get('air_date') and start_date <= episode['air_date'] <= end_date: + episode['task_info'] = { + 'task_name': task_info['task_name'], + 'content_type': task_info['content_type'], + 'progress': task_info + } + # 添加海报路径信息 + episode['poster_path'] = show_poster_path + all_episodes.append(episode) + + # 按播出日期排序 + all_episodes.sort(key=lambda x: x.get('air_date', '')) + + return jsonify({ + 'success': True, + 'data': { + 'episodes': all_episodes, + 'total': len(all_episodes) + } + }) + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'获取剧集播出信息失败: {str(e)}', + 'data': {'episodes': [], 'total': 0} + }) + + +# 初始化:为任务写入 shows/seasons,下载海报本地化 +def do_calendar_bootstrap() -> tuple: + """内部方法:执行日历初始化。返回 (success: bool, message: str)""" + try: + ensure_calendar_info_for_tasks() + + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return False, 'TMDB API未配置' + + tmdb_service = TMDBService(tmdb_api_key) + cal_db = CalendarDB() + + tasks = config_data.get('tasklist', []) + any_written = False + for task in tasks: + cal = (task or {}).get('calendar_info') or {} + match = cal.get('match') or {} + extracted = cal.get('extracted') or {} + tmdb_id = match.get('tmdb_id') + if not tmdb_id: + continue + + details = tmdb_service.get_tv_show_details(tmdb_id) or {} + # 使用新的方法获取中文标题,支持从别名中获取中国地区的别名 + name = tmdb_service.get_chinese_title_with_fallback(tmdb_id, extracted.get('show_name', '')) + first_air = (details.get('first_air_date') or '')[:4] + # 将原始状态转换为本地化中文,且对 returning_series 场景做本季终/播出中判断 + raw_status = (details.get('status') or '') + try: + localized_status = tmdb_service.get_localized_show_status(int(tmdb_id), int(match.get('latest_season_number') or 1), raw_status) + status = localized_status + except Exception: + status = raw_status + poster_path = details.get('poster_path') or '' + latest_season_number = match.get('latest_season_number') or 1 + + poster_local_path = '' + if poster_path: + poster_local_path = download_poster_local(poster_path) + + # 获取现有的绑定关系和内容类型,避免被覆盖 + existing_show = cal_db.get_show(int(tmdb_id)) + existing_bound_tasks = existing_show.get('bound_task_names', '') if existing_show else '' + existing_content_type = existing_show.get('content_type', '') if existing_show else '' + cal_db.upsert_show(int(tmdb_id), name, first_air, status, poster_local_path, int(latest_season_number), 0, existing_bound_tasks, existing_content_type) + refresh_url = f"/tv/{tmdb_id}/season/{latest_season_number}" + # 处理季名称 + season_name_raw = '' + try: + for s in (details.get('seasons') or []): + if int(s.get('season_number') or 0) == int(latest_season_number): + season_name_raw = s.get('name') or '' + break + except Exception: + season_name_raw = '' + try: + from sdk.tmdb_service import TMDBService as _T + season_name_processed = tmdb_service.process_season_name(season_name_raw) if isinstance(tmdb_service, _T) else season_name_raw + except Exception: + season_name_processed = season_name_raw + cal_db.upsert_season( + int(tmdb_id), + int(latest_season_number), + details_of_season_episode_count(tmdb_service, tmdb_id, latest_season_number), + refresh_url, + season_name_processed + ) + + # 立即拉取最新一季的所有集并写入本地,保证前端可立即读取 + try: + season = tmdb_service.get_tv_show_episodes(int(tmdb_id), int(latest_season_number)) or {} + episodes = season.get('episodes', []) or [] + from time import time as _now + now_ts = int(_now()) + for ep in episodes: + cal_db.upsert_episode( + tmdb_id=int(tmdb_id), + season_number=int(latest_season_number), + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + any_written = True + except Exception as _e: + logging.warning(f"bootstrap 写入剧集失败 tmdb_id={tmdb_id}: {_e}") + try: + if any_written: + notify_calendar_changed('bootstrap') + except Exception: + pass + return True, 'OK' + except Exception as e: + return False, f'bootstrap失败: {str(e)}' + + +@app.route("/api/calendar/bootstrap", methods=["POST"]) +def calendar_bootstrap(): + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + success, msg = do_calendar_bootstrap() + return jsonify({"success": success, "message": msg}) + + +def details_of_season_episode_count(tmdb_service: TMDBService, tmdb_id: int, season_number: int) -> int: + try: + season = tmdb_service.get_tv_show_episodes(tmdb_id, season_number) or {} + return len(season.get('episodes', []) or []) + except Exception: + return 0 + + +def download_poster_local(poster_path: str) -> str: + """下载 TMDB 海报到本地 static/cache/images 下,等比缩放为宽400px,返回相对路径。""" + try: + base = "https://image.tmdb.org/t/p/w400" + url = f"{base}{poster_path}" + r = requests.get(url, timeout=10) + if r.status_code != 200: + return '' + folder = CACHE_IMAGES_DIR + # 基于 poster_path 生成文件名 + safe_name = poster_path.strip('/').replace('/', '_') or 'poster.jpg' + file_path = os.path.join(folder, safe_name) + with open(file_path, 'wb') as f: + f.write(r.content) + # 返回用于前端引用的相对路径(通过 /cache/images 暴露) + return f"/cache/images/{safe_name}" + except Exception: + return '' + + +# 刷新:拉取最新一季所有集,按有无 runtime 进行增量更新 +@app.route("/api/calendar/refresh_latest_season") +def calendar_refresh_latest_season(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + tmdb_id = request.args.get('tmdb_id', type=int) + if not tmdb_id: + return jsonify({"success": False, "message": "缺少 tmdb_id"}) + + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return jsonify({"success": False, "message": "TMDB API 未配置"}) + + tmdb_service = TMDBService(tmdb_api_key) + cal_db = CalendarDB() + show = cal_db.get_show(int(tmdb_id)) + if not show: + return jsonify({"success": False, "message": "未初始化该剧(请先 bootstrap)"}) + + latest_season = int(show['latest_season_number']) + season = tmdb_service.get_tv_show_episodes(tmdb_id, latest_season) or {} + episodes = season.get('episodes', []) or [] + + from time import time as _now + now_ts = int(_now()) + + any_written = False + for ep in episodes: + cal_db.upsert_episode( + tmdb_id=int(tmdb_id), + season_number=latest_season, + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + any_written = True + + # 同步更新 seasons 表的季名称与总集数 + try: + season_name_raw = season.get('name') or '' + season_name_processed = tmdb_service.process_season_name(season_name_raw) + except Exception: + season_name_processed = season.get('name') or '' + try: + cal_db.upsert_season( + int(tmdb_id), + int(latest_season), + len(episodes), + f"/tv/{tmdb_id}/season/{latest_season}", + season_name_processed + ) + except Exception: + pass + + try: + if any_written: + notify_calendar_changed('refresh_latest_season') + except Exception: + pass + return jsonify({"success": True, "updated": len(episodes)}) + except Exception as e: + return jsonify({"success": False, "message": f"刷新失败: {str(e)}"}) + + +# 强制刷新单集或合并集的元数据(海报视图使用) +@app.route("/api/calendar/refresh_episode") +def calendar_refresh_episode(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + tmdb_id = request.args.get('tmdb_id', type=int) + season_number = request.args.get('season_number', type=int) + episode_number = request.args.get('episode_number', type=int) + + if not tmdb_id or not season_number or not episode_number: + return jsonify({"success": False, "message": "缺少必要参数"}) + + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return jsonify({"success": False, "message": "TMDB API 未配置"}) + + tmdb_service = TMDBService(tmdb_api_key) + cal_db = CalendarDB() + + # 验证节目是否存在 + show = cal_db.get_show(int(tmdb_id)) + if not show: + return jsonify({"success": False, "message": "未初始化该剧(请先 bootstrap)"}) + + # 获取指定季的所有集数据 + season = tmdb_service.get_tv_show_episodes(tmdb_id, season_number) or {} + episodes = season.get('episodes', []) or [] + + # 查找指定集 + target_episode = None + for ep in episodes: + if int(ep.get('episode_number') or 0) == episode_number: + target_episode = ep + break + + if not target_episode: + return jsonify({"success": False, "message": f"未找到第 {season_number} 季第 {episode_number} 集"}) + + # 强制更新该集数据(无论是否有 runtime) + from time import time as _now + now_ts = int(_now()) + + cal_db.upsert_episode( + tmdb_id=int(tmdb_id), + season_number=season_number, + episode_number=episode_number, + name=target_episode.get('name') or '', + overview=target_episode.get('overview') or '', + air_date=target_episode.get('air_date') or '', + runtime=target_episode.get('runtime'), + ep_type=(target_episode.get('episode_type') or target_episode.get('type')), + updated_at=now_ts, + ) + + # 通知日历数据变更 + try: + notify_calendar_changed('refresh_episode') + except Exception: + pass + + # 获取剧名用于通知 + show_name = show.get('name', '未知剧集') + return jsonify({"success": True, "message": f"《{show_name}》第 {season_number} 季 · 第 {episode_number} 集刷新成功"}) + except Exception as e: + return jsonify({"success": False, "message": f"刷新失败: {str(e)}"}) + + +# 强制刷新整个季的元数据(内容管理视图使用) +@app.route("/api/calendar/refresh_season") +def calendar_refresh_season(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + tmdb_id = request.args.get('tmdb_id', type=int) + season_number = request.args.get('season_number', type=int) + + if not tmdb_id or not season_number: + return jsonify({"success": False, "message": "缺少必要参数"}) + + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return jsonify({"success": False, "message": "TMDB API 未配置"}) + + tmdb_service = TMDBService(tmdb_api_key) + cal_db = CalendarDB() + + # 验证节目是否存在 + show = cal_db.get_show(int(tmdb_id)) + if not show: + return jsonify({"success": False, "message": "未初始化该剧(请先 bootstrap)"}) + + # 获取指定季的所有集数据 + season = tmdb_service.get_tv_show_episodes(tmdb_id, season_number) or {} + episodes = season.get('episodes', []) or [] + + if not episodes: + return jsonify({"success": False, "message": f"第 {season_number} 季没有集数据"}) + + # 强制更新该季所有集数据(无论是否有 runtime) + from time import time as _now + now_ts = int(_now()) + + updated_count = 0 + for ep in episodes: + cal_db.upsert_episode( + tmdb_id=int(tmdb_id), + season_number=season_number, + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + updated_count += 1 + + # 同步更新 seasons 表的季名称与总集数 + try: + season_name_raw = season.get('name') or '' + season_name_processed = tmdb_service.process_season_name(season_name_raw) + except Exception: + season_name_processed = season.get('name') or '' + try: + cal_db.upsert_season( + int(tmdb_id), + int(season_number), + len(episodes), + f"/tv/{tmdb_id}/season/{season_number}", + season_name_processed + ) + except Exception: + pass + + # 通知日历数据变更 + try: + notify_calendar_changed('refresh_season') + except Exception: + pass + + # 获取剧名用于通知 + show_name = show.get('name', '未知剧集') + return jsonify({"success": True, "message": f"《{show_name}》第 {season_number} 季刷新成功,共 {updated_count} 集"}) + except Exception as e: + return jsonify({"success": False, "message": f"刷新失败: {str(e)}"}) + + +# 刷新:拉取剧级别的最新详情并更新 shows 表(用于更新节目状态/中文名/首播年/海报/最新季) +@app.route("/api/calendar/refresh_show") +def calendar_refresh_show(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + tmdb_id = request.args.get('tmdb_id', type=int) + if not tmdb_id: + return jsonify({"success": False, "message": "缺少 tmdb_id"}) + + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return jsonify({"success": False, "message": "TMDB API 未配置"}) + + tmdb_service = TMDBService(tmdb_api_key) + cal_db = CalendarDB() + + details = tmdb_service.get_tv_show_details(int(tmdb_id)) or {} + if not details: + return jsonify({"success": False, "message": "未获取到节目详情"}) + + try: + existing_show = cal_db.get_show(int(tmdb_id)) + except Exception: + existing_show = None + + # 标题、状态与最新季 + try: + name = tmdb_service.get_chinese_title_with_fallback(int(tmdb_id), (existing_show or {}).get('name') or '') + except Exception: + name = (details.get('name') or details.get('original_name') or (existing_show or {}).get('name') or '') + + first_air = (details.get('first_air_date') or '')[:4] + raw_status = (details.get('status') or '') + try: + latest_sn_for_status = int((existing_show or {}).get('latest_season_number') or 1) + localized_status = tmdb_service.get_localized_show_status(int(tmdb_id), latest_sn_for_status, raw_status) + except Exception: + localized_status = raw_status + + latest_season_number = 0 + try: + for s in (details.get('seasons') or []): + sn = int(s.get('season_number') or 0) + if sn > latest_season_number: + latest_season_number = sn + except Exception: + latest_season_number = 0 + if latest_season_number <= 0: + try: + latest_season_number = int((existing_show or {}).get('latest_season_number') or 1) + except Exception: + latest_season_number = 1 + + # 海报 + poster_path = details.get('poster_path') or '' + poster_local_path = '' + try: + if poster_path: + poster_local_path = download_poster_local(poster_path) + elif existing_show and existing_show.get('poster_local_path'): + poster_local_path = existing_show.get('poster_local_path') + except Exception: + poster_local_path = (existing_show or {}).get('poster_local_path') or '' + + bound_task_names = (existing_show or {}).get('bound_task_names', '') + content_type = (existing_show or {}).get('content_type', '') + + cal_db.upsert_show( + int(tmdb_id), + name, + first_air, + localized_status, + poster_local_path, + int(latest_season_number), + 0, + bound_task_names, + content_type + ) + + try: + notify_calendar_changed('refresh_show') + except Exception: + pass + + return jsonify({"success": True, "message": "剧目详情已刷新"}) + except Exception as e: + return jsonify({"success": False, "message": f"刷新剧失败: {str(e)}"}) + +# 编辑追剧日历元数据:修改任务名、任务类型、重绑 TMDB +@app.route("/api/calendar/edit_metadata", methods=["POST"]) +def calendar_edit_metadata(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + data = request.get_json(force=True) or {} + task_name = (data.get('task_name') or '').strip() + new_task_name = (data.get('new_task_name') or '').strip() + new_content_type = (data.get('new_content_type') or '').strip() + new_tmdb_id = data.get('new_tmdb_id') + new_season_number = data.get('new_season_number') + + if not task_name: + return jsonify({"success": False, "message": "缺少任务名称"}) + + global config_data + tasks = config_data.get('tasklist', []) + target = None + for t in tasks: + tn = t.get('taskname') or t.get('task_name') or '' + if tn == task_name: + target = t + break + if not target: + return jsonify({"success": False, "message": "未找到任务"}) + + cal = (target.get('calendar_info') or {}) + match = (cal.get('match') or {}) + old_tmdb_id = match.get('tmdb_id') or cal.get('tmdb_id') + + changed = False + + if new_task_name and new_task_name != task_name: + target['taskname'] = new_task_name + target['task_name'] = new_task_name + changed = True + + valid_types = {'tv', 'anime', 'variety', 'documentary', 'other', ''} + if new_content_type in valid_types: + extracted = (target.setdefault('calendar_info', {}).setdefault('extracted', {})) + if extracted.get('content_type') != new_content_type: + # 同步到任务配置中的 extracted 与顶层 content_type,保证前端与其他逻辑可见 + extracted['content_type'] = new_content_type + target['content_type'] = new_content_type + # 未匹配任务:没有 old_tmdb_id 时,不访问数据库,仅更新配置 + # 已匹配任务:若已有绑定的 tmdb_id,则立即同步到数据库 + try: + if old_tmdb_id: + cal_db = CalendarDB() + cal_db.update_show_content_type(int(old_tmdb_id), new_content_type) + # 同步任务名与内容类型绑定关系 + task_final_name = target.get('taskname') or target.get('task_name') or task_name + cal_db.bind_task_and_content_type(int(old_tmdb_id), task_final_name, new_content_type) + except Exception as e: + logging.warning(f"同步内容类型到数据库失败: {e}") + changed = True + + did_rematch = False + cal_db = CalendarDB() + tmdb_api_key = config_data.get('tmdb_api_key', '') + tmdb_service = TMDBService(tmdb_api_key) if tmdb_api_key else None + # 场景一:提供 new_tmdb_id(重绑节目,可同时指定季数) + if new_tmdb_id: + try: + new_tid = int(str(new_tmdb_id).strip()) + except Exception: + return jsonify({"success": False, "message": "TMDB ID 非法"}) + + # 特殊处理:tmdb_id 为 0 表示取消匹配 + if new_tid == 0: + if old_tmdb_id: + # 解绑旧节目的任务引用 + try: + try: + _task_final_name = target.get('taskname') or target.get('task_name') or new_task_name or task_name + except Exception: + _task_final_name = task_name + cal_db.unbind_task_from_show(int(old_tmdb_id), _task_final_name) + except Exception as e: + logging.warning(f"解绑旧节目任务失败: {e}") + + # 清理旧节目的所有数据与海报(强制清理,因为我们已经解绑了任务) + try: + purge_calendar_by_tmdb_id_internal(int(old_tmdb_id), force=True) + except Exception as e: + logging.warning(f"清理旧节目数据失败: {e}") + + # 清空任务配置中的匹配信息 + if 'calendar_info' not in target: + target['calendar_info'] = {} + if 'match' in target['calendar_info']: + target['calendar_info']['match'] = {} + + changed = True + msg = '已取消匹配,并清除了原有的节目数据' + return jsonify({"success": True, "message": msg}) + + try: + season_no = int(new_season_number or 1) + except Exception: + season_no = 1 + + # 若 tmdb 发生变更,先解绑旧节目的任务引用;实际清理延后到配置写盘之后 + old_to_purge_tmdb_id = None + if old_tmdb_id and int(old_tmdb_id) != new_tid: + # 解绑旧节目的任务引用 + try: + try: + _task_final_name = target.get('taskname') or target.get('task_name') or new_task_name or task_name + except Exception: + _task_final_name = task_name + cal_db.unbind_task_from_show(int(old_tmdb_id), _task_final_name) + except Exception as e: + logging.warning(f"解绑旧节目任务失败: {e}") + # 记录待清理的旧 tmdb,在配置更新并落盘后再执行清理 + try: + old_to_purge_tmdb_id = int(old_tmdb_id) + except Exception: + old_to_purge_tmdb_id = None + + show = cal_db.get_show(new_tid) + if not show: + if not tmdb_service: + return jsonify({"success": False, "message": "TMDB API 未配置,无法初始化新节目"}) + # 直接从 TMDB 获取详情并写入本地,而不是调用全量 bootstrap + details = tmdb_service.get_tv_show_details(new_tid) or {} + if not details: + return jsonify({"success": False, "message": "未找到指定 TMDB 节目"}) + try: + chinese_title = tmdb_service.get_chinese_title_with_fallback(new_tid, details.get('name') or details.get('original_name') or '') + except Exception: + chinese_title = details.get('name') or details.get('original_name') or '' + poster_local_path = '' + try: + poster_local_path = download_poster_local(details.get('poster_path') or '') + except Exception: + poster_local_path = '' + first_air = (details.get('first_air_date') or '')[:4] + status = details.get('status') or '' + # 以 season_no 作为最新季写入 shows + cal_db.upsert_show(int(new_tid), chinese_title, first_air, status, poster_local_path, int(season_no), 0, '', (target.get('content_type') or '')) + show = cal_db.get_show(new_tid) + if not show: + return jsonify({"success": False, "message": "未找到指定 TMDB 节目"}) + + task_final_name = target.get('taskname') or target.get('task_name') or new_task_name or task_name + ct = (target.get('calendar_info') or {}).get('extracted', {}).get('content_type', '') + try: + cal_db.bind_task_and_content_type(new_tid, task_final_name, ct) + except Exception as e: + logging.warning(f"绑定任务失败: {e}") + + if 'calendar_info' not in target: + target['calendar_info'] = {} + if 'match' not in target['calendar_info']: + target['calendar_info']['match'] = {} + target['calendar_info']['match'].update({ + 'tmdb_id': new_tid, + 'matched_show_name': show.get('name', ''), + 'matched_year': show.get('year', '') + }) + target['calendar_info']['match']['latest_season_number'] = season_no + + try: + season = tmdb_service.get_tv_show_episodes(new_tid, season_no) if tmdb_service else None + eps = (season or {}).get('episodes', []) or [] + from time import time as _now + now_ts = int(_now()) + # 清理除当前季外的其他季数据,避免残留 + try: + cal_db.purge_other_seasons(int(new_tid), int(season_no)) + except Exception: + pass + for ep in eps: + cal_db.upsert_episode( + tmdb_id=int(new_tid), + season_number=int(season_no), + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + try: + sname_raw = (season or {}).get('name') or '' + sname = tmdb_service.process_season_name(sname_raw) if tmdb_service else sname_raw + cal_db.upsert_season(int(new_tid), int(season_no), len(eps), f"/tv/{new_tid}/season/{season_no}", sname) + cal_db.update_show_latest_season_number(int(new_tid), int(season_no)) + except Exception: + pass + except Exception as e: + logging.warning(f"刷新新季失败: {e}") + + did_rematch = True + changed = True + # 如果需要,延后清理旧节目的数据(此时任务配置已指向新节目,避免被清理函数误判仍被引用) + if old_to_purge_tmdb_id is not None: + try: + purge_calendar_by_tmdb_id_internal(int(old_to_purge_tmdb_id)) + except Exception as e: + logging.warning(f"清理旧 tmdb 失败: {e}") + # 场景二:未提供 new_tmdb_id,但提供了 new_season_number(仅修改季数) + elif (new_season_number is not None) and (str(new_season_number).strip() != '') and old_tmdb_id: + try: + season_no = int(new_season_number) + except Exception: + return jsonify({"success": False, "message": "季数必须为数字"}) + + # 检查是否与当前匹配的季数相同,如果相同则跳过处理 + current_match_season = match.get('latest_season_number') + if current_match_season and int(current_match_season) == season_no: + # 季数未变化,跳过处理 + pass + else: + if not tmdb_service: + return jsonify({"success": False, "message": "TMDB API 未配置"}) + + # 更新 shows 表中的最新季,清理其他季,并拉取指定季数据 + try: + cal_db.update_show_latest_season_number(int(old_tmdb_id), int(season_no)) + except Exception: + pass + + # 拉取该季数据 + try: + season = tmdb_service.get_tv_show_episodes(int(old_tmdb_id), int(season_no)) or {} + eps = season.get('episodes', []) or [] + from time import time as _now + now_ts = int(_now()) + # 清理除当前季外其他季,避免残留 + try: + cal_db.purge_other_seasons(int(old_tmdb_id), int(season_no)) + except Exception: + pass + for ep in eps: + cal_db.upsert_episode( + tmdb_id=int(old_tmdb_id), + season_number=int(season_no), + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + try: + sname_raw = (season or {}).get('name') or '' + sname = tmdb_service.process_season_name(sname_raw) + cal_db.upsert_season(int(old_tmdb_id), int(season_no), len(eps), f"/tv/{old_tmdb_id}/season/{season_no}", sname) + except Exception: + pass + except Exception as e: + return jsonify({"success": False, "message": f"刷新季数据失败: {e}"}) + + # 更新任务配置中的 latest_season_number 以便前端展示 + try: + if 'calendar_info' not in target: + target['calendar_info'] = {} + if 'match' not in target['calendar_info']: + target['calendar_info']['match'] = {} + target['calendar_info']['match']['latest_season_number'] = int(season_no) + except Exception: + pass + + changed = True + + if changed: + Config.write_json(CONFIG_PATH, config_data) + + try: + sync_task_config_with_database_bindings() + except Exception as e: + logging.warning(f"同步绑定失败: {e}") + + try: + notify_calendar_changed('edit_metadata') + except Exception: + pass + + msg = '元数据更新成功' + if did_rematch: + msg = '元数据更新成功,已重新匹配并刷新元数据' + return jsonify({"success": True, "message": msg}) + except Exception as e: + return jsonify({"success": False, "message": f"保存失败: {str(e)}"}) + +# 获取节目信息(用于获取最新季数) +@app.route("/api/calendar/show_info") +def get_calendar_show_info(): + try: + if not is_login(): + return jsonify({"success": False, "message": "未登录"}) + + tmdb_id = request.args.get('tmdb_id', type=int) + if not tmdb_id: + return jsonify({"success": False, "message": "缺少 tmdb_id"}) + + cal_db = CalendarDB() + show = cal_db.get_show(int(tmdb_id)) + if not show: + return jsonify({"success": False, "message": "未找到该节目"}) + + return jsonify({ + "success": True, + "data": { + "tmdb_id": show['tmdb_id'], + "name": show['name'], + "year": show['year'], + "status": show['status'], + "latest_season_number": show['latest_season_number'], + "poster_local_path": show['poster_local_path'] + } + }) + except Exception as e: + return jsonify({"success": False, "message": f"获取节目信息失败: {str(e)}"}) + + +# --------- 日历刷新调度:按性能设置的周期自动刷新最新季 --------- +def restart_calendar_refresh_job(): + try: + # 读取用户配置的刷新周期(秒),默认21600秒(6小时) + perf = config_data.get('performance', {}) if isinstance(config_data, dict) else {} + interval_seconds = int(perf.get('calendar_refresh_interval_seconds', 21600)) + if interval_seconds <= 0: + # 非法或关闭则移除 + try: + scheduler.remove_job('calendar_refresh_job') + except Exception: + pass + logging.info("已关闭追剧日历元数据自动刷新") + return + + # 重置任务 + try: + scheduler.remove_job('calendar_refresh_job') + except Exception: + pass + scheduler.add_job(run_calendar_refresh_all_internal, IntervalTrigger(seconds=interval_seconds), id='calendar_refresh_job', replace_existing=True) + if scheduler.state == 0: + scheduler.start() + logging.info(f"已启动追剧日历自动刷新,周期 {interval_seconds} 秒") + except Exception as e: + logging.warning(f"配置自动刷新任务失败: {e}") + + +def run_calendar_refresh_all_internal(): + try: + tmdb_api_key = config_data.get('tmdb_api_key', '') + if not tmdb_api_key: + return + tmdb_service = TMDBService(tmdb_api_key) + db = CalendarDB() + shows = [] + # 简单读取所有已初始化的剧目 + try: + cur = db.conn.cursor() + cur.execute('SELECT tmdb_id FROM shows') + shows = [r[0] for r in cur.fetchall()] + except Exception: + shows = [] + any_written = False + for tmdb_id in shows: + try: + # 直接重用内部逻辑 + with app.app_context(): + # 调用与 endpoint 相同的刷新流程 + season = tmdb_service.get_tv_show_episodes(int(tmdb_id), int(db.get_show(int(tmdb_id))['latest_season_number'])) or {} + episodes = season.get('episodes', []) or [] + from time import time as _now + now_ts = int(_now()) + for ep in episodes: + db.upsert_episode( + tmdb_id=int(tmdb_id), + season_number=int(db.get_show(int(tmdb_id))['latest_season_number']), + episode_number=int(ep.get('episode_number') or 0), + name=ep.get('name') or '', + overview=ep.get('overview') or '', + air_date=ep.get('air_date') or '', + runtime=ep.get('runtime'), + ep_type=(ep.get('episode_type') or ep.get('type')), + updated_at=now_ts, + ) + any_written = True + except Exception as e: + logging.warning(f"自动刷新失败 tmdb_id={tmdb_id}: {e}") + try: + if any_written: + notify_calendar_changed('auto_refresh') + except Exception: + pass + except Exception as e: + logging.warning(f"自动刷新任务异常: {e}") +# 本地缓存读取:获取最新一季的本地剧集数据 +@app.route("/api/calendar/episodes_local") +def get_calendar_episodes_local(): + try: + tmdb_id = request.args.get('tmdb_id') + db = CalendarDB() + # 建立 tmdb_id -> 任务信息 映射,用于前端筛选(任务名/类型) + tmdb_to_taskinfo = {} + try: + tasks = config_data.get('tasklist', []) + for t in tasks: + cal = (t.get('calendar_info') or {}) + match = (cal.get('match') or {}) + extracted = (cal.get('extracted') or {}) + tid = match.get('tmdb_id') + if tid: + tmdb_to_taskinfo[int(tid)] = { + 'task_name': t.get('taskname') or t.get('task_name') or '', + 'content_type': extracted.get('content_type') or extracted.get('type') or 'other', + 'progress': {} + } + # 若未能建立完整映射,尝试基于 shows 表名称与任务 extracted.show_name 做一次兜底绑定 + if not tmdb_to_taskinfo: + try: + cur = db.conn.cursor() + cur.execute('SELECT tmdb_id, name FROM shows') + rows = cur.fetchall() or [] + for tid, name in rows: + name = name or '' + # 找到名称一致的任务 + tgt = next((t for t in tasks if ((t.get('calendar_info') or {}).get('extracted') or {}).get('show_name') == name), None) + if tgt: + extracted = ((tgt.get('calendar_info') or {}).get('extracted') or {}) + tmdb_to_taskinfo[int(tid)] = { + 'task_name': tgt.get('taskname') or tgt.get('task_name') or '', + 'content_type': extracted.get('content_type') or extracted.get('type') or 'other', + 'progress': {} + } + except Exception: + pass + except Exception: + tmdb_to_taskinfo = {} + # 读取任务最新转存文件,构建 task_name -> 解析进度 映射 + progress_by_task = {} + try: + # 直接读取数据库,避免依赖登录校验的接口调用 + extractor = TaskExtractor() + rdb = RecordDB() + cursor = rdb.conn.cursor() + cursor.execute(""" + SELECT task_name, MAX(transfer_time) as latest_transfer_time + FROM transfer_records + WHERE task_name NOT IN ('rename', 'undo_rename') + GROUP BY task_name + """) + latest_times = cursor.fetchall() or [] + latest_files = {} + for task_name, latest_time in latest_times: + if latest_time: + time_window = 60000 + cursor.execute( + """ + SELECT renamed_to, original_name, transfer_time, modify_date + FROM transfer_records + WHERE task_name = ? AND transfer_time >= ? AND transfer_time <= ? + ORDER BY id DESC + """, + (task_name, latest_time - time_window, latest_time + time_window) + ) + files = cursor.fetchall() or [] + best_file = None + if files: + if len(files) == 1: + best_file = files[0][0] + else: + file_list = [] + for renamed_to, original_name, transfer_time, modify_date in files: + file_list.append({'file_name': renamed_to, 'original_name': original_name, 'updated_at': transfer_time}) + try: + sorted_files = sorted(file_list, key=sort_file_by_name) + best_file = sorted_files[-1]['file_name'] + except Exception: + best_file = files[0][0] + if best_file: + file_name_without_ext = os.path.splitext(best_file)[0] + processed_name = process_season_episode_info(file_name_without_ext, task_name) + latest_files[task_name] = processed_name + rdb.close() + for tname, latest in (latest_files or {}).items(): + parsed = extractor.extract_progress_from_latest_file(latest) + if parsed and (parsed.get('episode_number') or parsed.get('air_date')): + progress_by_task[tname] = parsed + except Exception: + progress_by_task = {} + + if tmdb_id: + show = db.get_show(int(tmdb_id)) + if not show: + return jsonify({'success': True, 'data': {'episodes': [], 'total': 0}}) + eps = db.list_latest_season_episodes(int(tmdb_id), int(show['latest_season_number'])) + # 注入任务信息与标准化进度 + info = tmdb_to_taskinfo.get(int(tmdb_id)) + if info: + for e in eps: + e['task_info'] = info + # 合并标准化进度 + tname = info.get('task_name') or '' + p = progress_by_task.get(tname) + if p: + e['task_info']['progress'] = p + return jsonify({'success': True, 'data': {'episodes': eps, 'total': len(eps), 'show': show}}) + else: + # 返回全部最新季汇总(供周视图合并展示) + eps = db.list_all_latest_episodes() + for e in eps: + info = tmdb_to_taskinfo.get(int(e.get('tmdb_id'))) + if info: + e['task_info'] = info + tname = info.get('task_name') or '' + p = progress_by_task.get(tname) + if p: + e['task_info']['progress'] = p + return jsonify({'success': True, 'data': {'episodes': eps, 'total': len(eps)}}) + except Exception as e: + return jsonify({'success': False, 'message': f'读取本地剧集失败: {str(e)}', 'data': {'episodes': [], 'total': 0}}) + + +# 清理缓存:按 tmdb_id 删除对应剧目的所有本地缓存 +@app.route("/api/calendar/purge_tmdb", methods=["POST"]) +def purge_calendar_tmdb(): + try: + tmdb_id = request.args.get('tmdb_id') or (request.json or {}).get('tmdb_id') + if not tmdb_id: + return jsonify({'success': False, 'message': '缺少 tmdb_id'}) + force = False + try: + fv = request.args.get('force') or (request.json or {}).get('force') + force = True if str(fv).lower() in ('1', 'true', 'yes') else False + except Exception: + force = False + ok = purge_calendar_by_tmdb_id_internal(int(tmdb_id), force=force) + try: + if ok: + notify_calendar_changed('purge_tmdb') + except Exception: + pass + return jsonify({'success': ok}) + except Exception as e: + return jsonify({'success': False, 'message': f'清理失败: {str(e)}'}) + + +# 清理缓存:按任务名查找 config 中的 tmdb_id 并清理 +@app.route("/api/calendar/purge_by_task", methods=["POST"]) +def purge_calendar_by_task(): + try: + task_name = request.args.get('task_name') or (request.json or {}).get('task_name') + if not task_name: + return jsonify({'success': False, 'message': '缺少 task_name'}) + force = False + try: + fv = request.args.get('force') or (request.json or {}).get('force') + force = True if str(fv).lower() in ('1', 'true', 'yes') else False + except Exception: + force = False + # 在配置中查找对应任务 + tasks = config_data.get('tasklist', []) + target = next((t for t in tasks if t.get('taskname') == task_name or t.get('task_name') == task_name), None) + if not target: + # 若任务不存在,但用户希望强制清理:尝试按 tmdb_id 反查无引用 show 并清理(更安全) + if force: + # 扫描 shows 表,删除未被任何任务引用的记录(孤儿) + purged = purge_orphan_calendar_shows_internal() + try: + if purged > 0: + notify_calendar_changed('purge_orphans') + except Exception: + pass + return jsonify({'success': True, 'message': f'任务不存在,已强制清理孤儿记录 {purged} 条'}) + return jsonify({'success': True, 'message': '任务不存在,视为已清理'}) + cal = (target or {}).get('calendar_info') or {} + tmdb_id = cal.get('match', {}).get('tmdb_id') or cal.get('tmdb_id') + if not tmdb_id: + return jsonify({'success': True, 'message': '任务未绑定 tmdb_id,无需清理'}) + ok = purge_calendar_by_tmdb_id_internal(int(tmdb_id), force=force) + try: + if ok: + notify_calendar_changed('purge_by_task') + except Exception: + pass + return jsonify({'success': ok}) + except Exception as e: + return jsonify({'success': False, 'message': f'清理失败: {str(e)}'}) # 豆瓣API路由 # 通用电影接口 @@ -2799,6 +5002,59 @@ def get_tv_list(tv_type, sub_category): 'data': {'items': []} }) +@app.route("/api/calendar/update_content_type", methods=["POST"]) +def update_show_content_type(): + """更新节目的内容类型""" + try: + data = request.get_json() + tmdb_id = data.get('tmdb_id') + content_type = data.get('content_type') + + if not tmdb_id or not content_type: + return jsonify({ + 'success': False, + 'message': '缺少必要参数:tmdb_id 或 content_type' + }) + + from app.sdk.db import CalendarDB + cal_db = CalendarDB() + + # 更新内容类型 + success = cal_db.update_show_content_type(int(tmdb_id), content_type) + + if success: + return jsonify({ + 'success': True, + 'message': '内容类型更新成功' + }) + else: + return jsonify({ + 'success': False, + 'message': '节目不存在或更新失败' + }) + + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'更新内容类型失败: {str(e)}' + }) + +@app.route("/api/calendar/content_types") +def get_content_types(): + """获取所有节目内容类型""" + try: + from app.sdk.db import CalendarDB + cal_db = CalendarDB() + content_types = cal_db.get_all_content_types() + return jsonify({ + 'success': True, + 'data': content_types + }) + except Exception as e: + return jsonify({ + 'success': False, + 'message': f'获取节目内容类型失败: {str(e)}' + }) if __name__ == "__main__": init() diff --git a/app/sdk/db.py b/app/sdk/db.py index dfb075d..a1620dc 100644 --- a/app/sdk/db.py +++ b/app/sdk/db.py @@ -268,4 +268,313 @@ class RecordDB: if records: columns = [col[0] for col in cursor.description] return [dict(zip(columns, row)) for row in records] - return [] \ No newline at end of file + return [] + + +class CalendarDB: + """追剧日历本地缓存数据库:剧、季、集信息本地化存储""" + def __init__(self, db_path="config/data.db"): + self.db_path = db_path + self.conn = None + self.init_db() + + 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) + cursor = self.conn.cursor() + + # shows + cursor.execute(''' + CREATE TABLE IF NOT EXISTS shows ( + tmdb_id INTEGER PRIMARY KEY, + name TEXT, + year TEXT, + status TEXT, + poster_local_path TEXT, + latest_season_number INTEGER, + last_refreshed_at INTEGER, + bound_task_names TEXT, + content_type TEXT + ) + ''') + + # 检查 content_type 字段是否存在,如果不存在则添加 + cursor.execute("PRAGMA table_info(shows)") + columns = [column[1] for column in cursor.fetchall()] + if 'content_type' not in columns: + cursor.execute('ALTER TABLE shows ADD COLUMN content_type TEXT') + + # seasons + cursor.execute(''' + CREATE TABLE IF NOT EXISTS seasons ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tmdb_id INTEGER, + season_number INTEGER, + season_name TEXT, + episode_count INTEGER, + refresh_url TEXT, + UNIQUE (tmdb_id, season_number) + ) + ''') + + # episodes + cursor.execute(''' + CREATE TABLE IF NOT EXISTS episodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tmdb_id INTEGER, + season_number INTEGER, + episode_number INTEGER, + name TEXT, + overview TEXT, + air_date TEXT, + runtime INTEGER, + type TEXT, + updated_at INTEGER, + UNIQUE (tmdb_id, season_number, episode_number) + ) + ''') + + self.conn.commit() + + def close(self): + if self.conn: + self.conn.close() + + # 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=""): + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tmdb_id) DO UPDATE SET + name=excluded.name, + year=excluded.year, + status=excluded.status, + poster_local_path=excluded.poster_local_path, + 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)) + self.conn.commit() + + def bind_task_to_show(self, tmdb_id:int, task_name:str): + """绑定任务到节目,在 bound_task_names 字段中记录任务名""" + cursor = self.conn.cursor() + cursor.execute('SELECT bound_task_names FROM shows WHERE tmdb_id=?', (tmdb_id,)) + row = cursor.fetchone() + if not row: + return False + + current_bound_tasks = row[0] or "" + task_list = current_bound_tasks.split(',') if current_bound_tasks else [] + + # 如果任务名不在绑定列表中,则添加 + if task_name not in task_list: + task_list.append(task_name) + new_bound_tasks = ','.join(task_list) + cursor.execute('UPDATE shows SET bound_task_names=? WHERE tmdb_id=?', (new_bound_tasks, tmdb_id)) + self.conn.commit() + return True + return False + + def unbind_task_from_show(self, tmdb_id:int, task_name:str): + """从节目解绑任务,更新 bound_task_names 列表""" + cursor = self.conn.cursor() + cursor.execute('SELECT bound_task_names FROM shows WHERE tmdb_id=?', (tmdb_id,)) + row = cursor.fetchone() + if not row: + return False + current_bound_tasks = row[0] or "" + task_list = [t for t in (current_bound_tasks.split(',') if current_bound_tasks else []) if t] + if task_name in task_list: + task_list.remove(task_name) + new_bound_tasks = ','.join(task_list) + cursor.execute('UPDATE shows SET bound_task_names=? WHERE tmdb_id=?', (new_bound_tasks, tmdb_id)) + self.conn.commit() + return True + return False + + def get_bound_tasks_for_show(self, tmdb_id:int): + """获取绑定到指定节目的任务列表""" + cursor = self.conn.cursor() + cursor.execute('SELECT bound_task_names FROM shows WHERE tmdb_id=?', (tmdb_id,)) + row = cursor.fetchone() + if not row or not row[0]: + return [] + return row[0].split(',') + + def get_show_by_task_name(self, task_name:str): + """根据任务名查找绑定的节目""" + cursor = self.conn.cursor() + cursor.execute('SELECT * FROM shows WHERE bound_task_names LIKE ?', (f'%{task_name}%',)) + row = cursor.fetchone() + if not row: + return None + columns = [c[0] for c in cursor.description] + return dict(zip(columns, row)) + + def get_show(self, tmdb_id:int): + cursor = self.conn.cursor() + cursor.execute('SELECT * FROM shows WHERE tmdb_id=?', (tmdb_id,)) + row = cursor.fetchone() + if not row: + return None + columns = [c[0] for c in cursor.description] + return dict(zip(columns, row)) + + def delete_show(self, tmdb_id:int): + cursor = self.conn.cursor() + cursor.execute('DELETE FROM episodes WHERE tmdb_id=?', (tmdb_id,)) + cursor.execute('DELETE FROM seasons WHERE tmdb_id=?', (tmdb_id,)) + cursor.execute('DELETE FROM shows WHERE tmdb_id=?', (tmdb_id,)) + self.conn.commit() + + # seasons + 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 字段则补充 + try: + cursor.execute("PRAGMA table_info(seasons)") + columns = [column[1] for column in cursor.fetchall()] + if 'season_name' not in columns: + cursor.execute('ALTER TABLE seasons ADD COLUMN season_name TEXT') + except Exception: + pass + cursor.execute(''' + INSERT INTO seasons (tmdb_id, season_number, season_name, episode_count, refresh_url) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(tmdb_id, season_number) DO UPDATE SET + season_name=excluded.season_name, + episode_count=excluded.episode_count, + refresh_url=excluded.refresh_url + ''', (tmdb_id, season_number, season_name, episode_count, refresh_url)) + self.conn.commit() + + 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)) + row = cursor.fetchone() + if not row: + return None + columns = [c[0] for c in cursor.description] + return dict(zip(columns, row)) + + # episodes + 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(''' + INSERT INTO episodes (tmdb_id, season_number, episode_number, name, overview, air_date, runtime, type, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tmdb_id, season_number, episode_number) DO UPDATE SET + name=excluded.name, + overview=excluded.overview, + air_date=excluded.air_date, + runtime=COALESCE(episodes.runtime, excluded.runtime), + type=COALESCE(excluded.type, episodes.type), + updated_at=excluded.updated_at + ''', (tmdb_id, season_number, episode_number, name, overview, air_date, runtime, ep_type, updated_at)) + self.conn.commit() + + def list_latest_season_episodes(self, tmdb_id:int, latest_season:int): + cursor = self.conn.cursor() + cursor.execute(''' + SELECT episode_number, name, overview, air_date, runtime, type + FROM episodes + WHERE tmdb_id=? AND season_number=? + ORDER BY episode_number ASC + ''', (tmdb_id, latest_season)) + rows = cursor.fetchall() + return [ + { + 'episode_number': r[0], + 'name': r[1], + 'overview': r[2], + 'air_date': r[3], + 'runtime': r[4], + 'type': r[5], + } for r in rows + ] + + def list_all_latest_episodes(self): + """返回所有已知剧目的最新季的所有集(扁平列表,供前端汇总显示)""" + cursor = self.conn.cursor() + cursor.execute('SELECT tmdb_id, name, year, status, poster_local_path, latest_season_number FROM shows') + shows = cursor.fetchall() + result = [] + for tmdb_id, name, year, status, poster_local_path, latest_season in shows: + eps = self.list_latest_season_episodes(tmdb_id, latest_season) + for e in eps: + item = { + 'tmdb_id': tmdb_id, + 'show_name': name, + 'year': year, + 'status': status, + 'poster_local_path': poster_local_path, + 'season_number': latest_season, + **e, + } + result.append(item) + return result + + # 内容类型管理方法 + def update_show_content_type(self, tmdb_id:int, content_type:str): + """更新节目的内容类型""" + cursor = self.conn.cursor() + cursor.execute('UPDATE shows SET content_type=? WHERE tmdb_id=?', (content_type, tmdb_id)) + self.conn.commit() + return cursor.rowcount > 0 + + def get_show_content_type(self, tmdb_id:int): + """获取节目的内容类型""" + cursor = self.conn.cursor() + cursor.execute('SELECT content_type FROM shows WHERE tmdb_id=?', (tmdb_id,)) + row = cursor.fetchone() + return row[0] if row else None + + def get_shows_by_content_type(self, content_type:str): + """根据内容类型获取节目列表""" + cursor = self.conn.cursor() + cursor.execute('SELECT * FROM shows WHERE content_type=? ORDER BY name', (content_type,)) + rows = cursor.fetchall() + if not rows: + return [] + columns = [c[0] for c in cursor.description] + return [dict(zip(columns, row)) for row in rows] + + def get_all_content_types(self): + """获取所有已使用的内容类型""" + cursor = self.conn.cursor() + cursor.execute('SELECT DISTINCT content_type FROM shows WHERE content_type IS NOT NULL AND content_type != "" ORDER BY content_type') + rows = cursor.fetchall() + return [row[0] for row in rows] + + def bind_task_and_content_type(self, tmdb_id:int, task_name:str, content_type:str): + """绑定任务到节目并设置内容类型""" + # 先绑定任务 + self.bind_task_to_show(tmdb_id, task_name) + # 再更新内容类型 + self.update_show_content_type(tmdb_id, content_type) + + # --------- 扩展:管理季与集清理/更新工具方法 --------- + def purge_other_seasons(self, tmdb_id: int, keep_season_number: int): + """清除除指定季之外的所有季与对应集数据""" + cursor = self.conn.cursor() + # 删除其他季的 episodes + cursor.execute('DELETE FROM episodes WHERE tmdb_id=? AND season_number != ?', (tmdb_id, keep_season_number)) + # 删除其他季的 seasons 行 + cursor.execute('DELETE FROM seasons WHERE tmdb_id=? AND season_number != ?', (tmdb_id, keep_season_number)) + self.conn.commit() + + def delete_season(self, tmdb_id: int, season_number: int): + """删除指定季及其所有集数据""" + cursor = self.conn.cursor() + cursor.execute('DELETE FROM episodes WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number)) + cursor.execute('DELETE FROM seasons WHERE tmdb_id=? AND season_number=?', (tmdb_id, season_number)) + self.conn.commit() + + 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() diff --git a/app/sdk/tmdb_service.py b/app/sdk/tmdb_service.py new file mode 100644 index 0000000..d81e8e7 --- /dev/null +++ b/app/sdk/tmdb_service.py @@ -0,0 +1,423 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +TMDB服务模块 +用于获取电视节目信息和播出时间表 +""" + +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +import re +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Tuple +import logging + +logger = logging.getLogger(__name__) + +class TMDBService: + def __init__(self, api_key: str = None): + self.api_key = api_key + # 首选改为 api.tmdb.org,备选为 api.themoviedb.org + self.primary_url = "https://api.tmdb.org/3" + self.backup_url = "https://api.themoviedb.org/3" + self.current_url = self.primary_url + self.language = "zh-CN" # 返回中文数据 + # 复用会话,开启重试 + self.session = requests.Session() + retries = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"]) # 简单退避 + adapter = HTTPAdapter(max_retries=retries, pool_connections=20, pool_maxsize=50) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + # 简单内存缓存,避免短时间重复请求 + self._cache = {} + self._cache_ttl_seconds = 600 + + def is_configured(self) -> bool: + """检查TMDB API是否已配置""" + return bool(self.api_key and self.api_key.strip()) + + def reset_to_primary_url(self): + """重置到主API地址""" + self.current_url = self.primary_url + logger.info("TMDB API地址已重置为主地址") + + def get_current_api_url(self) -> str: + """获取当前使用的API地址""" + return self.current_url + + def is_using_backup_url(self) -> bool: + """检查是否正在使用备用地址""" + return self.current_url == self.backup_url + + def _make_request(self, endpoint: str, params: Dict = None) -> Optional[Dict]: + """发送API请求,支持自动切换备用地址""" + if not self.is_configured(): + return None + + if params is None: + params = {} + + params.update({ + 'api_key': self.api_key, + 'language': self.language, + 'include_adult': False + }) + + # 简单缓存键 + try: + from time import time as _now + cache_key = (endpoint, tuple(sorted((params or {}).items()))) + cached = self._cache.get(cache_key) + if cached and (_now() - cached[0]) < self._cache_ttl_seconds: + return cached[1] + except Exception: + pass + + # 尝试主地址 + try: + url = f"{self.current_url}{endpoint}" + response = self.session.get(url, params=params, timeout=10) + response.raise_for_status() + data = response.json() + try: + self._cache[cache_key] = (_now(), data) + except Exception: + pass + return data + except Exception as e: + logger.warning(f"TMDB主地址请求失败: {e}") + + # 如果当前使用的是主地址,尝试切换到备用地址 + if self.current_url == self.primary_url: + logger.info("尝试切换到TMDB备用地址...") + self.current_url = self.backup_url + try: + url = f"{self.current_url}{endpoint}" + response = self.session.get(url, params=params, timeout=10) + response.raise_for_status() + logger.info("TMDB备用地址连接成功") + data = response.json() + try: + self._cache[cache_key] = (_now(), data) + except Exception: + pass + return data + except Exception as backup_e: + logger.error(f"TMDB备用地址请求也失败: {backup_e}") + # 重置回主地址,下次请求时重新尝试 + self.current_url = self.primary_url + return None + else: + # 如果备用地址也失败,重置回主地址 + logger.error(f"TMDB备用地址请求失败: {e}") + self.current_url = self.primary_url + return None + + def search_tv_show(self, query: str, year: str = None) -> Optional[Dict]: + """搜索电视剧""" + params = { + 'query': query, + 'first_air_date_year': year + } + + result = self._make_request('/search/tv', params) + if result and result.get('results'): + # 返回第一个匹配结果 + return result['results'][0] + return None + + def get_tv_show_details(self, tv_id: int) -> Optional[Dict]: + """获取电视剧详细信息""" + return self._make_request(f'/tv/{tv_id}') + + def get_tv_show_alternative_titles(self, tv_id: int) -> Optional[Dict]: + """获取电视剧的别名信息""" + return self._make_request(f'/tv/{tv_id}/alternative_titles') + + def _is_chinese_text(self, text: str) -> bool: + """检查文本是否包含中文字符""" + if not text: + return False + for char in text: + if '\u4e00' <= char <= '\u9fff': # 中文字符范围 + return True + return False + + def get_chinese_title_with_fallback(self, tv_id: int, original_title: str = "") -> str: + """ + 获取中文标题,如果中文标题为空或不是中文则从别名中获取中国地区的别名 + + Args: + tv_id: TMDB ID + original_title: 原始标题,作为最后的备用方案 + + Returns: + 中文标题或备用标题 + """ + try: + # 首先获取节目详情,检查是否有中文标题 + details = self.get_tv_show_details(tv_id) + if details: + tmdb_name = details.get('name', '').strip() + + # 检查TMDB返回的标题是否包含中文字符 + if tmdb_name and self._is_chinese_text(tmdb_name): + logger.info(f"直接获取到中文标题: {tmdb_name} (TMDB ID: {tv_id})") + return tmdb_name + + # 如果TMDB返回的标题不是中文,尝试从别名中获取中国地区的别名 + alternative_titles = self.get_tv_show_alternative_titles(tv_id) + if alternative_titles and alternative_titles.get('results'): + # 查找中国地区的别名 + for alt_title in alternative_titles['results']: + if alt_title.get('iso_3166_1') == 'CN': + chinese_alt_title = alt_title.get('title', '').strip() + if chinese_alt_title and self._is_chinese_text(chinese_alt_title): + logger.info(f"从别名中获取到中文标题: {chinese_alt_title} (TMDB ID: {tv_id})") + return chinese_alt_title + + # 如果都没有找到中文标题,返回原始标题 + logger.info(f"未找到中文标题,使用原始标题: {original_title} (TMDB ID: {tv_id})") + return original_title + else: + # 如果无法获取详情,返回原始标题 + return original_title + + except Exception as e: + logger.warning(f"获取中文标题失败: {e}, 使用原始标题: {original_title}") + return original_title + + def get_tv_show_episodes(self, tv_id: int, season_number: int) -> Optional[Dict]: + """获取指定季的剧集信息""" + return self._make_request(f'/tv/{tv_id}/season/{season_number}') + + def get_tv_show_air_dates(self, tv_id: int) -> Optional[Dict]: + """获取电视剧播出时间信息""" + return self._make_request(f'/tv/{tv_id}/air_dates') + + def get_tv_show_episode_air_dates(self, tv_id: int, season_number: int) -> List[Dict]: + """获取指定季所有剧集的播出时间""" + episodes = self.get_tv_show_episodes(tv_id, season_number) + if not episodes or 'episodes' not in episodes: + return [] + + episode_list = [] + for episode in episodes['episodes']: + if episode.get('air_date'): + episode_list.append({ + 'episode_number': episode.get('episode_number'), + 'air_date': episode.get('air_date'), + 'name': episode.get('name'), + 'overview': episode.get('overview') + }) + + return episode_list + + # ===== 节目状态中文映射(不含 returning_series 场景判断;该判断在 run.py 本地完成) ===== + def map_show_status_cn(self, status: str) -> str: + """将 TMDB 节目状态映射为中文。未知状态保持原样。""" + try: + if not status: + return '' + key = str(status).strip().lower().replace(' ', '_') + mapping = { + 'returning_series': '播出中', + 'in_production': '制作中', + 'planned': '计划中', + 'ended': '已完结', + 'canceled': '已取消', + 'cancelled': '已取消', + 'pilot': '试播集', + 'rumored': '待确认', + } + return mapping.get(key, status) + except Exception: + return status + + # 注意:returning_series 的“播出中/本季终”判断在 run.py 使用本地 seasons/episodes 统计完成 + + def arabic_to_chinese_numeral(self, number: int) -> str: + """将阿拉伯数字转换为中文数字(用于季数),支持到万(0 < number < 100000)。 + 规则与约定: + - 基本单位:一、二、三、四、五、六、七、八、九、十、百、千、万;包含“零”与“两(2的特殊口语用法)”。 + - 10-19 省略“一十”:10=十,11=十一。 + - “两”的使用:在 百/千/万 位上优先使用“两”(如200=两百,2000=两千,20000=两万);十位仍用“二十”。 + - 正确处理“零”的读法:如 101=一百零一,1001=一千零一,10010=一万零一十。 + - 超出范围(<=0 或 >=100000)时返回原数字字符串。 + """ + try: + number = int(number) + except Exception: + return str(number) + + if number <= 0 or number >= 100000: + return str(number) + + digits = ["零", "一", "二", "三", "四", "五", "六", "七", "八", "九"] + + def convert_0_9999(n: int) -> str: + if n == 0: + return "零" + if n < 10: + return digits[n] + if n < 20: + # 十到十九 + return "十" + (digits[n - 10] if n > 10 else "") + parts = [] + thousand = n // 1000 + hundred = (n % 1000) // 100 + ten = (n % 100) // 10 + one = n % 10 + + # 千位 + if thousand: + if thousand == 2: + parts.append("两千") + else: + parts.append(digits[thousand] + "千") + + # 百位 + if hundred: + if thousand and hundred == 0: + # 不会发生:hundred 有值才进来 + pass + if hundred == 2: + parts.append("两百") + else: + parts.append(digits[hundred] + "百") + else: + if thousand and (ten != 0 or one != 0): + parts.append("零") + + # 十位 + if ten: + if ten == 1 and not thousand and not hundred: + # 10-19 形式(已在 n<20 处理)但也考虑 0xx 场景 + parts.append("十") + else: + parts.append(digits[ten] + "十") + else: + if (hundred or thousand) and one != 0: + parts.append("零") + + # 个位 + if one: + parts.append(digits[one]) + + # 合并并清理多余“零” + result = ''.join(parts) + # 去重连续零 + while "零零" in result: + result = result.replace("零零", "零") + # 尾部零去掉 + if result.endswith("零"): + result = result[:-1] + return result + + if number < 10000: + return convert_0_9999(number) + + # 处理万级:number = a * 10000 + b, 1 <= a <= 9, 0 <= b < 10000 + wan = number // 10000 + rest = number % 10000 + + # 万位上的 2 使用“两万”,其他使用常规数字 + 万 + if wan == 2: + prefix = "两万" + else: + prefix = digits[wan] + "万" + + if rest == 0: + return prefix + + # rest 存在且不足四位时,需要根据是否存在中间的 0 添加“零” + rest_str = convert_0_9999(rest) + # 当 rest < 1000 时,且 rest_str 不以“零”开头,需要补一个“零” + if rest < 1000: + if not rest_str.startswith("零"): + return prefix + "零" + rest_str + return prefix + rest_str + + def process_season_name(self, raw_name: str) -> str: + """将 TMDB 返回的季名称进行本地化处理: + - 将“第1季”“第 24 季”转换为“第一季”“第二十四季” + - 其他名称保持原样 + """ + try: + if not raw_name: + return raw_name + # 匹配“第 1 季”“第1季”“第 24 季”等 + m = re.search(r"第\s*(\d+)\s*季", raw_name) + if m: + n = int(m.group(1)) + cn = self.arabic_to_chinese_numeral(n) + return re.sub(r"第\s*\d+\s*季", f"第{cn}季", raw_name) + return raw_name + except Exception: + return raw_name + + def search_and_get_episodes(self, show_name: str, year: str = None) -> Optional[Dict]: + """搜索电视剧并获取剧集信息""" + # 搜索电视剧 + show = self.search_tv_show(show_name, year) + if not show: + return None + + tv_id = show.get('id') + if not tv_id: + return None + + # 获取详细信息 + details = self.get_tv_show_details(tv_id) + if not details: + return None + + # 获取所有季的剧集信息 + seasons = details.get('seasons', []) + all_episodes = [] + + for season in seasons: + season_number = season.get('season_number', 0) + if season_number > 0: # 排除特殊季(如第0季) + episodes = self.get_tv_show_episode_air_dates(tv_id, season_number) + for episode in episodes: + episode['season_number'] = season_number + episode['show_name'] = show_name + episode['show_id'] = tv_id + all_episodes.append(episode) + + return { + 'show_info': { + 'id': tv_id, + 'name': show_name, + 'original_name': details.get('original_name'), + 'overview': details.get('overview'), + 'poster_path': details.get('poster_path'), + 'first_air_date': details.get('first_air_date'), + 'media_type': details.get('media_type', 'tv') + }, + 'episodes': all_episodes + } + + def get_episodes_by_date_range(self, start_date: str, end_date: str, show_name: str = None) -> List[Dict]: + """获取指定日期范围内的剧集播出信息""" + if not self.is_configured(): + return [] + + # 这里可以实现更复杂的日期范围查询 + # 目前简化实现,返回空列表 + # 实际项目中可以通过TMDB的discover API或其他方式实现 + return [] + + def convert_to_beijing_time(self, utc_time_str: str) -> str: + """将UTC时间转换为北京时间""" + try: + # 解析UTC时间 + utc_time = datetime.fromisoformat(utc_time_str.replace('Z', '+00:00')) + # 转换为北京时间(UTC+8) + beijing_time = utc_time + timedelta(hours=8) + return beijing_time.strftime('%Y-%m-%d') + except Exception as e: + logger.error(f"时间转换失败: {e}") + return utc_time_str diff --git a/app/static/css/main.css b/app/static/css/main.css index b2b4cbe..b4f2ae8 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -3,6 +3,19 @@ * 整合了所有 WebUI 的样式,包括 index.html、login.html 和 dashboard.css 中的样式 */ +/* --------------- CSS变量定义 --------------- */ +:root { + --focus-border-color-rgb: 0, 123, 255; + --success-color: #28a745; + --secondary-text-color: #979799; /* 影视发现类型文本中灰色 */ + --dark-text-color: #404040; + --light-text-color: #b1b1b3; + --border-color: #e3e3e5; /* 统一边框颜色变量 */ + --focus-border-color: #0D53FF; /* 输入框聚焦时的边框颜色 */ + --shadow-spread: 0; /* 统一阴影扩散距离设为0 */ + --button-gray-background-color: #ededf0; /* 按钮灰色背景颜色 */ +} + /* --------------- 基础样式 --------------- */ body { font-size: 1rem; @@ -159,6 +172,11 @@ main .row.title { margin-left: -15px; /* 添加负边距向左移动2px */ } +/* 精准调整:QASX API 模块与上方模块的垂直间距减小 8px */ +main .row.title[title^="QASX API"] { + margin-top: 12px; +} + /* 系统配置页面标题文字样式 */ main .row.title h2 { font-size: 1.25rem !important; /* 标题字体大小 */ @@ -487,6 +505,10 @@ main div[v-if="activeTab === 'config'"] .row.title:first-child { /* 侧边栏折叠相关功能只在大尺寸屏幕上启用 */ @media (max-width: 767.98px) { + /* 追剧日历:筛选框与下方按钮组的竖向间距调整为 8px */ + .calendar-filter-row { + margin-bottom: 8px !important; + } .sidebar-collapsed { width: auto !important; min-width: auto !important; @@ -897,6 +919,25 @@ select.form-control { font-size: 0.9rem; } +/* 合并集切换按钮图标样式 */ +.bi-file-break { + color: var(--dark-text-color); + font-size: 0.95rem; +} + +.bi-file { + color: var(--dark-text-color); + font-size: 0.95rem; +} + +/* 追剧日历视图切换按钮图标样式 */ +.bi-grid-3x3-gap { + color: var(--dark-text-color); + font-size: 0.98rem; + position: relative; + top: 0.5px; +} + /* TMDB图标和豆瓣图标样式 */ .tmdb-icon { width: 1.07rem; @@ -922,6 +963,9 @@ select.form-control { .btn-outline-secondary:hover .bi-link-45deg, .btn-outline-secondary:hover .bi-google, .btn-outline-secondary:hover .bi-calendar3, +.btn-outline-secondary:hover .bi-file-break, +.btn-outline-secondary:hover .bi-file, +.btn-outline-secondary:hover .bi-grid-3x3-gap, .btn-outline-secondary:hover .bi-eye, .btn-outline-secondary:hover .bi-eye-slash, .btn-outline-secondary:hover .bi-reply, @@ -1197,7 +1241,7 @@ table.table thead th { /* 表头悬停样式 */ .table thead th.cursor-pointer:hover { - background-color: #f7f7fa; /* 表头悬停背景色 */ + background-color: #f7f7f9; /* 表头悬停背景色 */ } /* 表格单元格样式 */ @@ -1220,6 +1264,51 @@ table.table thead th { background-color: var(--button-gray-background-color); /* 行悬停背景色 */ } +/* 任务列表:悬停不改变新增信息颜色(集数统计/任务进度/节目状态) */ +/* 任务列表新增信息样式,跟随最近转存文件/最近更新日期的风格与交互 */ +.task-season-counts, +.task-progress, +.task-show-status { + color: var(--dark-text-color); + font-size: 0.95rem; +} + +/* 仅调节两个位置的集数统计中的斜杠上移: + 1) 任务列表里的 .task-season-counts */ +.task-season-counts .count-slash, +.discovery-poster-overlay .count-slash { + position: relative; + top: -2px; + display: inline-block; + font-size: 0.8em; /* 单独缩小斜杠字号,仅影响这两处 */ + font-weight: 600; /* 提高字重,抵消缩小字号带来的变细感 */ +} + +/* 仅调节两个位置的集数统计中的斜杠上移: + 2) 内容管理海报悬停层里的 .calendar-poster-overlay(复用同一类名) */ + .calendar-poster-overlay .count-slash, + .discovery-poster-overlay .count-slash { + position: relative; + top: -1.5px; + display: inline-block; + font-size: 0.8em; /* 单独缩小斜杠字号,仅影响这两处 */ + font-weight: 600; /* 提高字重,抵消缩小字号带来的变细感 */ + } + +/* 悬停显示模式 - 与 task-latest-* 保持一致:默认隐藏,仅在悬停时显示,且不变色 */ +.task-season-counts.hover-only, +.task-progress.hover-only, +.task-show-status.hover-only { + display: none; + transition: all 0.2s ease; +} + +.task .btn:hover .task-season-counts.hover-only, +.task .btn:hover .task-progress.hover-only, +.task .btn:hover .task-show-status.hover-only { + display: inline; +} + /* 长文本列的特殊处理 */ .table td.text-wrap { white-space: normal; @@ -1431,14 +1520,16 @@ button.close:focus, /* --------------- 文件选择弹窗样式 --------------- */ /* 文件选择弹窗整体样式 */ #fileSelectModal .modal-dialog, -#createTaskModal .modal-dialog { +#createTaskModal .modal-dialog, +#editMetadataModal .modal-dialog { max-width: 1080px; margin: 4rem auto; width: calc(100% - 1.25rem); /* 左右各保留1.5rem的最小边距 */ } #fileSelectModal .modal-content, -#createTaskModal .modal-content { +#createTaskModal .modal-content, +#editMetadataModal .modal-content { border-radius: 6px; border: 1px solid var(--border-color); box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1); @@ -1446,7 +1537,8 @@ button.close:focus, /* 弹窗头部样式 */ #fileSelectModal .modal-header, -#createTaskModal .modal-header { +#createTaskModal .modal-header, +#editMetadataModal .modal-header { background-color: #fff; border-bottom: 1px solid var(--border-color); padding: 11px 16px; @@ -1455,7 +1547,8 @@ button.close:focus, } #fileSelectModal .modal-title, -#createTaskModal .modal-title { +#createTaskModal .modal-title, +#editMetadataModal .modal-title { font-size: 1.2rem; font-weight: 500; color: var(--dark-text-color); @@ -1470,7 +1563,8 @@ button.close:focus, /* 弹窗关闭按钮 */ #fileSelectModal .close, -#createTaskModal .close { +#createTaskModal .close, +#editMetadataModal .close { font-size: 1.4rem; padding: 8px; margin: -8px -8px -8px auto; @@ -1482,6 +1576,7 @@ button.close:focus, /* 修改关闭按钮样式,使用 bi-x-lg 图标 */ #fileSelectModal .close .bi-x-lg, #createTaskModal .close .bi-x-lg, +#editMetadataModal .close .bi-x-lg, .modal .close .bi-x-lg { font-size: 1.2rem; color: var(--dark-text-color); @@ -1492,25 +1587,29 @@ button.close:focus, } #fileSelectModal .close:hover, -#createTaskModal .close:hover { +#createTaskModal .close:hover, +#editMetadataModal .close:hover { opacity: 1; color: var(--dark-text-color); } /* 弹窗主体样式 */ #fileSelectModal .modal-body, -#createTaskModal .modal-body { +#createTaskModal .modal-body, +#editMetadataModal .modal-body { padding: 16px; font-size: 0.875rem; } /* 创建任务模态框主内容区相对定位 */ -#createTaskModal .modal-body { +#createTaskModal .modal-body, +#editMetadataModal .modal-body { position: relative; } /* 创建任务模态框主内容区底部分割线 */ -#createTaskModal .modal-body::after { +#createTaskModal .modal-body::after, +#editMetadataModal .modal-body::after { content: ''; position: absolute; bottom: 7px; @@ -1538,6 +1637,91 @@ button.close:focus, align-items: center; /* 垂直居中 */ flex-wrap: wrap; /* 允许换行 */ } +/* 编辑元数据模态框:输入前标题灰底,统一输入高度与字体,与创建任务保持一致 */ +#editMetadataModal .input-group-prepend .input-group-text { + background-color: var(--button-gray-background-color) !important; +} +#editMetadataModal .form-control, +#editMetadataModal .input-group-text, +#editMetadataModal .input-group .form-control::placeholder { + height: 32px; + font-size: 0.95rem; /* 与创建任务模态框的页面内容字号一致 */ +} +#editMetadataModal .input-group-text, +#editMetadataModal .form-control, +#editMetadataModal .input-group-append .input-group-text { + border-color: var(--border-color); +} + +/* 编辑模态:两列输入的横向间距 8px(每列左右各4px) */ +#editMetadataModal .row > [class*="col-"] { + padding-right: 4px; + padding-left: 4px; +} +/* 抵消父级row默认的左右负margin,确保净间距为8px */ +#editMetadataModal .row { + margin-right: -4px; + margin-left: -4px; +} + +/* 灰底方块:第 / 季 统一尺寸并重叠边框 */ +#editMetadataModal .input-group .input-group-text.square-append, +#editMetadataModal .input-group .input-group-prepend .input-group-text.square-append, +#editMetadataModal .input-group .input-group-append .input-group-text.square-append { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--button-gray-background-color); + border-color: var(--border-color); +} + +/* 重叠边框:后置灰块与输入之间、两端灰块与输入之间边框不重复 */ +#editMetadataModal .input-group .input-group-prepend .input-group-text.square-append, +#editMetadataModal .input-group .input-group-append .input-group-text.square-append { + border-color: var(--border-color); +} +#editMetadataModal .input-group .input-group-prepend .input-group-text.square-append.season-left { + border-right: 0 !important; /* 取消右边框,由输入框左边框承担分隔线 */ + width: 32px; + margin-right: 0; +} +#editMetadataModal .input-group .input-group-append .input-group-text.square-append.season-right { + border-left: 1px solid var(--border-color); +} +/* 修正:嵌入在 .form-control 内部的方块两侧边框不重复 */ +#editMetadataModal .form-control .input-group-text.square-append { + border-left: none; + border-right: none; +} + +/* 修正季数输入宽度默认值和展示 */ +#editMetadataModal .edit-season-number { + box-sizing: border-box; /* 让边框计入总宽度 */ + width: 32px; /* 基础宽度,允许内联样式按内容放大 */ + min-width: 32px; + text-align: center; + padding: 0 8px; /* 提供足够内边距避免数字贴边被裁切 */ + border-left: 1px solid var(--border-color); /* 使用输入框左边框作为唯一分隔线 */ + margin-left: -1px; /* 将左边框与“第”的右侧对齐并覆盖,避免双线 */ +} + +/* 焦点态:与其他输入一致,仅边框变色,无阴影/扩展;左侧边线覆盖“第”的右边线 */ +#editMetadataModal .edit-season-number:focus { + outline: none; + box-shadow: none !important; + border-color: var(--focus-border-color) !important; + border-left-color: var(--focus-border-color) !important; + margin-left: -1px; /* 焦点态保持覆盖,避免出现双线 */ +} + +/* 焦点态:保持四边高亮(左侧也显示蓝色),不改变布局 */ +#editMetadataModal .edit-season-number:focus { + outline: none; + box-shadow: inset 0 0 0 1px #0d6efd, 0 0 0 0.2rem rgba(13,110,253,.25); +} #fileSelectModal .breadcrumb-item { display: flex; @@ -1748,7 +1932,8 @@ button.close:focus, /* 弹窗底部样式 */ #fileSelectModal .modal-footer, -#createTaskModal .modal-footer { +#createTaskModal .modal-footer, +#editMetadataModal .modal-footer { border-top: none; /* 隐藏底部分割线 */ padding: 0px 16px 12px 16px; /* 上 右 下 左:设置上内边距为0 */ margin-top: -4px; /* 使用负margin使整个底部区域向上移动 */ @@ -1760,13 +1945,15 @@ button.close:focus, /* 添加文件选择模态框左下角文件信息文本的左边距样式 */ #fileSelectModal .modal-footer .file-selection-info, -#createTaskModal .modal-footer .file-selection-info { +#createTaskModal .modal-footer .file-selection-info, +#editMetadataModal .modal-footer .file-selection-info { margin-left: 0px; /* 与表格左边距保持一致 */ font-size: 0.85rem !important; /* 覆盖内联样式 */ } #fileSelectModal .modal-footer span, -#createTaskModal .modal-footer span { +#createTaskModal .modal-footer span, +#editMetadataModal .modal-footer span { font-size: 0.85rem; color: var(--dark-text-color); margin-right: auto; @@ -1774,7 +1961,8 @@ button.close:focus, /* 弹窗底部按钮样式 */ #fileSelectModal .btn-primary, -#createTaskModal .btn-primary { +#createTaskModal .btn-primary, +#editMetadataModal .btn-primary { background-color: var(--focus-border-color); border-color: var(--focus-border-color); font-size: 0.85rem; @@ -1789,14 +1977,16 @@ button.close:focus, /* 弹窗底部按钮内的标记样式 */ #fileSelectModal .btn-primary .badge, -#createTaskModal .btn-primary .badge { +#createTaskModal .btn-primary .badge, +#editMetadataModal .btn-primary .badge { margin-left: 5px; display: flex; align-items: center; } #fileSelectModal .btn-primary:hover, -#createTaskModal .btn-primary:hover { +#createTaskModal .btn-primary:hover, +#editMetadataModal .btn-primary:hover { background-color: #0A42CC !important; border-color: #0A42CC !important; } @@ -2039,7 +2229,7 @@ div.jsoneditor-tree button.jsoneditor-button:focus { /* 登录页标题区域 - 浅灰色背景 */ .login-header { - background-color: #f7f7fa; + background-color: #f7f7f9; padding: 30px; margin-bottom: 0; height: 100px; /* 设置固定高度 */ @@ -2195,6 +2385,10 @@ div.jsoneditor-tree button.jsoneditor-button:focus { font-size: 0.94rem; } +.sidebar .nav-link .bi-calendar3-week { + font-size: 0.94rem; +} + .sidebar .nav-link .bi-power { font-size: 1.27rem; } @@ -2515,7 +2709,7 @@ body { /* 设置侧边栏背景色 */ .sidebar.bg-light { - background-color: #f7f7fa !important; + background-color: #f7f7f9 !important; } /* 设置任务列表之间的分隔线颜色 */ @@ -2680,6 +2874,15 @@ body { outline: none !important; } +/* 确保日历图标按钮在获得焦点后,悬停时仍正确显示深色背景 */ +.btn-outline-secondary:has(.bi-calendar3):hover, +.btn-outline-secondary:has(.bi-calendar3):hover:focus, +.btn-outline-secondary:has(.bi-calendar3):hover:active { + background-color: var(--dark-text-color) !important; + border-color: var(--dark-text-color) !important; + color: #fff !important; +} + /* 自定义JSON编辑器样式 */ /* 调整搜索框样式 */ div.jsoneditor-search { @@ -3752,7 +3955,7 @@ input::-moz-list-button { } #fileSelectModal .modal-body > div::-webkit-scrollbar-track { - background-color: #f7f7fa; /* 滚动条轨道 */ + background-color: #f7f7f9; /* 滚动条轨道 */ border-radius: 4px; /* 圆角滚动条 */ } } @@ -3834,7 +4037,7 @@ input::-moz-list-button { /* 模态框表头悬停样式 */ #fileSelectModal .table th.cursor-pointer:hover { - background-color: #f7f7fa; /* 表头悬停背景色 */ + background-color: #f7f7f9; /* 表头悬停背景色 */ cursor: pointer; } @@ -4336,6 +4539,11 @@ table.selectable-records .expand-button:hover { visibility: visible; } +/* 日历(月视图)当日更新星标尺寸:日历剧名字号较小,星标相应缩小 */ +.calendar-month-episode .task-today-indicator { + font-size: 0.95em; +} + .display-setting-row { margin-left: -4px !important; margin-right: -4px !important; @@ -4508,6 +4716,8 @@ select.task-filter-select, .batch-rename-btn { width: 32px; height: 32px; + min-width: 32px; + max-width: 32px; padding: 0; display: flex; align-items: center; @@ -4515,6 +4725,7 @@ select.task-filter-select, border-radius: 6px !important; color: var(--dark-text-color) !important; border-color: var(--dark-text-color) !important; + flex-shrink: 0; } /* 为相邻的batch-rename-btn按钮添加左边距 */ @@ -4965,7 +5176,7 @@ table.selectable-files .file-size-cell { /* 表头悬停样式 */ .table thead th.cursor-pointer:hover { - background-color: #f7f7fa; /* 表头悬停背景色 */ + background-color: #f7f7f9; /* 表头悬停背景色 */ } /* 确保所有表格的表头悬停样式一致 */ @@ -4973,7 +5184,7 @@ table.selectable-files .file-size-cell { .selectable-files th.cursor-pointer:hover, table.selectable-files th.cursor-pointer:hover, table.selectable-records th.cursor-pointer:hover { - background-color: #f7f7fa !important; /* 表头悬停背景色 */ + background-color: #f7f7f9 !important; /* 表头悬停背景色 */ cursor: pointer; } @@ -5608,13 +5819,15 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil } /* 创建任务模态框的取消按钮样式 */ -#createTaskModal .modal-footer .btn-cancel { +#createTaskModal .modal-footer .btn-cancel, +#editMetadataModal .modal-footer .btn-cancel { background-color: var(--button-gray-background-color) !important; border-color: var(--button-gray-background-color) !important; color: var(--dark-text-color) !important; } -#createTaskModal .modal-footer .btn-cancel:hover { +#createTaskModal .modal-footer .btn-cancel:hover, +#editMetadataModal .modal-footer .btn-cancel:hover { background-color: #e0e2e6 !important; border-color: #e0e2e6 !important; color: var(--dark-text-color) !important; @@ -5633,7 +5846,8 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil /* --------------- 创建任务模态框使用任务列表样式 --------------- */ /* 创建任务模态框底部间距调整 */ -#createTaskModal .modal-footer { +#createTaskModal .modal-footer, +#editMetadataModal .modal-footer { margin-top: 5px; /* 调整为5px,让分割线距离按钮16px */ } @@ -6033,7 +6247,7 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil .file-manager-rule-bar-responsive .input-group-append .btn { margin-left: 8px; } - /* 只影响移动端的“预览并执行重命名”按钮上边距 */ + /* 只影响移动端的"预览并执行重命名"按钮上边距 */ .file-manager-rule-bar-responsive .batch-rename-btn { margin-top: 0px; /* 你想要的上边距 */ width: 32px; @@ -6167,7 +6381,7 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil left: 0; right: 0; bottom: 0; - background: rgba(0, 0, 0, 0.7); + background: var(--dark-text-color); color: white; padding: 8px 8px 5px 8px; font-size: 0.75rem; @@ -6216,6 +6430,26 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil z-index: 10; } +/* 转存进度徽标颜色:类名使用状态名称,便于直接调整 */ +.discovery-rating.status-finale { color: #efb30a; } /* 黄色(评分同色) */ +.discovery-rating.status-ended { color: #ff4d4f; } /* 红色 */ +.discovery-rating.status-other { color: #00C853; } /* 绿色 */ + +/* 追剧日历 - 已转存标识 */ +.calendar-transferred-badge { color: #00C853; padding: 2px 8px 2px 7.5px; } +.calendar-transferred-badge i { + line-height: 1; + display: inline-block; + transform: translateY(0.5px) scale(1.5); + /* 使用多向阴影模拟加粗(图标为字体,font-weight不生效) */ + text-shadow: + 0 0 0 currentColor, + 0 0.45px 0 currentColor, + 0 -0.45px 0 currentColor, + 0.45px 0 0 currentColor, + -0.45px 0 0 currentColor; +} + .discovery-create-task { position: absolute; top: 8px; @@ -6237,8 +6471,16 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil font-weight: 210; } -.discovery-create-task .plus-text { - transform: translateX(-0.5px) translateY(-1.5px); +.discovery-create-task i { + font-size: 0.8em; + transform: translateX(0px) translateY(0px); + /* 使用多向阴影模拟加粗(图标为字体,font-weight不生效) */ + text-shadow: + 0 0 0 currentColor, + 0 0.1px 0 currentColor, + 0 -0.1px 0 currentColor, + 0.1px 0 0 currentColor, + -0.1px 0 0 currentColor; } .discovery-poster:hover .discovery-create-task { @@ -6251,6 +6493,124 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil color: white; } +/* 追剧日历海报视图刷新按钮样式 */ +.calendar-refresh-metadata { + position: absolute; + top: 8px; + left: 8px; + width: 22px; + height: 22px; + background-color: transparent; + border: 1px solid white; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.3s ease; + z-index: 10; + color: white; + font-size: 0.8rem; + /* 使用多向阴影模拟加粗(图标为字体,font-weight不生效) */ + text-shadow: + 0 0 0 currentColor, + 0 0.1px 0 currentColor, + 0 -0.1px 0 currentColor, + 0.1px 0 0 currentColor, + -0.1px 0 0 currentColor; +} + +.calendar-poster:hover .calendar-refresh-metadata { + opacity: 1; +} + +.calendar-refresh-metadata:hover { + background-color: var(--focus-border-color); + border-color: var(--focus-border-color); + color: white; +} + +/* 内容管理视图刷新按钮样式 */ +.discovery-refresh-metadata { + position: absolute; + top: 8px; + left: 8px; + width: 22px; + height: 22px; + background-color: transparent; + border: 1px solid white; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.3s ease; + z-index: 10; + color: white; + font-size: 0.8rem; + /* 使用多向阴影模拟加粗(图标为字体,font-weight不生效) */ + text-shadow: + 0 0 0 currentColor, + 0 0.1px 0 currentColor, + 0 -0.1px 0 currentColor, + 0.1px 0 0 currentColor, + -0.1px 0 0 currentColor; +} + +.discovery-poster:hover .discovery-refresh-metadata { + opacity: 1; +} + +.discovery-refresh-metadata:hover { + background-color: var(--focus-border-color); + border-color: var(--focus-border-color); + color: white; +} + +/* 内容管理视图编辑按钮样式(与刷新按钮一致,靠右一点) */ +.discovery-edit-metadata { + position: absolute; + top: 8px; + left: 36px; + width: 22px; + height: 22px; + background-color: transparent; + border: 1px solid white; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + opacity: 0; + transition: all 0.3s ease; + z-index: 10; + color: white; + /* 使用多向阴影模拟加粗(图标为字体,font-weight不生效) */ + text-shadow: + 0 0 0 currentColor, + 0 0.1px 0 currentColor, + 0 -0.1px 0 currentColor, + 0.1px 0 0 currentColor, + -0.1px 0 0 currentColor; +} + +/* 内容管理视图编辑元数据按钮图标样式 */ +.discovery-edit-metadata i { + font-size: 0.67rem; +} + +.discovery-poster:hover .discovery-edit-metadata { + opacity: 1; +} + +.discovery-edit-metadata:hover { + background-color: var(--focus-border-color); + border-color: var(--focus-border-color); + color: white; +} + .discovery-info { padding: 0 0px; text-align: left; @@ -6400,7 +6760,7 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil letter-spacing: normal; } -/* 仅在“搜索来源”前的最后一个插件折叠时,将间距减少 2px */ +/* 仅在"搜索来源"前的最后一个插件折叠时,将间距减少 2px */ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) { margin-bottom: -10px !important; /* override inline -8px only for collapsed state */ } @@ -6437,4 +6797,946 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) { line-height: 1.2; white-space: nowrap; vertical-align: baseline; +} + +/* --------------- 追剧日历样式 --------------- */ +/* 日历分类按钮样式 */ +.calendar-category-buttons { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: -12px; +} + +.calendar-category-btn { + border-radius: 6px; + padding: 6px 12px; + font-size: 0.95rem; + transition: all 0.2s ease; + border: 1px solid var(--dark-text-color); + background-color: transparent; + color: var(--dark-text-color); + height: 32px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.calendar-category-btn:hover { + background-color: var(--dark-text-color); + border-color: var(--dark-text-color); + color: white; +} + +.calendar-category-btn.active { + background-color: var(--focus-border-color) !important; + border-color: var(--focus-border-color) !important; + color: white !important; +} + +/* 日历控制按钮样式 */ +.calendar-controls { + display: flex; + align-items: center; + gap: 8px; + margin-top: -12px; + flex-wrap: nowrap; + min-width: 0; +} + +/* 移动端:追剧日历右上角按钮栏不换行并可横向滚动 */ +@media (max-width: 767.98px) { + /* 追剧日历:顶部一行(分类 + 控制)不换行 */ + .calendar-header-row { + flex-wrap: nowrap !important; + align-items: center; + overflow-x: auto !important; /* 整行作为滚动容器 */ + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + column-gap: 8px; /* 左右两组之间 8px 间距 */ + margin-top: 8px !important; /* 与上方内容保持 8px 间距 */ + /* 去除内边距,使用遮罩实现两侧 20px 的“硬遮挡”效果(无渐变) */ + padding-left: 0; + padding-right: 0; + -webkit-mask-image: linear-gradient(to right, + transparent 0, + transparent 15px, + black 15px, + black calc(100% - 15px), + transparent calc(100% - 15px), + transparent 100% + ); + mask-image: linear-gradient(to right, + transparent 0, + transparent 15px, + black 15px, + black calc(100% - 15px), + transparent calc(100% - 15px), + transparent 100% + ); + } + .calendar-header-row::-webkit-scrollbar { display: none; } + /* 两列都按内容宽度,不允许被压缩到0,通过外层行滚动 */ + .calendar-header-row > .col-lg-8.col-md-6, + .calendar-header-row > .col-lg-4.col-md-6 { + flex: 0 0 auto !important; + width: auto !important; + max-width: none !important; + } + /* 去除列内侧补白,避免视觉间距异常 */ + .calendar-header-row > .col-lg-8.col-md-6 { padding-right: 0 !important; } + .calendar-header-row > .col-lg-4.col-md-6 { padding-left: 0 !important; } + + /* 左侧分类按钮容器:保持单行(滚动交给外层行)*/ + .calendar-category-buttons { + flex-wrap: nowrap !important; + margin-top: 0 !important; /* 覆盖全局 -12px,避免被遮挡 */ + } + + /* 按钮容器强制单行,超出部分横向滚动,隐藏滚动条 */ + .calendar-controls { + width: auto !important; + max-width: 100% !important; + flex-wrap: nowrap !important; + overflow-x: auto !important; + white-space: nowrap; /* 兜底防止换行 */ + justify-content: flex-start !important; /* 移动端从左对齐,便于滚动 */ + margin-top: 0 !important; /* 覆盖全局 -12px,避免被上方遮挡 */ + -webkit-overflow-scrolling: touch; /* 惯性滚动 */ + scrollbar-width: none; /* Firefox 隐藏滚动条 */ + touch-action: pan-x; /* 明确允许横向滑动 */ + } + .calendar-controls::-webkit-scrollbar { /* WebKit 隐藏滚动条 */ + display: none; + } + /* 防止子元素换行并被压缩 */ + .calendar-controls > * { + flex: 0 0 auto !important; /* 不收缩,不换行 */ + } + + /* 移除先前对父级列的约束,交由外层行滚动 */ + /* 压缩按钮间距为 8px(与PC一致,保证不再因为额外 margin 导致换行)*/ + .calendar-controls .btn, + .calendar-controls .btn-group { + margin: 0; /* 由 gap 控制间距 */ + } +} + +/* 统一日历控制按钮的样式 */ +.calendar-controls .btn { + height: 32px; + display: flex; + align-items: center; + justify-content: center; +} + +/* 移除按钮组的额外边距,使用gap统一控制间距 */ +.calendar-controls .btn-group { + margin: 0; + flex-shrink: 0; + display: flex; +} + +/* 移除单独按钮的额外边距,使用gap统一控制间距 */ +.calendar-controls .btn:not(.btn-group .btn) { + margin: 0; +} + +/* 确保按钮组内部按钮重叠显示 */ +.calendar-controls .btn-group .btn { + margin: 0; + border-radius: 0; + border-right-width: 0; +} + +.calendar-controls .btn-group .btn:first-child { + border-top-left-radius: 6px; + border-bottom-left-radius: 6px; +} + +.calendar-controls .btn-group .btn:last-child { + border-top-right-radius: 6px; + border-bottom-right-radius: 6px; + border-right-width: 1px; +} + +/* 今天按钮样式统一 */ +.calendar-controls .btn-primary { + background-color: transparent !important; + border-color: var(--dark-text-color) !important; + color: var(--dark-text-color) !important; + padding: 6px 8px !important; +} + +.calendar-controls .btn-primary:hover { + background-color: var(--dark-text-color) !important; + border-color: var(--dark-text-color) !important; + color: white !important; +} + +/* 海报模式样式 整体上边距调整 */ +.calendar-poster-mode { + padding-top: 4px; +} + +.calendar-date-navigation { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + column-gap: 20px; + margin-bottom: 20px; +} + +/* 日历模式样式 整体上边距调整 */ +.calendar-month-mode { + /* 在窄屏下,当内部表格达到最小宽度时,由本容器产生横向滚动,而不是页面溢出 */ + padding-top: 4px; + overflow-x: auto; /* 横向滚动托管在日历容器 */ + overflow-y: hidden; /* 纵向由页面滚动,避免双滚动条 */ + /* 保留稳定的滚动条占位,让滚动条显示在表格下方不遮挡内容 */ + scrollbar-gutter: stable both-edges; /* 支持的浏览器会在下方保留沟槽 */ + padding-bottom: 16px; /* 为不支持 scrollbar-gutter 的浏览器腾出空间 */ + padding-left: 0px; + padding-right: 0px; +} + +.calendar-date-item { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + border-radius: 6px; + background-color: transparent; + border: 1px solid var(--dark-text-color); + width: 100%; + height: 32px; + font-size: 0.95rem; + color: var(--dark-text-color); + cursor: default; +} + +.calendar-date-item.today { + background-color: var(--focus-border-color); + border-color: var(--focus-border-color); + color: white; +} + +.calendar-date-weekday { + font-size: 0.95rem; + font-weight: normal; + margin: 0; +} + +.calendar-date-day { + font-size: 0.95rem; + font-weight: normal; + margin: 0; +} + +.calendar-poster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + column-gap: 20px; + row-gap: 14px; + padding: 0; + margin-top: 20px; +} + +.calendar-poster-column { + display: flex; + flex-direction: column; + gap: 14px; +} + +.calendar-poster-item { + width: 100%; +} + +.calendar-poster { + position: relative; + width: 100%; + aspect-ratio: 2/3; /* 强制保持2:3比例 */ + border-radius: 6px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 10px; +} + +.calendar-poster-image { + width: 100%; + height: 100%; + object-fit: cover; + transition: opacity 0.3s ease; +} + +.calendar-poster-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--dark-text-color); + color: white; + padding: 8px 8px 5px 8px; + font-size: 0.75rem; + font-weight: normal; + line-height: 1.4; + opacity: 0; + transition: opacity 0.3s ease; + display: flex; + flex-direction: column; + justify-content: flex-end; + pointer-events: none; +} + +.calendar-poster:hover .calendar-poster-overlay { + opacity: 1; +} + +.calendar-poster:hover .calendar-poster-image { + opacity: 0; + transition: opacity 0.3s ease; +} + +.calendar-poster-overlay .info-line { + margin-bottom: 5.5px; + padding-bottom: 5px; + word-wrap: break-word; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.calendar-poster-overlay .info-line:first-child { + margin-top: 0; +} + +.calendar-poster-overlay .info-line:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +/* 海报悬停简介按行数截断 */ +.calendar-poster-overlay .info-line.overview { + overflow: hidden; /* 隐藏超出部分,JS 负责截断 */ + position: relative; + display: block; + white-space: normal; +} + +.calendar-episode-info { + padding: 0 0px; + text-align: left; +} + +.calendar-show-name { + font-size: 0.95rem; + font-weight: 500; + color: var(--dark-text-color); + margin-bottom: 0px; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; /* 点击打开剧的TMDB页面 */ + transition: color 0.2s ease; +} + +.calendar-show-name:hover { + color: var(--focus-border-color); +} + +.calendar-episode-number { + font-size: 0.95rem; + color: var(--secondary-text-color); + margin-bottom: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; /* 点击打开集的TMDB页面 */ +} + +/* 已达到最近转存进度的集数颜色(用于海报视图集数) */ +.calendar-episode-number.episode-number-reached { + color: var(--secondary-text-color); +} + +/* 悬停时集数高亮为 focus-border-color */ +.calendar-episode-number:hover { + color: var(--focus-border-color); +} + +/* 取消对钩图标样式(已移除对应元素) */ + +/* 日历模式样式 */ +.calendar-month-table { + border-radius: 6px; + overflow: hidden; + border: 1px solid var(--border-color); + /* 作为统一网格容器,表头与表体共享列宽 */ + display: grid; + grid-template-columns: repeat(7, minmax(100px, 1fr)); + min-width: 700px; /* 7列 × 100px = 700px 最小宽度,配合外层开启横向滚动 */ +} + +.calendar-month-header { + /* 让表头的单元格直接参与父级网格,保证与表体同步缩放 */ + display: contents; +} + +/* 星期导航栏样式 */ +.calendar-month-header-cell { + /* 高度32px,垂直居中,左对齐左边距8px */ + height: 32px; + padding: 0 14px; + text-align: left; + display: flex; + align-items: center; + justify-content: flex-start; + font-weight: 400; /* 常规体 */ + font-size: 0.95rem; + color: var(--dark-text-color); + border-right: 1px solid var(--border-color); /* 显示内部分割线 */ + background-color: #f7f7f9; + /* 防止文本被挤压成竖排 */ + min-width: 100px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 移除星期导航最后一列的右侧分割线,避免溢出 */ +.calendar-month-header-cell:last-child { + border-right: none; +} + +.calendar-month-body { + /* 表体同样作为父级网格的直接子项 */ + display: contents; +} + +.calendar-month-row { + /* 将行本身不渲染为容器,使单元格直接参与父级网格 */ + display: contents; +} + +.calendar-month-cell { + min-height: 101px; /* 调整日期单元格行高 */ + padding: 8px; + border-right: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + position: relative; +} + +.calendar-month-cell:nth-child(7n) { + border-right: none; +} + +/* 移除最后一行(最后7个单元格)的底部边框,避免底部双线 */ +.calendar-month-body .calendar-month-cell:nth-last-child(-n + 7) { + border-bottom: none; +} + +/* 为首行(前7个单元格)添加顶部边框,形成与星期导航栏之间的单一分割线 */ +.calendar-month-body .calendar-month-cell:nth-child(-n + 7) { + border-top: 1px solid var(--border-color); +} + +.calendar-month-date { + /* 统一所有日期号的展示容器,避免"今天"与其他日期基线不一致 */ + position: relative; /* 允许整体微调位置 */ + top: -0.5px; /* 整体上移 0.5px,使号数与圆背景一同上移 */ + left: 6px; /* 所有号数向右偏移6px */ + display: inline-flex; + align-items: center; + justify-content: flex-start; /* 默认居左对齐 */ + width: 22px; + height: 22px; + line-height: 22px; /* 保障文本垂直居中且基线一致 */ + vertical-align: middle; /* 与同行元素对齐 */ + font-size: 1rem; /* 与集信息、按钮统一 */ + font-weight: 600; /* 仅日期号数加粗 */ + margin-bottom: 4px; +} + +.calendar-month-cell.other-month { + background-color: #f7f7f9; /* 非本月背景 */ + color: var(--secondary-text-color); +} + +.calendar-month-cell.today { + /* 取消整格背景色与白字,改为仅日期圆形高亮 */ + background-color: transparent; + color: inherit; +} + +/* 当天日期圆形高亮背景 */ +.calendar-month-cell.today .calendar-month-date { + position: relative; /* 为伪元素定位 */ + justify-content: flex-start; /* 今日与其他日期左对齐一致 */ + color: var(--focus-border-color); /* 当天号数文本颜色改为focus-border-color */ +} + +.calendar-month-cell.today .calendar-month-date::before { + display: none; /* 隐藏蓝色圆形背景 */ +} + +.calendar-month-cell.has-episodes { + background-color: transparent !important; /* 去除有播出集背景色 */ +} + +.calendar-month-episodes { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: 4px; /* 下移单集列表的最高位置 4px */ +} + +/* 日历模式单集卡片样式 */ +.calendar-month-episode { + font-size: 0.85rem; + padding: 6px; /* 等距6px */ + line-height: 1; /* 统一行高,消除上下视觉不等距 */ + /* 日历视图:默认保持原按钮灰背景 */ + background-color: #f7f7f9; + border-radius: 6px; /* 圆角6px */ + display: flex; + align-items: center; + gap: 4px; +} + +/* 日历视图:仅当该集已达转存进度时,卡片背景改为导航悬停浅蓝色 */ +.calendar-month-episode:has(.episode-number.episode-number-reached) { + background-color: #e6f1ff; +} + +.episode-title { + font-weight: 400; /* 常规体 */ + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; /* 点击打开剧的TMDB页面 */ +} + +.episode-number { + font-size: 0.85rem; + color: var(--secondary-text-color); + white-space: nowrap; + cursor: pointer; /* 点击打开集的TMDB页面 */ +} + +/* 日历视图:已转存与未转存集号不区分,统一保持原灰色 */ +.calendar-month-episode .episode-number.episode-number-reached { + color: var(--secondary-text-color); +} + +/* 悬停时,日历模式下的剧名与集数分别高亮 */ +.calendar-month-episode .episode-title:hover, +.calendar-month-episode .episode-number:hover { + color: var(--focus-border-color); +} + +/* 集号响应式显示:不同宽度下显示不同格式 */ + +/* 默认状态:显示完整格式 */ +.calendar-month-episode .episode-number { + display: inline; + font-size: 0.85rem; + color: var(--secondary-text-color); + white-space: nowrap; +} + +/* 宽度 > 1280px:完整状态 S01E06, S01E34-E38 */ +@media (min-width: 1281px) { + .calendar-month-episode .episode-number::before { + content: attr(data-full); + font-size: 0.85rem; + } + .calendar-month-episode .episode-number { + font-size: 0; + } +} + +/* 宽度 1080px - 1280px:省略季 E06, E34-E38 */ +@media (max-width: 1280px) and (min-width: 1081px) { + .calendar-month-episode .episode-number::before { + content: attr(data-episode-only); + font-size: 0.85rem; + } + .calendar-month-episode .episode-number { + font-size: 0; + } +} + +/* 宽度 950px - 1080px:纯数字 06, 34-38 */ +@media (max-width: 1080px) and (min-width: 951px) { + .calendar-month-episode .episode-number::before { + content: attr(data-number-only); + font-size: 0.85rem; + } + .calendar-month-episode .episode-number { + font-size: 0; + } +} + +/* 宽度 < 950px:隐藏集号 */ +@media (max-width: 950px) { + .calendar-month-episode .episode-number { + display: none; + } + + /* 在集号隐藏时,为剧名提供更多空间,确保至少显示四个汉字 */ + .episode-title { + min-width: 60px; + } +} + +.episode-completed { + color: var(--success-color); + font-size: 10px; + flex-shrink: 0; +} + +/* 响应式调整 */ +@media (max-width: 576px) { + .calendar-poster-grid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + column-gap: 20px; + row-gap: 14px; + } + + .calendar-date-navigation { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + column-gap: 20px; + row-gap: 14px; + } + + .calendar-date-item { + padding: 6px 8px; /* 文本左右间距 8px */ + height: 32px; /* 高度 32px */ + } + + .calendar-month-cell { + min-height: 42px; + padding: 6px; + } + + /* 表格内部的紧凑卡片样式(仅作用于日历表格体内) */ + .calendar-month-body .calendar-month-episode { + font-size: 10px; + padding: 1px 3px; + transform: translateY(-7.5px); /* 整体上移 */ + } + + /* 超小模式:仅在表格内部隐藏文本,下方选中日期列表需正常显示 */ + .calendar-month-body .calendar-month-episode .episode-title, + .calendar-month-body .calendar-month-episode .episode-number, + .calendar-month-body .calendar-month-episode .calendar-transferred-badge, + .calendar-month-body .calendar-month-episode .episode-completed { + display: none !important; /* 隐藏文本与徽标 */ + } + + /* 超小模式:表格内部最后一张集卡片到底部边框的可视间距 = 6px + 由于集卡片整体向上位移 7.5px,使用负的 margin-bottom 抵消,保持 6px */ + .calendar-month-body .calendar-month-episodes { + margin-bottom: -7.5px; + } + + /* 超小模式:取消日历表格最小宽度,按屏幕宽度自适应 */ + .calendar-month-table { + min-width: 0 !important; /* 覆盖 700px 最小宽度 */ + grid-template-columns: repeat(7, minmax(0, 1fr)) !important; /* 7 列严格等宽 */ + width: 100% !important; /* 填满容器宽度 */ + box-sizing: border-box; /* 边框计入宽度,避免产生水平溢出 */ + } + + /* 超小模式:父容器不再需要横向滚动,由表格自适应 */ + .calendar-month-mode { + overflow-x: hidden !important; + } + + /* 超小模式:允许网格子项收缩,杜绝由最小内容宽度导致的溢出 */ + .calendar-month-cell, + .calendar-month-header-cell { + min-width: 0 !important; + } + /* 超小模式:选中单元格背景色 */ + .calendar-month-cell.selected { + background-color: #e6f1ff !important; + } + + /* 超小模式:星期导航文本左边距 8px */ + .calendar-month-header-cell { + padding-left: 8px !important; + padding-right: 8px !important; + } + + /* 超小模式:日期号数左边距 8px */ + .calendar-month-date { + left: 2px !important; + top: -3.5px !important; + } + /* 超小模式:表格内部的卡片背景色覆盖(仅表格体内紧凑卡片) */ + .calendar-month-body .calendar-month-episode { + background-color: var(--border-color) !important; /* 普通状态背景色 */ + } + .calendar-month-body .calendar-month-episode:has(.episode-number.episode-number-reached) { + background-color: var(--focus-border-color) !important; /* 已转存背景色 */ + } + + /* 超小模式:下方“选中日期”列表使用桌面端样式 */ + .calendar-selected-episodes .calendar-month-episodes { + gap: 2px; + margin-top: 4px; + } + .calendar-selected-episodes .calendar-month-episode { + font-size: 0.85rem; + padding: 6px; + line-height: 1; + transform: none; /* 还原不上移 */ + background-color: #f7f7f9; /* 桌面端默认背景 */ + border-radius: 6px; + } + .calendar-selected-episodes .calendar-month-episode:has(.episode-number.episode-number-reached) { + background-color: #e6f1ff; /* 桌面端已转存背景 */ + } + + /* 超小模式:下方列表的集号样式与桌面端一致(显示完整 SxxExx) */ + .calendar-selected-episodes .calendar-month-episode .episode-number { + display: inline; + font-size: 0; /* 由伪元素承载显示文本 */ + color: var(--secondary-text-color); + white-space: nowrap; + } + .calendar-selected-episodes .calendar-month-episode .episode-number::before { + content: attr(data-full); + font-size: 0.85rem; + } + + /* 超小模式:日历与下方选中日期卡片的间距 */ + .calendar-selected-episodes { + margin-top: 20px; + } +} + +@media (min-width: 1200px) { + .calendar-poster-grid { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } + + .calendar-date-navigation { + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + } +} + +/* 追剧日历页面日期导航按钮组中的按钮固定宽度32px(包含左右边框) */ +.calendar-controls .btn-group .btn-sm:not(.btn-primary) { + width: 31px; + height: 32px; + min-width: 31px; + max-width: 31px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* 今天按钮固定宽度47.5px */ +.calendar-controls .btn-group .btn-sm.btn-primary { + width: 47.5px; + height: 32px; + min-width: 47.5px; + max-width: 47.5px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* 为最后一个按钮单独设置32px宽度,确保总宽度正确 */ +.calendar-controls .btn-group .btn-sm:last-child:not(.btn-primary) { + width: 32px; + min-width: 32px; + max-width: 32px; +} + +/* 追剧日历页面单独按钮固定宽度32px */ +.calendar-controls .btn:not(.btn-group .btn) { + width: 32px; + height: 32px; + min-width: 32px; + max-width: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* 追剧日历页面日期导航按钮的箭头图标大小 */ +.calendar-controls .btn-group .btn-sm .bi-chevron-double-left { + font-size: 1.09rem; + position: relative; + top: 0px; + left: 0.5px; /* 向左移动左箭头 */ +} + +.calendar-controls .btn-group .btn-sm .bi-chevron-left { + font-size: 1.09rem; + position: relative; + top: 0px; + left: -1px; /* 向左移动左箭头 */ +} + +.calendar-controls .btn-group .btn-sm .bi-chevron-double-right { + font-size: 1.09rem; + position: relative; + top: 0px; + right: 0.5px; /* 向右移动右箭头 */ +} + +.calendar-controls .btn-group .btn-sm .bi-chevron-right { + font-size: 1.09rem; + position: relative; + top: 0px; + right: -1px; /* 向右移动右箭头 */ +} + +/* 内容管理按钮的图标样式 */ +.calendar-controls .btn .bi-reply { + font-size: 1.25rem; + position: relative; + top: -1.5px; + left: 0px; +} + +.calendar-controls .btn .bi-tags { + font-size: 1.1rem; + position: relative; + top: 0px; + left: 0.5px; +} + +/* 系统配置高亮链接颜色,使用主题 focus 边框颜色 */ +.link-focus { + color: var(--focus-border-color); +} +.link-focus:hover, +.link-focus:focus { + color: var(--focus-border-color); + text-decoration: none; +} + +#editMetadataModal .matched-result .input-group-text.square-append { + border-left: none; + border-right: none; +} +#editMetadataModal .matched-result .input-group-text.square-append:first-of-type { + border-right: 1px solid var(--border-color); +} +#editMetadataModal .matched-result .input-group-text.square-append:last-of-type { + border-left: 1px solid var(--border-color); +} +#editMetadataModal .matched-season-wrap { + margin-left: auto; + margin-right: -8px; + display: flex; + align-items: center; +} +#editMetadataModal .matched-result .matched-season { + box-sizing: border-box; /* 宽高包含边框 */ + width: 30px; /* 默认尺寸减小 2px:30x32(含边框)*/ + min-width: 30px; + height: 32px; + display: inline-flex; /* 允许按内容自适应宽度 */ + align-items: center; + justify-content: center; + padding: 0 8px; /* 左右各 8px 内边距 */ + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + border-left: 0; /* 左右边线由两侧方块承担,重叠不显示双线 */ + border-right: 0; +} +/* 1) 去除“第”的四个圆角 */ +#editMetadataModal .matched-season-wrap .input-group-text.square-append:first-of-type { + border-radius: 0 !important; + border-left: 1px solid var(--border-color) !important; /* 添加左侧边框线 */ +} +/* 2) “季”固定尺寸,3) 去除其左侧圆角 */ +#editMetadataModal .matched-season-wrap .input-group-text.square-append:last-of-type { + box-sizing: border-box; /* 含边框宽高 */ + width: 31px !important; + height: 32px !important; + border-top-left-radius: 0 !important; + border-bottom-left-radius: 0 !important; +} +#editMetadataModal .matched-link { + color: var(--dark-text-color); + text-decoration: none; +} +#editMetadataModal .matched-link:hover { + color: var(--focus-border-color); + text-decoration: none; +} + +/* 编辑元数据底部提示内的 TMDB 链接样式 */ +#editMetadataModal .tmdb-link { + color: var(--dark-text-color); + text-decoration: none; +} +#editMetadataModal .tmdb-link:hover { + color: var(--focus-border-color); + text-decoration: none; +} + +/* 编辑元数据底部提示信息和按钮文本样式 */ +#editMetadataModal .modal-footer .file-selection-info { + transform: translateY(0.5px); +} +#editMetadataModal .modal-footer .btn .btn-text { + display: inline-block; + transform: translateY(0.5px); +} +#editMetadataModal .modal-footer .btn.btn-primary .btn-text { + color: #fff; +} +/* 取消按钮维持原有深色文字,不随主按钮白色规则变化 */ +#editMetadataModal .modal-footer .btn.btn-primary.btn-cancel .btn-text { + color: var(--dark-text-color); +} + +/* 显示设置:拖拽时显示“移动”而非“添加”视觉提示 */ +.draggable-item { + cursor: move; /* 显示移动光标 */ +} + +.draggable-item:active { + opacity: 1; +} + +/* 被拖起的原位置元素半透明,表示“占位/已被拖动” */ +.draggable-item.drag-origin { + opacity: 0.4; +} + +/* QASX API Token 显示框:不可编辑但显示文本指针 */ +.token-display { + cursor: text; +} + +/* TMDB 说明文本样式与链接样式(继承颜色、无下划线、悬停不变) */ +.tmdb-attribution { + margin-top: 4px; + margin-bottom: 4px; + color: var(--light-text-color); +} +.tmdb-attribution a { + text-decoration: none; + color: inherit; +} +.tmdb-attribution a:hover, +.tmdb-attribution a:focus { + text-decoration: none; + color: inherit; } \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index 298926b..e815b6e 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -324,7 +324,7 @@
-
+
{{ toastMessage }}
@@ -379,6 +379,11 @@ 影视发现 +
-

所有账号都会进行签到(纯签到只需填写移动端参数),只有第一个账号会进行转存,请自行确认账号顺序;所有填写了 Cookie 的账号均支持文件整理,如需签到请在 Cookie 后方添加签到参数。

未验证
- +
@@ -796,7 +800,7 @@
-
+

显示设置

@@ -804,85 +808,19 @@
-
-
+ + +
+
- 运行此任务 + {{ getDisplayLabel(key) }}
- -
-
-
-
-
- 删除此任务 -
- -
-
-
-
-
- 刷新 Plex 媒体库 -
- -
-
-
-
-
- 刷新 AList 目录 -
- -
-
-
-
-
-
-
- 最近转存文件 -
- -
-
-
-
-
- 最近更新日期 -
- -
-
-
-
-
- 当日更新标识 -
- @@ -901,7 +839,7 @@
-
+
单次请求文件数量 @@ -912,7 +850,7 @@
-
+
文件列表缓存时长 @@ -923,7 +861,7 @@
-
+
影视榜单项目数量 @@ -934,10 +872,21 @@
+
+
+
+ 追剧日历刷新周期 +
+ +
+ +
+
+
-
+
-

API

+

QASX API

@@ -947,9 +896,25 @@
Token
- +
+
+
+

TMDB API

+ + + +
+
+
+
+ API 密钥 +
+ +
+

部分功能基于 TMDB 提供的数据实现,本应用与 TMDB 无关。

+
@@ -991,16 +956,33 @@
# - - · {{ taskLatestFiles[task.taskname] }} - - - · {{ getTaskLatestRecordDisplay(task.taskname) }} - + @@ -1009,11 +991,15 @@
- - - - - +
@@ -1670,7 +1656,7 @@ {{ parseFloat(item.rating.value).toFixed(1) }}
- + +
@@ -1694,6 +1680,364 @@
+ +
+
+ + + + + +
+ +
+
+
+
+ 名称筛选 +
+ +
+ +
+
+
+
+
+
+ 任务筛选 +
+
+ +
+
+ +
+
+
+
+ + +
+
+ +
+ + +
+
+
+ +
+ +
+ + + + + +
+
+ + + +
+ + + + + + + + + + + + +
+
+
+ + +
+ +
+
+
+ + + + + + + + +
+ {{ getTransferProgress(task) }}% +
+
+
{{ task.task_name }}
+
+ + +
+
{{ task.latest_season_name }}
+
+
+
{{ task.matched_status }}
+
+
+
+
+ {{ task.task_name }} + + + +
+
{{ getContentTypeCN(task.content_type) }}
+
+
+
+
+ + +
+ +
+
+
{{ date.weekday }}
+
{{ date.day }}
+
+
+ + +
+
+
+
+ + + + + + +
+ +
+ + +
+
{{ episode.name || episode.show_name }}
+
{{ episode.overview }}
+
+ + +
+ +
+
+ {{ episode.show_name }} + · {{ getEpisodeFinaleStatus(episode) }} + + + +
+
+ {{ getEpisodeDisplayNumber(episode) }} +
+
+
+
+
+
+ + +
+
+
+
+ {{ weekday }} +
+
+
+
+
+
+ + {{ day.isCurrentMonth ? (calendar.currentDate.getMonth() + 1) : (calendar.currentDate.getMonth() + 2) }}/1 + + + {{ day.dayNumber }} + +
+
+
+ + {{ episode.show_name }} + · {{ getEpisodeFinaleStatus(episode) }} + + + + + + {{ getEpisodeDisplayNumber(episode) }} + +
+
+
+
+
+
+ +
+ +
+
+
+
+
@@ -2117,8 +2461,98 @@
+ +
@@ -2143,6 +2577,7 @@ emby: 'Emby', plex: 'Plex' }, + configHasLoaded: false, formData: { cookie: [], push_config: {}, @@ -2182,15 +2617,35 @@ delete_task: "always", refresh_plex: "always", refresh_alist: "always", + season_counts: "always", latest_update_date: "always", + task_progress: "always", + show_status: "always", latest_transfer_file: "always", today_update_indicator: "always" }, + // 显示顺序(可拖拽修改):按钮 + 信息 + button_display_order: [ + "refresh_plex", + "refresh_alist", + "run_task", + "delete_task", + "latest_transfer_file", + "season_counts", + "latest_update_date", + "task_progress", + "show_status", + "today_update_indicator" + ], file_performance: { api_page_size: 200, cache_expire_time: 30, discovery_items_count: 30 }, + performance: { + // 追剧日历刷新周期(秒),默认6小时(21600秒) + calendar_refresh_interval_seconds: 21600 + }, plugin_config_mode: { aria2: "independent", alist_strm_gen: "independent", @@ -2209,7 +2664,8 @@ try_match: true, media_id: "" } - } + }, + tmdb_api_key: "" }, userInfoList: [], // 用户信息列表 accountsDetail: [], // 账号详细信息列表 @@ -2236,6 +2692,28 @@ taskLatestRecords: {}, // 存储每个任务的最新转存记录日期 taskLatestFiles: {}, // 存储每个任务的最近转存文件 modalLoading: false, + // 编辑元数据状态 + editMetadata: { + visible: false, + original: { + task_name: '', + content_type: '', + tmdb_id: '', + season_number: '' + }, + form: { + task_name: '', + content_type: '', + tmdb_id: '', + season_number: 1 + }, + display: { + matched_label: '未匹配', + matched_tmdb_id: '', + matched_season_number: '' + }, + hint: '' + }, smart_param: { index: null, savepath: "", @@ -2258,6 +2736,8 @@ }, activeTab: 'config', configModified: false, + // 标志:当由后端推送或编辑元数据保存触发的程序性更新时,暂时抑制未保存提示 + suppressConfigModifiedOnce: false, fileSelect: { index: null, shareurl: "", @@ -2430,6 +2910,51 @@ ] } }, + // 追剧日历相关数据 + calendar: { + hasLoaded: false, + error: null, + tasks: [], + taskMapByName: {}, + manageMode: false, + layoutTick: 0, + contentTypes: [], + // 仅用于“已转存”判定的进度映射(来源:/task_latest_info.latest_files) + progressByTaskName: {}, + progressByShowName: {}, + // 当日更新数据(来源:/api/calendar/today_updates_local) + todayUpdatesByTaskName: {}, // { [task_name]: true } + todayUpdatesByShow: {}, // { [show_name]: Set(keys) },key: 'S01E02' 或 'D:YYYY-MM-DD' + // 从本地存储读取已选择的类型,默认 all + selectedType: (localStorage.getItem('calendar_selected_type') || 'all'), + nameFilter: '', + taskFilter: '', + taskNames: [], + viewMode: (localStorage.getItem('calendar_view_mode') === 'month' || localStorage.getItem('calendar_view_mode') === 'poster') + ? localStorage.getItem('calendar_view_mode') + : 'poster', // poster 或 month(支持持久化) + mergeEpisodes: localStorage.getItem('calendar_merge_episodes') === 'true', // 合并集模式(支持持久化) + currentDate: new Date(), + weekDates: [], + monthWeeks: [], + episodes: [], + // 选中日期(YYYY-MM-DD),默认今天 + selectedDate: (function(){ + const d = new Date(); + const y = d.getFullYear(); + const m = (d.getMonth()+1).toString().padStart(2,'0'); + const day = d.getDate().toString().padStart(2,'0'); + return `${y}-${m}-${day}`; + })() + }, + // 日历页面resize监听器 + calendarResizeHandler: null, + // 日历自动检测更新相关 + calendarAutoWatchTimer: null, + calendarLatestFilesSignature: '', + calendarAutoWatchTickRef: null, + calendarAutoWatchFocusHandler: null, + calendarAutoWatchVisibilityHandler: null, // 创建任务相关数据 createTask: { loading: false, @@ -2470,6 +2995,108 @@ }]; } }, + // 管理视图:按任务名(拼音)排序并应用顶部筛选 + managementTasksFiltered() { + if (!this.calendar.tasks || this.calendar.tasks.length === 0) return []; + let list = this.calendar.tasks.slice(); + // 顶部筛选:类型 + if (this.calendar.selectedType && this.calendar.selectedType !== 'all') { + list = list.filter(t => (t.content_type || 'other') === this.calendar.selectedType); + } + // 顶部筛选:名称关键词(任务名或剧名) + if (this.calendar.nameFilter && this.calendar.nameFilter.trim() !== '') { + const kw = this.calendar.nameFilter.toLowerCase(); + list = list.filter(t => (t.task_name || '').toLowerCase().includes(kw) || (t.show_name || '').toLowerCase().includes(kw)); + } + // 顶部筛选:任务筛选(精确任务名) + if (this.calendar.taskFilter && this.calendar.taskFilter.trim() !== '') { + list = list.filter(t => (t.task_name || '') === this.calendar.taskFilter); + } + // 按匹配状态和任务名称拼音排序:匹配的项目在前,未匹配的项目在后 + try { + list.sort((a, b) => { + // 首先按匹配状态排序:匹配的在前,未匹配的在后 + const aMatched = !!(a.matched_show_name && a.matched_show_name.trim() !== ''); + const bMatched = !!(b.matched_show_name && b.matched_show_name.trim() !== ''); + + if (aMatched !== bMatched) { + return aMatched ? -1 : 1; // 匹配的排在前面 + } + + // 匹配状态相同时,按任务名称拼音排序 + const aKey = pinyinPro.pinyin(a.task_name || '', { toneType: 'none', type: 'string' }).toLowerCase(); + const bKey = pinyinPro.pinyin(b.task_name || '', { toneType: 'none', type: 'string' }).toLowerCase(); + if (aKey < bKey) return -1; if (aKey > bKey) return 1; return 0; + }); + } catch (e) {} + return list; + }, + + // 海报和日历视图下过滤掉所有内容都是未匹配的分类 + filteredContentTypes() { + if (!this.calendar.tasks || this.calendar.tasks.length === 0) { + return this.calendar.contentTypes; + } + + // 只在海报和日历视图下进行过滤 + if (this.calendar.manageMode) { + return this.calendar.contentTypes; + } + + return this.calendar.contentTypes.filter(type => { + // 获取该分类下的所有任务 + const typeTasks = this.calendar.tasks.filter(task => (task.content_type || 'other') === type); + + // 如果该分类下没有任务,隐藏该分类按钮 + if (typeTasks.length === 0) { + return false; + } + + // 检查该分类下是否有匹配的任务 + const hasMatchedTask = typeTasks.some(task => + task.matched_show_name && task.matched_show_name.trim() !== '' + ); + + // 如果有匹配的任务,保留该分类按钮 + return hasMatchedTask; + }); + }, + + // 管理视图:将过滤后的任务按当前海报列数进行分栏,复用相同布局 + managementColumns() { + const tasks = this.managementTasksFiltered; + if (!tasks || tasks.length === 0) return []; + // 依赖布局tick以在窗口变化时强制重新计算 + void this.calendar.layoutTick; + // 与周视图一致的列数推导逻辑 + const availableWidth = this.getCalendarAvailableWidth ? this.getCalendarAvailableWidth() : 1200; + const minColumnWidth = 140; + const columnGap = 20; + // 列数计算与周视图一致,加入 eps 避免边界像素/滚动条导致的多列 + const eps = 0.1; + // 桌面端也允许最少 2 列,避免在某些宽度下被强制多出一列 + const columns = Math.max(2, Math.floor((availableWidth + columnGap - eps) / (minColumnWidth + columnGap))); + const cols = Array.from({ length: columns }, () => []); + // 简单逐列分配,保证与日历海报列宽/间距一致 + tasks.forEach((t, idx) => { + cols[idx % columns].push(t); + }); + return cols; + }, + // 基于已加载的日历剧集构建:show_name -> poster_local_path 映射 + posterByShowName() { + const map = {}; + try { + (this.calendar.episodes || []).forEach(ep => { + const key = (ep && ep.show_name) ? String(ep.show_name).trim() : ''; + const poster = ep && ep.poster_local_path; + if (key && poster && !map[key]) map[key] = poster; + }); + } catch (e) {} + return map; + }, + + filteredHistoryRecords() { // 直接返回服务器端已筛选的数据 if (!this.history.records || this.history.records.length === 0) { @@ -2568,10 +3195,48 @@ watch: { formData: { handler(newVal, oldVal) { - this.configModified = true; + if (this.suppressConfigModifiedOnce) { + // 消费一次抑制标志,不触发未保存提示 + this.suppressConfigModifiedOnce = false; + } else { + this.configModified = true; + } }, deep: true }, + // 名称筛选变化时重建月视图 + 'calendar.nameFilter': function(newVal, oldVal) { + if (this.calendar.viewMode === 'month') { + this.initializeCalendarDates(); + } + }, + // 任务筛选变化时重建月视图 + 'calendar.taskFilter': function(newVal, oldVal) { + if (this.calendar.viewMode === 'month') { + this.initializeCalendarDates(); + } + }, + // 侧边栏折叠/展开变化时,触发布局重算 + sidebarCollapsed(val) { + if (this.activeTab === 'calendar') { + if (this.calendar.manageMode) { + this.calendar.layoutTick = Date.now(); + } else if (this.calendar.viewMode === 'poster') { + this.updateWeekDates(); + } + } + }, + // 页面宽度模式变化(窄/中/宽),触发布局重算 + pageWidthMode(val) { + if (this.activeTab === 'calendar') { + if (this.calendar.manageMode) { + this.calendar.layoutTick = Date.now(); + } else if (this.calendar.viewMode === 'poster') { + this.updateWeekDates(); + } + } + }, + historyNameFilter: { handler(newVal, oldVal) { // 延迟加载,避免频繁请求 @@ -2594,6 +3259,32 @@ if (newValue === 'tasklist') { this.loadTaskLatestInfo(); } + // 切换到追剧日历:立刻检查一次并启动后台监听;离开则停止监听 + if (newValue === 'calendar') { + // 立即检查一次(若已初始化过监听,直接调用tick引用) + // 先本地读取一次,立刻应用“已转存”状态(不依赖轮询) + try { this.loadCalendarEpisodesLocal && this.loadCalendarEpisodesLocal(); } catch (e) {} + // 先启动监听以确保 tickRef 已就绪 + this.startCalendarAutoWatch(); + // 重置签名,确保本次切换能触发一次增量检查 + this.calendarLatestFilesSignature = ''; + // 进入页面即刻用现有的最近文件重建一次进度映射,避免UI滞后 + try { + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {}); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } catch (e) {} + // 立刻触发一次增量检查与热更新(无需等待下一轮定时器) + if (this.calendarAutoWatchTickRef) { + this.calendarAutoWatchTickRef(); + } + // 离开再返回时恢复管理模式记忆 + try { + const savedManage = localStorage.getItem('calendar_manage_mode'); + if (savedManage === 'true') this.calendar.manageMode = true; + } catch (e) {} + } else if (oldValue === 'calendar') { + this.stopCalendarAutoWatch(); + } // 如果切换到文件整理页面,则加载文件列表 if (newValue === 'filemanager') { this.fetchAccountsDetail(); @@ -2671,6 +3362,26 @@ } $('[data-toggle="tooltip"]').tooltip(); + // 窗口尺寸变化时,及时重算追剧日历布局,避免列数滞后 + this._onCalendarResize = this.debounce(() => { + if (this.activeTab === 'calendar') { + this.calendar.layoutTick = Date.now(); + this.initializeCalendarDates(); + } + }, 80); + window.addEventListener('resize', this._onCalendarResize); + window.addEventListener('orientationchange', this._onCalendarResize); + // 使用 ResizeObserver 监听日历根容器尺寸变化,实时刷新列数 + if (window.ResizeObserver) { + this._calendarResizeObserver = new ResizeObserver(() => { + if (this.activeTab === 'calendar') { + this._onCalendarResize(); + } + }); + if (this.$refs && this.$refs.calendarRoot) { + this._calendarResizeObserver.observe(this.$refs.calendarRoot); + } + } document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('click', (e) => { // 如果点击的是输入框、搜索按钮或下拉菜单本身,不关闭下拉菜单 @@ -2771,10 +3482,207 @@ }, beforeDestroy() { window.removeEventListener('beforeunload', this.handleBeforeUnload); + if (this._onCalendarResize) { + window.removeEventListener('resize', this._onCalendarResize); + window.removeEventListener('orientationchange', this._onCalendarResize); + } + if (this._calendarResizeObserver && this.$refs && this.$refs.calendarRoot) { + this._calendarResizeObserver.unobserve(this.$refs.calendarRoot); + this._calendarResizeObserver.disconnect(); + this._calendarResizeObserver = null; + } // 移除点击事件监听器 document.removeEventListener('click', this.handleOutsideClick); + // 清理日历后台监听 + this.stopCalendarAutoWatch(); }, methods: { + // 选择日历日期 + selectCalendarDate(day) { + try { + if (!day || !day.date) return; + this.calendar.selectedDate = day.date; + } catch (e) {} + }, + // 根据日期字符串获取当日剧集数组 + getEpisodesByDate(dateStr) { + if (!dateStr) return []; + // 在 monthWeeks 结构里查找对应日期的 episodes + for (const week of (this.calendar.monthWeeks || [])) { + for (const d of (week.days || [])) { + if (d.date === dateStr) { + return Array.isArray(d.episodes) ? d.episodes : []; + } + } + } + return []; + }, + // --- 显示设置拖拽排序 --- + onDisplayDragStart(e, key) { + try { + e.dataTransfer.setData('text/plain', key); + // 为原位置元素添加半透明视觉,表示“已被拖起” + const origin = e.target.closest('.draggable-item'); + if (origin) origin.classList.add('drag-origin'); + } catch (err) {} + }, + onDisplayDrop(e, targetKey) { + try { + const sourceKey = (e.dataTransfer && e.dataTransfer.getData('text/plain')) || ''; + if (!sourceKey || sourceKey === targetKey) return; + const order = (this.formData && this.formData.button_display_order) ? this.formData.button_display_order.slice() : []; + const from = order.indexOf(sourceKey); + const to = order.indexOf(targetKey); + if (from === -1 || to === -1) return; + order.splice(from, 1); + order.splice(to, 0, sourceKey); + this.formData.button_display_order = order; + } catch (err) {} + }, + onDisplayDragOver(e) { + try { + // 使用“move”效果,避免浏览器默认的“copy/+”指示 + e.dataTransfer.dropEffect = 'move'; + } catch (err) {} + }, + onDisplayDragEnd(e) { + try { + // 拖拽结束时移除所有占位/高亮样式 + const items = document.querySelectorAll('#display-setting-draggable .draggable-item'); + items.forEach(el => el.classList.remove('drag-origin', 'drag-over')); + } catch (err) {} + }, + getDisplayLabel(key) { + const map = { + refresh_plex: '刷新 Plex 媒体库', + refresh_alist: '刷新 AList 目录', + run_task: '运行此任务', + delete_task: '删除此任务', + latest_transfer_file: '最近转存文件', + season_counts: '集数信息统计', + latest_update_date: '最近更新日期', + task_progress: '当前任务进度', + show_status: '电视节目状态', + today_update_indicator: '当日更新标识' + }; + return map[key] || key; + }, + // ----- 任务列表新增显示:集数统计/任务进度/节目状态 ----- + getTaskSeasonCounts(taskName) { + try { + if (!taskName || !this.calendar || !Array.isArray(this.calendar.tasks)) return null; + 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); + const aired = Number(sc.aired_count || 0); + const total = Number(sc.total_count || 0); + if (transferred === 0 && aired === 0 && total === 0) return null; + return { transferred, aired, total }; + } catch (e) { return null; } + }, + formatSeasonCounts(sc) { + try { + if (!sc) return ''; + // 为斜杠包裹span,便于单独微调位置 + return `${sc.transferred} / ${sc.aired} / ${sc.total}`; + } catch (e) { return ''; } + }, + getTaskProgress(taskName) { + try { + const sc = this.getTaskSeasonCounts(taskName); + if (!sc) return null; + if (sc.aired <= 0) return 0; + const pct = Math.floor((sc.transferred / sc.aired) * 100); + return Math.max(0, Math.min(100, pct)); + } catch (e) { return null; } + }, + getTaskShowStatus(taskName) { + try { + if (!taskName || !this.calendar || !Array.isArray(this.calendar.tasks)) return ''; + const t = this.calendar.tasks.find(x => (x.task_name || x.taskname) === taskName); + const s = (t && t.matched_status ? String(t.matched_status) : '').trim(); + if (!s) return ''; + // 仅在这些状态展示 + if (['本季终', '已完结', '已取消'].includes(s)) return s; + return ''; + } catch (e) { return ''; } + }, + seasonInputComputedWidth() { + try { + const val = String(this.editMetadata && this.editMetadata.form ? (this.editMetadata.form.season_number ?? '') : ''); + const len = val.length || 1; + const px = Math.max(31, len * 9 + 12); + return px + 'px'; + } catch (e) { + return '31px'; + } + }, + // 获取管理视图卡片展示用:实时“已转存集数”(优先使用进度映射,其次回退到任务自带的 season_counts) + 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); + } catch (e) { return 0; } + }, + // 获取管理视图卡片展示用:已播出集数(直接来自任务元数据) + getTaskAiredCount(task) { + try { return Number((task && task.season_counts && task.season_counts.aired_count) || 0); } catch (e) { return 0; } + }, + // 获取管理视图卡片展示用:总集数(直接来自任务元数据) + getTaskTotalCount(task) { + try { return Number((task && task.season_counts && task.season_counts.total_count) || 0); } catch (e) { return 0; } + }, + // 计算转存进度(已转存/已播出 的百分比,取整),优先使用实时映射 + getTransferProgress(task) { + try { + if (!task || !task.season_counts) return 0; + const transferred = this.getTaskTransferredCount(task); + const aired = this.getTaskAiredCount(task); + if (aired <= 0) return 0; + const pct = Math.floor((transferred / aired) * 100); + return Math.max(0, Math.min(100, pct)); + } catch (e) { return 0; } + }, + // 根据节目状态返回徽标颜色类(使用状态名便于直接在CSS中调整颜色) + getProgressBadgeClass(task) { + try { + const status = (task && task.matched_status ? String(task.matched_status) : '').trim(); + if (!status) return ''; + if (status === '本季终') return 'status-finale'; + if (status === '已完结') return 'status-ended'; + // 其他状态:统一用绿色 + return 'status-other'; + } catch (e) { + return ''; + } + }, // 仅当有有效信息时返回悬停提示,否则返回null以不显示 getSuggestionHoverTitle(suggestion) { if (!suggestion) return null; @@ -2856,6 +3764,1552 @@ return message; }, + // 追剧日历相关方法 + // 加载追剧日历数据 + async loadCalendarData() { + if (this.calendar.hasLoaded) return; + + try { + console.log('开始加载追剧日历数据...'); + + // 直接加载任务信息(本地数据,无需缓存) + const tasksResponse = await axios.get('/api/calendar/tasks'); + console.log('任务信息响应:', tasksResponse.data); + + if (tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks; + // 规范化并排序内容类型:tv、anime、variety、documentary、other(其他始终最后) + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + + // 从任务列表页面接口读取最近转存文件,构建进度映射 + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + // 同步到任务面板缓存,避免重复请求 + this.taskLatestFiles = latestFiles; + // 构建映射 + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks, this.calendar.progressByTaskName); + } else { + this.calendar.progressByTaskName = {}; + this.calendar.progressByShowName = {}; + } + } catch (e) { + console.warn('读取任务最近转存文件失败:', e); + this.calendar.progressByTaskName = {}; + this.calendar.progressByShowName = {}; + } + + // 读取持久化的内容类型选择并校验有效性 + try { + const savedType = localStorage.getItem('calendar_selected_type') || 'all'; + const validTypes = ['all', ...this.calendar.contentTypes]; + if (validTypes.includes(savedType)) { + this.calendar.selectedType = savedType; + } else { + this.calendar.selectedType = 'all'; + } + } catch (e) { + this.calendar.selectedType = 'all'; + } + // 读取管理模式持久化(支持三态记忆:管理/海报/月历) + try { + const savedManage = localStorage.getItem('calendar_manage_mode'); + if (savedManage === 'true') { + this.calendar.manageMode = true; + } else if (savedManage === 'false') { + this.calendar.manageMode = false; + } + } catch (e) {} + + console.log('任务数据:', this.calendar.tasks); + console.log('内容类型:', this.calendar.contentTypes); + + // 首屏优先渲染本地缓存,保证页面快速可见 + await this.loadCalendarEpisodesLocal(); + // 同步加载今日更新数据 + try { await this.loadTodayUpdatesLocal(); } catch (e) {} + // 背景增量刷新(带节流),完成后热更新 + if (this.shouldAutoRefreshCalendar()) { + this.refreshCalendarData().catch(() => {}); + } + // 启动后台轻量监听,近实时发现“最近转存文件”变化 + this.startCalendarAutoWatch(); + + this.calendar.hasLoaded = true; + + // 初始化日期数据 + this.initializeCalendarDates(); + } else { + this.calendar.error = tasksResponse.data.message; + console.error('加载任务信息失败:', tasksResponse.data.message); + } + } catch (error) { + this.calendar.error = '加载追剧日历数据失败'; + console.error('加载追剧日历数据失败:', error); + } + }, + + // 切换内容管理模式 + toggleCalendarManageMode() { + this.calendar.manageMode = !this.calendar.manageMode; + try { + localStorage.setItem('calendar_manage_mode', this.calendar.manageMode ? 'true' : 'false'); + } catch (e) {} + // 进入或退出管理模式时触发布局重算 + this.calendar.layoutTick = Date.now(); + }, + + // 将任务转为与海报视图一致的episode-like对象:优先使用后端返回的匹配海报 + getTaskPosterLikeEpisode(task) { + const show = (task && task.show_name) ? String(task.show_name).trim() : ''; + // 1) 后端提供的 matched_poster_local_path(真实匹配结果) + let poster = (task && task.matched_poster_local_path) || ''; + // 2) 若无,则根据 show_name 在已加载episodes中兜底 + if (!poster && show && this.posterByShowName && this.posterByShowName[show]) { + poster = this.posterByShowName[show]; + } + // 3) 仍无则使用任务自身字段或默认逻辑 + if (!poster) { + poster = (task && task.poster_local_path) || ''; + } + return { + poster_local_path: poster, + task_info: { task_name: task && task.task_name, content_type: task && task.content_type }, + show_name: task && task.show_name + }; + }, + + // 内容类型中文名 + getContentTypeCN(type) { + const map = { tv: '剧集', anime: '动画', variety: '综艺', documentary: '纪录片', other: '其他' }; + return map[type] || '其他'; + }, + + // 基于最近转存文件构建:task_name -> { episode_number, air_date } + buildProgressByTaskNameFromLatestFiles(latestFiles) { + const result = {}; + if (!latestFiles || typeof latestFiles !== 'object') return result; + const patterns = [ + /S(\d{1,2})E(\d{1,3})/i, // S01E01 + /E(\d{1,3})/i, // E01 + /第(\d{1,3})集/, // 第1集 + /第(\d{1,3})期/, // 第1期(综艺) + /(\d{1,3})集/, // 1集 + /(\d{1,3})期/, // 1期 + ]; + const datePatterns = [ + /(\d{4})-(\d{1,2})-(\d{1,2})/, // 2025-01-01 + /(\d{4})\/(\d{1,2})\/(\d{1,2})/, + /(\d{4})\.(\d{1,2})\.(\d{1,2})/, + ]; + const parseOne = (txt) => { + if (!txt) return null; + for (const re of patterns) { + const m = String(txt).match(re); + if (m) { + if (/S\d+E\d+/i.test(m[0])) { + return { episode_number: parseInt(m[2]), air_date: null }; + } + return { episode_number: parseInt(m[1]), air_date: null }; + } + } + for (const re of datePatterns) { + const m = String(txt).match(re); + if (m) { + const y = m[1]; + const mm = String(m[2]).padStart(2, '0'); + const dd = String(m[3]).padStart(2, '0'); + return { episode_number: null, air_date: `${y}-${mm}-${dd}` }; + } + } + return null; + }; + Object.keys(latestFiles || {}).forEach(taskName => { + if (taskName && taskName !== '__calendar_shows__' && taskName !== '__calendar_tmdb_ids__') { + const parsed = parseOne(latestFiles[taskName]); + if (parsed && (parsed.episode_number != null || parsed.air_date)) { + result[taskName] = parsed; + } + } + }); + return result; + }, + + // 基于任务列表信息构建:show_name -> 任务进度(同一剧取更大集号或更晚日期) + buildProgressByShowNameFromTasks(tasks, progressByTaskName) { + const result = {}; + if (!Array.isArray(tasks)) return result; + const pickNewer = (a, b) => { + if (!a) return b; if (!b) return a; + if (a.episode_number != null && b.episode_number != null) { + return b.episode_number > a.episode_number ? b : a; + } + if (a.episode_number != null) return a; + if (b.episode_number != null) return b; + if ((b.air_date || '') > (a.air_date || '')) return b; return a; + }; + tasks.forEach(t => { + const tname = t.task_name; + const sname = t.show_name; + const prog = progressByTaskName[tname]; + if (sname && prog) { + result[sname] = pickNewer(result[sname], prog); + } + }); + return result; + }, + + // 加载本地剧集数据;如无则自动 bootstrap + refresh 再读 + async loadCalendarEpisodesLocal() { + const tryReadLocal = async () => { + const res = await axios.get('/api/calendar/episodes_local'); + if (res.data && res.data.success) { + this.calendar.episodes = res.data.data.episodes || []; + return this.calendar.episodes.length; + } + return 0; + }; + + let count = await tryReadLocal(); + if (count > 0) return; + + try { + await axios.post('/api/calendar/bootstrap'); + } catch (e) { + console.warn('bootstrap 失败:', e); + } + + const tasklist = (this.formData && this.formData.tasklist) ? this.formData.tasklist : []; + const tmdbIds = []; + tasklist.forEach(t => { + const cal = (t && t.calendar_info) ? t.calendar_info : {}; + const match = cal.match || {}; + if (match.tmdb_id) tmdbIds.push(match.tmdb_id); + }); + + for (const id of tmdbIds) { + try { + await axios.get('/api/calendar/refresh_latest_season', { params: { tmdb_id: id } }); + } catch (e) { + console.warn('refresh 失败:', id, e); + } + } + + await tryReadLocal(); + }, + + // 初始化日历日期数据 + initializeCalendarDates() { + this.updateWeekDates(); + this.updateMonthWeeks(); + }, + + // 更新周视图日期 + updateWeekDates() { + const today = new Date(); + const currentDay = new Date(this.calendar.currentDate); + + // 动态计算显示的列数,根据主内容区域宽度自动调整 + const availableWidth = this.getCalendarAvailableWidth(); + const minColumnWidth = 140; // 最小列宽 + const columnGap = 20; // 列间距 + // 计算列数:n = floor((W + gap - eps) / (minWidth + gap)),避免边界像素/滚动条导致的多列 + const eps = 0.1; + const maxColumns = Math.max(2, Math.floor((availableWidth + columnGap - eps) / (minColumnWidth + columnGap))); + + + + // 从当前日期开始,向后扩展日期范围(今天始终在第一列) + const startDate = new Date(currentDay); + + this.calendar.weekDates = []; + for (let i = 0; i < maxColumns; i++) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + + const isToday = date.toDateString() === today.toDateString(); + const dayOfWeek = date.getDay(); + const weekdayNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']; + const weekday = weekdayNames[dayOfWeek]; + const day = date.getDate().toString().padStart(2, '0'); + + this.calendar.weekDates.push({ + date: this.formatDateToYYYYMMDD(date), + weekday: isToday ? '今天' : weekday, + day: `${(date.getMonth() + 1).toString().padStart(2, '0')}/${day}`, + isToday: isToday + }); + } + }, + + // 更新月视图周数据 + updateMonthWeeks() { + const currentDay = new Date(this.calendar.currentDate); + const year = currentDay.getFullYear(); + const month = currentDay.getMonth(); + + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const firstDayOfWeek = firstDay.getDay() || 7; // 转换为周一到周日(1-7) + + const weeks = []; + let currentWeek = []; + + // 添加上个月的日期 + for (let i = firstDayOfWeek - 1; i > 0; i--) { + const date = new Date(year, month, 1 - i); + currentWeek.push({ + date: this.formatDateToYYYYMMDD(date), + dayNumber: date.getDate(), + isCurrentMonth: false, + isToday: false, + episodes: [] + }); + } + + // 添加当前月的日期 + for (let day = 1; day <= lastDay.getDate(); day++) { + const date = new Date(year, month, day); + const isToday = date.toDateString() === new Date().toDateString(); + + currentWeek.push({ + date: this.formatDateToYYYYMMDD(date), + dayNumber: day, + isCurrentMonth: true, + isToday: isToday, + episodes: this.getEpisodesByDate(this.formatDateToYYYYMMDD(date)) + }); + + if (currentWeek.length === 7) { + weeks.push({ + weekIndex: weeks.length, + days: currentWeek + }); + currentWeek = []; + } + } + + // 添加下个月的日期 + if (currentWeek.length > 0) { + for (let day = 1; currentWeek.length < 7; day++) { + const date = new Date(year, month + 1, day); + currentWeek.push({ + date: this.formatDateToYYYYMMDD(date), + dayNumber: date.getDate(), + isCurrentMonth: false, + isToday: false, + episodes: [] + }); + } + weeks.push({ + weekIndex: weeks.length, + days: currentWeek + }); + } + + this.calendar.monthWeeks = weeks; + }, + + // 根据日期获取剧集 + getEpisodesByDate(date) { + // 从已加载的剧集中筛选指定日期的剧集 + if (!this.calendar.episodes || this.calendar.episodes.length === 0) { + return []; + } + + // 先进行基础筛选 + let filteredEpisodes = this.calendar.episodes.filter(episode => { + // 根据筛选条件过滤 + if (this.calendar.selectedType !== 'all') { + // 通过剧集名称匹配任务,获取内容类型 + const matchedTask = this.findTaskByShowName(episode.show_name); + const episodeContentType = matchedTask ? matchedTask.content_type : 'other'; + if (episodeContentType !== this.calendar.selectedType) { + return false; + } + } + + // 名称筛选:检查剧集名称 + if (this.calendar.nameFilter && this.calendar.nameFilter.trim() !== '') { + const nameFilter = this.calendar.nameFilter.toLowerCase(); + const showName = (episode.show_name || '').toLowerCase(); + if (!showName.includes(nameFilter)) { + return false; + } + } + + // 任务筛选:检查任务名称 + if (this.calendar.taskFilter && this.calendar.taskFilter.trim() !== '') { + const matchedTask = this.findTaskByShowName(episode.show_name); + const taskName = matchedTask ? matchedTask.task_name : ''; + if (taskName !== this.calendar.taskFilter) { + return false; + } + } + + return episode.air_date === date; + }); + + // 如果启用了合并集功能,则进行合并处理 + if (this.calendar.mergeEpisodes) { + return this.mergeEpisodesByShow(filteredEpisodes); + } + + return filteredEpisodes; + }, + + // 根据剧集名称查找对应的任务 + findTaskByShowName(showName) { + if (!this.calendar.tasks || !showName) return null; + + // 首先尝试精确匹配 + let matchedTask = this.calendar.tasks.find(task => + task.show_name === showName || + task.matched_show_name === showName + ); + + if (matchedTask) return matchedTask; + + // 如果精确匹配失败,尝试模糊匹配 + matchedTask = this.calendar.tasks.find(task => + (task.show_name && task.show_name.includes(showName)) || + (task.matched_show_name && task.matched_show_name.includes(showName)) + ); + + return matchedTask || null; + }, + // 判断剧集是否为 finale 集(支持合并集) + isFinaleEpisode(episode) { + try { + if (!episode) return false; + // 合并集:检查任一原始集是否 finale + if (episode.is_merged && Array.isArray(episode.original_episodes) && episode.original_episodes.length > 0) { + return episode.original_episodes.some(ep => { + const t = String((ep && (ep.type || ep.ep_type || ep.episode_type)) || '').toLowerCase(); + return t.includes('finale'); + }); + } + // 单集:直接检查类型字段 + const tp = String((episode.type || episode.ep_type || episode.episode_type || '')).toLowerCase(); + return tp.includes('finale'); + } catch (e) { + return false; + } + }, + // 获取需要在 finale 集显示的节目状态文案 + getEpisodeFinaleStatus(episode) { + try { + if (!this.isFinaleEpisode(episode)) return ''; + const task = this.findTaskByShowName(episode && episode.show_name); + const status = task && task.matched_status ? String(task.matched_status).trim() : ''; + // 规范:若节目为“已完结/已取消”,则在 finale 集显示对应状态;否则显示“本季终” + if (status === '已完结' || status === '已取消') return status; + return '本季终'; + } catch (e) { + return ''; + } + }, + // 返回用于悬停提示的 文本:剧名 或 剧名 · 状态 + getEpisodeShowTitleWithStatus(episode) { + try { + const name = (episode && episode.show_name) ? String(episode.show_name).trim() : ''; + const status = this.getEpisodeFinaleStatus(episode); + return status ? `${name} · ${status}` : name; + } catch (e) { + return (episode && episode.show_name) || ''; + } + }, + + // 合并同一天同一节目的多集 + mergeEpisodesByShow(episodes) { + if (!episodes || episodes.length === 0) { + return []; + } + + // 按节目名称和任务信息分组 + const groupedEpisodes = {}; + + episodes.forEach(episode => { + // 验证episode对象的基本结构 + if (!episode || typeof episode !== 'object') { + return; + } + + // 创建唯一键:节目名称 + 任务名称 + 季数 + const showName = episode.show_name || 'unknown'; + const taskName = (episode.task_info && episode.task_info.task_name) ? episode.task_info.task_name : 'unknown'; + const seasonNumber = episode.season_number || 1; + + // 清理键值,防止特殊字符导致问题 + const cleanShowName = String(showName).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_'); + const cleanTaskName = String(taskName).replace(/[^a-zA-Z0-9\u4e00-\u9fa5]/g, '_'); + const showKey = `${cleanShowName}_${cleanTaskName}_${seasonNumber}`; + + if (!groupedEpisodes[showKey]) { + groupedEpisodes[showKey] = []; + } + groupedEpisodes[showKey].push(episode); + }); + + // 对每个分组进行合并处理 + const mergedEpisodes = []; + + Object.values(groupedEpisodes).forEach(episodeGroup => { + if (episodeGroup.length === 1) { + // 只有一集,直接添加 + mergedEpisodes.push(episodeGroup[0]); + } else { + // 多集,进行合并 + const mergedEpisode = this.createMergedEpisode(episodeGroup); + mergedEpisodes.push(mergedEpisode); + } + }); + + return mergedEpisodes; + }, + + // 创建合并后的剧集对象 + createMergedEpisode(episodeGroup) { + // 按集数排序 + episodeGroup.sort((a, b) => { + const episodeA = parseInt(a.episode_number) || 0; + const episodeB = parseInt(b.episode_number) || 0; + return episodeA - episodeB; + }); + + // 使用最后一集的数据作为基础 + const baseEpisode = episodeGroup[episodeGroup.length - 1]; + const firstEpisode = episodeGroup[0]; + const lastEpisode = episodeGroup[episodeGroup.length - 1]; + + // 创建合并后的剧集对象 + const mergedEpisode = { + ...baseEpisode, + // 合并集数显示 + episode_number: this.formatMergedEpisodeNumbers(episodeGroup), + // 合并集数范围(用于显示) + episode_range: { + start: parseInt(firstEpisode.episode_number) || 0, + end: parseInt(lastEpisode.episode_number) || 0, + count: episodeGroup.length + }, + // 标记为合并集 + is_merged: true, + // 原始剧集列表(用于调试或其他用途) + original_episodes: episodeGroup + }; + + return mergedEpisode; + }, + + // 格式化合并集数显示 + formatMergedEpisodeNumbers(episodeGroup) { + if (!episodeGroup || episodeGroup.length === 0) { + return ''; + } + + if (episodeGroup.length === 1) { + return episodeGroup[0].episode_number || ''; + } + + const firstEpisode = parseInt(episodeGroup[0].episode_number) || 0; + const lastEpisode = parseInt(episodeGroup[episodeGroup.length - 1].episode_number) || 0; + const seasonNumber = parseInt(episodeGroup[0].season_number) || 1; + + // 确保集数和季数都是有效的数字 + if (isNaN(firstEpisode) || isNaN(lastEpisode) || isNaN(seasonNumber)) { + return episodeGroup[0].episode_number || ''; + } + + if (firstEpisode === lastEpisode) { + return `S${String(seasonNumber).padStart(2, '0')}E${String(firstEpisode).padStart(2, '0')}`; + } else { + return `S${String(seasonNumber).padStart(2, '0')}E${String(firstEpisode).padStart(2, '0')}-E${String(lastEpisode).padStart(2, '0')}`; + } + }, + + // 获取内容类型显示名称 + getContentTypeDisplayName(type) { + const typeNames = { + 'all': '全部', + 'tv': '剧集', + 'anime': '动画', + 'variety': '综艺', + 'documentary': '纪录片', + 'other': '其他' + }; + return typeNames[type] || type; + }, + + // 缓存管理方法 + setCachedData(key, data) { + try { + localStorage.setItem(`quark_calendar_${key}`, JSON.stringify(data)); + } catch (error) { + console.warn('缓存数据失败:', error); + } + }, + + getCachedData(key) { + try { + const data = localStorage.getItem(`quark_calendar_${key}`); + return data ? JSON.parse(data) : null; + } catch (error) { + console.warn('读取缓存数据失败:', error); + return null; + } + }, + + + + // 增量刷新追剧日历数据:仅对最新季执行增量拉取,然后本地读取 + async refreshCalendarData() { + try { + const tasklist = (this.formData && this.formData.tasklist) ? this.formData.tasklist : []; + const tmdbIds = []; + tasklist.forEach(t => { + const cal = (t && t.calendar_info) ? t.calendar_info : {}; + const match = cal.match || {}; + if (match.tmdb_id) tmdbIds.push(match.tmdb_id); + }); + for (const id of tmdbIds) { + try { + await axios.get('/api/calendar/refresh_latest_season', { params: { tmdb_id: id } }); + } catch (e) { + console.warn('refresh 失败:', id, e); + } + } + + // 重新加载任务数据,确保内容管理页面能热更新 + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + // 重新构建进度映射 + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + + // 重新计算内容类型,确保类型按钮能热更新 + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + } + } catch (e) {} + + // 刷新内存数据,不清空缓存键,直接本地读取覆盖 + await this.loadCalendarEpisodesLocal(); + // 热更新当前视图(不打断用户操作) + this.initializeCalendarDates(); + // 并行刷新今日更新数据,确保当日更新标识正常显示 + try { await this.loadTodayUpdatesLocal(); } catch (e) {} + // 记录自动刷新时间戳,用于节流 + try { localStorage.setItem('calendar_last_auto_refresh', String(Date.now())); } catch (e) {} + } catch (error) { + console.warn('增量刷新失败:', error); + } + }, + + // 刷新单集或合并集的元数据(海报视图使用) + async refreshEpisodeMetadata(episode) { + try { + if (!episode || !episode.tmdb_id || !episode.season_number) { + console.warn('刷新单集元数据失败:缺少必要参数', episode); + return; + } + + // 检查是否为合并集 + if (episode.is_merged && episode.original_episodes && episode.original_episodes.length > 0) { + // 合并集:刷新所有原始集 + console.log('检测到合并集,将刷新所有原始集:', episode.original_episodes.length, '集'); + + let successCount = 0; + let failCount = 0; + const successEpisodes = []; + const failEpisodes = []; + const errors = []; + + // 并行刷新所有原始集 + const refreshPromises = episode.original_episodes.map(async (originalEpisode) => { + try { + const response = await axios.get('/api/calendar/refresh_episode', { + params: { + tmdb_id: episode.tmdb_id, + season_number: episode.season_number, + episode_number: originalEpisode.episode_number + } + }); + + if (response.data.success) { + successCount++; + successEpisodes.push(originalEpisode.episode_number); + return { success: true, episode: originalEpisode.episode_number }; + } else { + failCount++; + failEpisodes.push(originalEpisode.episode_number); + errors.push(`第${originalEpisode.episode_number}集: ${response.data.message}`); + return { success: false, episode: originalEpisode.episode_number, error: response.data.message }; + } + } catch (error) { + failCount++; + failEpisodes.push(originalEpisode.episode_number); + const errorMsg = error.response?.data?.message || error.message; + errors.push(`第${originalEpisode.episode_number}集: ${errorMsg}`); + return { success: false, episode: originalEpisode.episode_number, error: errorMsg }; + } + }); + + // 等待所有刷新完成 + await Promise.all(refreshPromises); + + // 显示结果提示 + const showName = episode.show_name || '未知剧集'; + const seasonNumber = episode.season_number; + + if (successCount > 0 && failCount === 0) { + // 全部成功 + const episodeRange = this.formatEpisodeRange(successEpisodes); + this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${episodeRange}刷新成功`); + } else if (successCount > 0 && failCount > 0) { + // 部分成功:使用顿号明确区分成功和失败的集数 + const successRange = this.formatEpisodeRange(successEpisodes, true); + const failRange = this.formatEpisodeRange(failEpisodes, true); + this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${successRange}刷新成功,${failRange}刷新失败`); + console.warn('部分刷新失败:', errors); + } else { + // 全部失败 + const episodeRange = this.formatEpisodeRange(failEpisodes); + this.showToast(`《${showName}》第 ${seasonNumber} 季 · ${episodeRange}刷新失败`); + console.error('集刷新失败:', errors); + } + + } else { + // 单集:直接刷新 + if (!episode.episode_number) { + console.warn('单集缺少集号参数', episode); + this.showToast('缺少集号信息'); + return; + } + + const response = await axios.get('/api/calendar/refresh_episode', { + params: { + tmdb_id: episode.tmdb_id, + season_number: episode.season_number, + episode_number: episode.episode_number + } + }); + + if (response.data.success) { + this.showToast(response.data.message || '单集刷新成功'); + } else { + this.showToast(response.data.message || '单集刷新失败'); + } + } + + // 热更新数据 + await this.refreshCalendarData(); + + } catch (error) { + console.error('刷新单集元数据失败:', error); + this.showToast('刷新失败:' + (error.response?.data?.message || error.message)); + } + }, + + // 刷新整个季的元数据(内容管理视图使用) + async refreshSeasonMetadata(task) { + try { + if (!task || !task.match_tmdb_id) { + console.warn('刷新季元数据失败:缺少必要参数', task); + return; + } + + // 获取最新季数,优先使用任务中的 season_number,否则从数据库获取 + let season_number = task.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('无法获取季数信息'); + return; + } + + // 先刷新剧级别详情(更新节目状态/最新季/海报等) + try { + await axios.get('/api/calendar/refresh_show', { + params: { tmdb_id: task.match_tmdb_id } + }); + } catch (e) { + // 忽略失败,继续刷新季 + console.warn('刷新剧详情失败(忽略继续):', e); + } + + const response = await axios.get('/api/calendar/refresh_season', { + params: { + tmdb_id: task.match_tmdb_id, + season_number: season_number + } + }); + + if (response.data.success) { + // 显示成功提示 + this.showToast(response.data.message || '季元数据刷新成功'); + + // 刷新任务数据,确保节目状态(如 本季终/已完结/已取消)及时更新 + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + } + } catch (e) {} + + // 热更新日历与本地剧集数据 + await this.refreshCalendarData(); + } else { + this.showToast(response.data.message || '季元数据刷新失败'); + } + } catch (error) { + console.error('刷新季元数据失败:', error); + this.showToast('季元数据刷新失败:' + (error.response?.data?.message || error.message)); + } + }, + + // 打开编辑元数据模态框 + openEditMetadataModal(task) { + try { + if (!task) return; + // 预填充表单数据 + 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 matchedName = task.matched_show_name || ''; + const matchedYear = task.matched_year || ''; + + this.editMetadata = { + visible: true, + original: { + task_name: currentName, + content_type: task.content_type || '', + tmdb_id: currentTmdbId || '', + season_number: currentSeason || '' + }, + form: { + task_name: currentName, + content_type: task.content_type || '', + tmdb_id: '', + season_number: currentSeason || 1 + }, + display: { + matched_label: matchedName ? `${matchedName}${matchedYear ? ' (' + matchedYear + ')' : ''}` : '未匹配', + matched_tmdb_id: currentTmdbId || '', + matched_season_number: currentSeason || '', + seasonInputWidth: '32px' + }, + hint: `若匹配结果不正确,请前往 TMDB 搜索对应的正确条目,并使用该条目网址末尾的数字(即 TMDB ID)来修正匹配` + }; + + $('#editMetadataModal').modal('show'); + // 初始化季数输入宽度 + this.$nextTick(() => { + try { + const initVal = String(this.editMetadata.form.season_number || '1'); + const px = this.measureSeasonInputWidth(initVal); + this.$set(this.editMetadata.display, 'seasonInputWidth', px + 'px'); + } catch (e) {} + }); + // 若已匹配但没有季数信息,则从后端获取最新季数用于展示 + try { + const tid = this.editMetadata.display.matched_tmdb_id; + const hasSeason = !!this.editMetadata.display.matched_season_number; + if (tid && !hasSeason) { + axios.get('/api/calendar/show_info', { params: { tmdb_id: tid } }) + .then(res => { + if (res.data && res.data.success && res.data.data) { + const sn = res.data.data.latest_season_number; + if (sn) { + this.$set(this.editMetadata.display, 'matched_season_number', sn); + } + } + }) + .catch(() => {}); + } + } catch (e) {} + } catch (e) { + this.showToast('打开编辑失败'); + } + }, + + // 保存编辑元数据 + async saveEditMetadata() { + try { + if (!this.editMetadata || !this.editMetadata.form) return; + const payload = { + task_name: this.editMetadata.original.task_name, + new_task_name: this.editMetadata.form.task_name, + new_content_type: this.editMetadata.form.content_type, + new_tmdb_id: this.editMetadata.form.tmdb_id, + new_season_number: this.editMetadata.form.season_number + }; + + // 如果没有任何变化则直接关闭 + const noNameChange = (payload.new_task_name || '') === (this.editMetadata.original.task_name || ''); + const noTypeChange = (payload.new_content_type || '') === (this.editMetadata.original.content_type || ''); + const noRematch = !(payload.new_tmdb_id && String(payload.new_tmdb_id).trim()) && !(payload.new_season_number && String(payload.new_season_number).trim()); + if (noNameChange && noTypeChange && noRematch) { + $('#editMetadataModal').modal('hide'); + return; + } + + const res = await axios.post('/api/calendar/edit_metadata', payload); + if (res.data && res.data.success) { + this.showToast(res.data.message || '保存成功'); + $('#editMetadataModal').modal('hide'); + // 热更新任务与日历 + // 避免触发“未保存修改”提示:本次更新由后端变更引发 + this.suppressConfigModifiedOnce = true; + await this.refreshCalendarData(); + } else { + this.showToast(res.data.message || '保存失败'); + } + } catch (e) { + this.showToast('保存失败:' + (e.response?.data?.message || e.message)); + } + }, + // 根据输入内容自适应季数输入框宽度,最小32px + autoSizeSeasonInput(e) { + try { + const el = e && e.target; + if (!el) return; + const val = String(el.value || ''); + // 使用隐藏量尺精确测量当前文本宽度 + let ruler = document.getElementById('season-width-ruler'); + if (!ruler) { + ruler = document.createElement('span'); + ruler.id = 'season-width-ruler'; + ruler.style.position = 'absolute'; + ruler.style.visibility = 'hidden'; + ruler.style.whiteSpace = 'pre'; + ruler.style.font = window.getComputedStyle(el).font; + document.body.appendChild(ruler); + } + ruler.style.font = window.getComputedStyle(el).font; + ruler.textContent = val || '1'; + const textWidth = ruler.getBoundingClientRect().width; + // 左右 padding(16) + 边框(2) + const px = Math.max(32, Math.ceil(textWidth + 16 + 2)); + if (this.editMetadata && this.editMetadata.display) { + this.$set(this.editMetadata.display, 'seasonInputWidth', px + 'px'); + } else { + el.style.width = px + 'px'; + } + } catch (err) {} + }, + // 供初始化时测量使用 + measureSeasonInputWidth(val) { + try { + const el = document.querySelector('#editMetadataModal .edit-season-number'); + if (!el) return 32; + let ruler = document.getElementById('season-width-ruler'); + if (!ruler) { + ruler = document.createElement('span'); + ruler.id = 'season-width-ruler'; + ruler.style.position = 'absolute'; + ruler.style.visibility = 'hidden'; + ruler.style.whiteSpace = 'pre'; + document.body.appendChild(ruler); + } + const style = window.getComputedStyle(el); + ruler.style.font = style.font; + ruler.textContent = String(val || '1'); + const textWidth = ruler.getBoundingClientRect().width; + return Math.max(32, Math.ceil(textWidth + 16 + 2)); + } catch (e) { return 32; } + }, + + // 格式化集数范围显示 + formatEpisodeRange(episodeNumbers, useComma = false) { + if (!episodeNumbers || episodeNumbers.length === 0) { + return ''; + } + + // 排序集数 + const sortedEpisodes = [...episodeNumbers].sort((a, b) => a - b); + + if (sortedEpisodes.length === 1) { + return `第 ${sortedEpisodes[0]} 集`; + } + + // 如果强制使用顿号(用于部分成功的情况) + if (useComma) { + return `第 ${sortedEpisodes.join('、')} 集`; + } + + // 检查是否为连续集数 + const isConsecutive = sortedEpisodes.every((ep, index) => { + if (index === 0) return true; + return ep === sortedEpisodes[index - 1] + 1; + }); + + if (isConsecutive) { + // 连续集数:第 33 至 35 集 + return `第 ${sortedEpisodes[0]} 至 ${sortedEpisodes[sortedEpisodes.length - 1]} 集`; + } else { + // 非连续集数:第 33、35、37 集 + return `第 ${sortedEpisodes.join('、')} 集`; + } + }, + + // 是否需要自动刷新:默认30分钟节流(可根据需要调整) + shouldAutoRefreshCalendar() { + const key = 'calendar_last_auto_refresh'; + const throttleMs = 30 * 60 * 1000; // 30分钟 + try { + const last = parseInt(localStorage.getItem(key) || '0'); + if (!last || (Date.now() - last) > throttleMs) return true; + } catch (e) { + return true; + } + return false; + }, + + // 计算“最近转存文件”的签名,便于快速判断是否有变化 + calcLatestFilesSignature(mapObj) { + try { + if (!mapObj) return ''; + const entries = Object.keys(mapObj).sort().map(k => `${k}:${mapObj[k]}`); + return entries.join('|'); + } catch (e) { return ''; } + }, + + // 启动后台监听:每60秒轻量检查一次 task_latest_info,变更则触发热更新 + startCalendarAutoWatch() { + try { + if (this.calendarAutoWatchTimer) return; + // 初始化当前签名(若任务面板已加载过最新信息可直接使用;否则置空) + this.calendarLatestFilesSignature = this.calcLatestFilesSignature(this.taskLatestFiles); + const tick = 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.calendarLatestFilesSignature) { + // 更新签名,触发热更新 + this.calendarLatestFilesSignature = sig; + // 先更新任务面板数据(若有显示) + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = res.data.data.latest_records || {}; + // 同步重建“已转存”判定所需的进度映射,确保UI立即反映 + try { + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } catch (e) {} + // 拉取并热更新日历 + await this.refreshCalendarData(); + // 同步“今日更新” + try { await this.loadTodayUpdatesLocal(); } catch (e) {} + } + } + } catch (e) { + // 忽略错误,下一轮继续 + } + }; + this.calendarAutoWatchTickRef = tick; + // 立即执行一次检查(不阻塞UI),随后每60秒执行 + setTimeout(tick, 0); + const baseIntervalMs = this.calendar && this.calendar.manageMode ? 5 * 1000 : 60 * 1000; + if (!document.hidden) { + this.calendarAutoWatchTimer = setInterval(tick, baseIntervalMs); + } + // 页面可见性变化:隐藏时暂停,显示时恢复并立刻检查(SSE 在时不重复启轮询) + const onFocusOrVisible = () => tick(); + const onVisibilityChange = () => { + if (document.hidden) { + if (this.calendarAutoWatchTimer) { + clearInterval(this.calendarAutoWatchTimer); + this.calendarAutoWatchTimer = null; + } + } else { + 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(); + } + } + }; + this.calendarAutoWatchFocusHandler = onFocusOrVisible; + this.calendarAutoWatchVisibilityHandler = onVisibilityChange; + window.addEventListener('focus', this.calendarAutoWatchFocusHandler); + document.addEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler); + + // 建立 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) {} + }; + const onChanged = async (ev) => { + try { + // 解析变更原因(后端通过 SSE data 传递) + let changeReason = ''; + try { changeReason = JSON.parse(ev && ev.data || '{}').reason || ''; } catch (e) {} + + // 先拉取最新转存信息并重建映射(用于管理视图与进度判定) + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + + // 重新加载任务数据,确保内容管理页面能热更新 + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + // 重新构建进度映射 + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + + // 重新计算内容类型,确保类型按钮能热更新 + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + } + } catch (e) {} + + // 若为任务编辑/保存操作,刷新任务列表 formData.tasklist,实现任务列表页面热更新 + try { + if (changeReason === 'edit_metadata' || changeReason === 'task_updated') { + const dataRes = await axios.get('/data'); + if (dataRes.data && dataRes.data.success) { + const cfg = dataRes.data.data || {}; + const oldTaskCount = (this.formData.tasklist || []).length; + // 后端推送导致的任务列表更新,不应触发“未保存修改”提示 + this.suppressConfigModifiedOnce = true; + this.formData.tasklist = cfg.tasklist || []; + // 同步任务名集合用于筛选 + this.calendar.taskNames = (this.formData.tasklist || []).map(t => t.taskname).filter(Boolean); + // 如任务数量变化,重建与任务相关的最新文件映射的键集合 + if ((this.formData.tasklist || []).length !== oldTaskCount) { + try { + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(this.taskLatestFiles || {}); + } catch (e) {} + } + } + } + } catch (e) {} + + // 再仅本地读取并热更新日历/海报视图 + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + // 并行刷新今日更新数据 + 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) {} + }; + } + } catch (e) { + // 忽略 SSE 失败,继续使用轮询 + } + } catch (e) { + // ignore + } + }, + + // 停止后台监听并移除事件 + stopCalendarAutoWatch() { + try { + if (this.calendarAutoWatchTimer) { + clearInterval(this.calendarAutoWatchTimer); + this.calendarAutoWatchTimer = null; + } + if (this.calendarAutoWatchFocusHandler) { + window.removeEventListener('focus', this.calendarAutoWatchFocusHandler); + this.calendarAutoWatchFocusHandler = null; + } + if (this.calendarAutoWatchVisibilityHandler) { + document.removeEventListener('visibilitychange', this.calendarAutoWatchVisibilityHandler); + this.calendarAutoWatchVisibilityHandler = null; + } + if (this.calendarSSE) { + try { this.calendarSSE.close(); } catch (e) {} + this.calendarSSE = null; + } + } catch (e) { + // ignore + } + }, + + // 选择内容类型 + selectCalendarType(type) { + this.calendar.selectedType = type; + try { + localStorage.setItem('calendar_selected_type', type); + } catch (e) { + // ignore + } + // 重新构建月视图数据以应用筛选(天内episodes依赖selectedType) + this.initializeCalendarDates(); + }, + + // 清除筛选器 + clearCalendarFilter(filterType) { + if (filterType === 'nameFilter') { + this.calendar.nameFilter = ''; + } else if (filterType === 'taskFilter') { + this.calendar.taskFilter = ''; + } + }, + + // 更新内容类型列表(用于热更新) + updateContentTypes(rawTypes) { + const knownOrderWithoutOther = ['tv', 'anime', 'variety', 'documentary']; + const hasOther = rawTypes.includes('other'); + const typeSet = new Set(rawTypes); + const orderedKnown = knownOrderWithoutOther.filter(t => typeSet.has(t)); + const unknownTypes = rawTypes.filter(t => !knownOrderWithoutOther.concat(['other']).includes(t)); + this.calendar.contentTypes = [ + ...orderedKnown, + ...unknownTypes, + ...(hasOther ? ['other'] : []) + ]; + }, + + // 切换视图模式 + toggleCalendarViewMode() { + this.calendar.viewMode = this.calendar.viewMode === 'poster' ? 'month' : 'poster'; + try { + localStorage.setItem('calendar_view_mode', this.calendar.viewMode); + } catch (e) { + console.warn('无法持久化保存日历视图模式:', e); + } + this.initializeCalendarDates(); + }, + + // 切换合并集模式 + toggleCalendarMergeEpisodes() { + this.calendar.mergeEpisodes = !this.calendar.mergeEpisodes; + try { + localStorage.setItem('calendar_merge_episodes', this.calendar.mergeEpisodes.toString()); + } catch (e) { + console.warn('无法持久化保存合并集设置:', e); + } + // 重新初始化日历数据以应用新的合并集设置 + this.initializeCalendarDates(); + }, + + // 更改日期 + changeCalendarDate(action) { + const currentDate = new Date(this.calendar.currentDate); + + switch (action) { + case 'prevDay': + currentDate.setDate(currentDate.getDate() - 1); + break; + case 'nextDay': + currentDate.setDate(currentDate.getDate() + 1); + break; + case 'prevWeek': + currentDate.setDate(currentDate.getDate() - 7); + break; + case 'nextWeek': + currentDate.setDate(currentDate.getDate() + 7); + break; + case 'prevMonth': + currentDate.setMonth(currentDate.getMonth() - 1); + break; + case 'nextMonth': + currentDate.setMonth(currentDate.getMonth() + 1); + break; + } + + this.calendar.currentDate = currentDate; + this.initializeCalendarDates(); + }, + + // 回到今天 + goToToday() { + this.calendar.currentDate = new Date(); + this.initializeCalendarDates(); + }, + + // 处理海报悬停 + handleCalendarPosterHover(event, episode) { + // 从图片提取颜色并为覆盖层生成渐变背景(与影视发现页一致) + const posterElement = event.currentTarget; + const imgElement = posterElement.querySelector('img'); + const overlayElement = posterElement.querySelector('.calendar-poster-overlay'); + const overviewElement = overlayElement ? overlayElement.querySelector('.info-line.overview') : null; + + if (imgElement && overlayElement) { + if (imgElement.complete) { + const gradient = this.createGradientFromImage(imgElement); + overlayElement.style.background = gradient; + } else { + overlayElement.style.background = 'var(--dark-text-color)'; + } + } + + // 按行数截断:根据宽度估算每行字符数与行高,保证最后一行完整并追加省略号 + if (overviewElement) { + try { + const maxHeightRatio = 0.7; // 简介最高占比70% + const posterHeight = posterElement.clientHeight || 0; + const overlayStyles = window.getComputedStyle(overviewElement); + const lineHeight = parseFloat(overlayStyles.lineHeight) || 18; // 兜底18px + const fontSize = parseFloat(overlayStyles.fontSize) || 14; // 兜底14px + const containerWidth = posterElement.clientWidth || 0; + if (!posterHeight || !containerWidth || !lineHeight) return; + + const maxHeight = Math.floor(posterHeight * maxHeightRatio); + const maxLines = Math.max(1, Math.floor(maxHeight / lineHeight)); + + // 基于经验的单行字符估算(中文字符大约 ~0.55 * 宽度/字体大小) + const charsPerLine = Math.max(6, Math.floor(containerWidth / (fontSize * 0.55))); + + const fullText = overviewElement.getAttribute('data-fulltext') || overviewElement.textContent || ''; + // 如果天然高度未超过限制则不处理 + overviewElement.textContent = fullText; + overviewElement.style.maxHeight = ''; + const naturalHeight = overviewElement.scrollHeight; + if (naturalHeight <= maxHeight) { + return; // 文本本来就不超出,无需截断 + } + + // 目标字符上限(留出省略号空间) + const targetChars = Math.max(1, (maxLines * charsPerLine) - 1); + + // 二分查找截断位置,确保不超过最大高度 + let left = 0, right = fullText.length, best = 0; + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const trial = fullText.slice(0, Math.min(mid, targetChars)) + '…'; + overviewElement.textContent = trial; + if (overviewElement.scrollHeight <= maxHeight) { + best = mid; + left = mid + 1; + } else { + right = mid - 1; + } + } + + const finalText = fullText.length > best ? (fullText.slice(0, Math.min(best, targetChars)) + '…') : fullText; + overviewElement.textContent = finalText; + } catch (e) { + // ignore + } + } + }, + + // 隐藏海报悬停 + hideCalendarPosterHover() { + // 仅由CSS控制透明度,不重置背景以保留取色渐变 + }, + + // 获取剧集海报URL + getEpisodePosterUrl(episode) { + // 优先使用本地缓存海报 + if (episode.poster_local_path) { + return episode.poster_local_path; + } + + // 如果没有海报,返回默认图片 + return '/static/images/no-poster.svg'; + }, + + // 处理图片加载错误 + handleCalendarImageError(event) { + event.target.src = '/static/images/no-poster.svg'; + }, + + // ===== 精简版“已转存”判定:仅依据任务列表最近转存文件构建的映射 ===== + // 提取进度(优先按任务名命中,其次按剧名命中) + getSimpleProgress(episode) { + if (!episode) return null; + // 1) 任务名映射 + const tname = episode.task_info && episode.task_info.task_name ? episode.task_info.task_name : null; + if (tname && this.calendar.progressByTaskName && this.calendar.progressByTaskName[tname]) { + const p = this.calendar.progressByTaskName[tname] || {}; + const epNum = p.episode_number != null ? parseInt(p.episode_number) : null; + const air = p.air_date || null; + return { episode_number: isNaN(epNum) ? null : epNum, air_date: air }; + } + // 2) 剧名映射 + const sname = episode.show_name || null; + if (sname && this.calendar.progressByShowName && this.calendar.progressByShowName[sname]) { + const p = this.calendar.progressByShowName[sname] || {}; + const epNum = p.episode_number != null ? parseInt(p.episode_number) : null; + const air = p.air_date || null; + return { episode_number: isNaN(epNum) ? null : epNum, air_date: air }; + } + return null; + }, + + // 用于比较的集号(合并集取末尾一集;否则取自身集号) + getEpisodeCompareNumber(episode) { + if (!episode) return null; + if (episode.is_merged && episode.episode_range && episode.episode_range.end != null) { + const n = parseInt(episode.episode_range.end); + return isNaN(n) ? null : n; + } + if (episode.episode_number != null) { + const n = parseInt(episode.episode_number); + return isNaN(n) ? null : n; + } + return null; + }, + + // 统一判断“是否已转存/已完成” + isEpisodeReachedProgress(episode) { + const prog = this.getSimpleProgress(episode); + if (!prog) return false; + const lastNum = this.getEpisodeCompareNumber(episode); + if (prog.episode_number != null && lastNum != null) { + return lastNum <= prog.episode_number; + } + if (prog.air_date && episode && episode.air_date) { + return episode.air_date <= prog.air_date; + } + return false; + }, + + + + // 获取剧集显示集数 + getEpisodeDisplayNumber(episode) { + if (episode.is_merged) { + // 合并集直接使用格式化后的集数 + return episode.episode_number; + } else { + // 普通集数按原格式显示 + return `S${episode.season_number.toString().padStart(2, '0')}E${episode.episode_number.toString().padStart(2, '0')}`; + } + }, + + // 获取仅集数格式(省略季) + getEpisodeOnlyNumber(episode) { + if (episode.is_merged) { + // 合并集:S01E34-E38 -> E34-E38 + return episode.episode_number.replace(/^S\d+/, ''); + } else { + // 普通集数:S01E06 -> E06 + return `E${episode.episode_number.toString().padStart(2, '0')}`; + } + }, + + // 获取纯数字格式 + getNumberOnly(episode) { + if (episode.is_merged) { + // 合并集:S01E34-E38 -> 34-38 + return episode.episode_number.replace(/^S\d+E/, '').replace(/E/g, ''); + } else { + // 普通集数:S01E06 -> 06 + return episode.episode_number.toString().padStart(2, '0'); + } + }, + + // 获取剧集提示信息 + getEpisodeTooltip(episode) { + // 规范:集数悬停显示对应集在TMDB上的单集标题 + // 普通:SxxExx 标题 + // 合并:逐行列出每一集:SxxExx 标题 + const pad2 = n => String(n).padStart(2, '0'); + if (episode.is_merged && Array.isArray(episode.original_episodes) && episode.original_episodes.length) { + const lines = episode.original_episodes.map(ep => { + const s = ep.season_number ? pad2(ep.season_number) : pad2(episode.season_number || 1); + const e = ep.episode_number ? pad2(ep.episode_number) : ''; + const title = ep.name || ''; + return `S${s}E${e} ${title}`.trim(); + }); + return lines.join('\n'); + } + const s = episode.season_number ? pad2(episode.season_number) : '01'; + const e = episode.episode_number ? pad2(episode.episode_number) : '01'; + const title = episode.name || ''; + return `S${s}E${e} ${title}`.trim(); + }, + + // 打开剧的TMDB页面 + openShowTmdbPage(episode) { + try { + const tmdbId = episode.tmdb_id || (episode.task_info && episode.task_info.match && episode.task_info.match.tmdb_id) || (episode.task_info && episode.task_info.tmdb_id); + if (tmdbId) { + const url = `https://www.themoviedb.org/tv/${tmdbId}`; + window.open(url, '_blank'); + } + } catch (e) { + console.warn('打开剧TMDB页面失败', e); + } + }, + + // 打开“内容管理”任务匹配到的节目的 TMDB 页面 + openTaskMatchedTmdbPage(task) { + try { + if (!task) return; + const tmdbId = (task.match && task.match.tmdb_id) || task.match_tmdb_id || (task.calendar_info && task.calendar_info.match && task.calendar_info.match.tmdb_id) || task.tmdb_id; + if (tmdbId) { + const url = `https://www.themoviedb.org/tv/${tmdbId}`; + window.open(url, '_blank'); + } + } catch (e) { + console.warn('打开任务匹配TMDB页面失败', e); + } + }, + + // 打开集的TMDB页面(合并集打开最后一集) + openEpisodeTmdbPage(episode) { + try { + const tmdbId = episode.tmdb_id || (episode.task_info && episode.task_info.match && episode.task_info.match.tmdb_id) || (episode.task_info && episode.task_info.tmdb_id); + if (!tmdbId) return; + const season = episode.season_number || (episode.original_episodes && episode.original_episodes.length ? episode.original_episodes[0].season_number : 1); + const episodeNum = episode.is_merged && episode.episode_range ? episode.episode_range.end : episode.episode_number; + if (!season || !episodeNum) return; + const url = `https://www.themoviedb.org/tv/${tmdbId}/season/${parseInt(season)}/episode/${parseInt(episodeNum)}`; + window.open(url, '_blank'); + } catch (e) { + console.warn('打开集TMDB页面失败', e); + } + }, + // 获取插件配置的占位符文本 getPluginConfigPlaceholder(pluginName, key) { const placeholders = { @@ -3404,6 +5858,14 @@ this.sidebarCollapsed = !this.sidebarCollapsed; // 保存侧边栏状态到本地存储 localStorage.setItem('quarkAutoSave_sidebarCollapsed', this.sidebarCollapsed); + + // 如果当前在追剧日历页面且为海报模式,重新计算列数 + if (this.activeTab === 'calendar' && this.calendar.viewMode === 'poster') { + // 延迟一点时间,等待DOM更新完成 + this.$nextTick(() => { + this.updateWeekDates(); + }); + } }, cleanTaskNameForSearch(taskName) { if (!taskName) return ''; @@ -3473,6 +5935,13 @@ this.fetchAccountsDetail(); this.loadFileListWithoutLoading(this.fileManager.currentFolder); } + + // 当切换到追剧日历标签时加载日历数据 + if (tab === 'calendar') { + this.loadCalendarData(); + // 添加窗口大小变化监听器,自动调整列数 + this.addCalendarResizeListener(); + } }, checkNewVersion() { // 移除本地版本中的v前缀 @@ -3524,6 +5993,27 @@ // 初始化新任务的插件配置,应用全局配置 this.newTask.addition = { ...config_data.task_plugins_config_default }; this.applyGlobalPluginConfig(this.newTask); + // --- 新增:预加载追剧日历任务元数据,支持任务列表直接显示 season_counts / matched_status --- + axios.get('/api/calendar/tasks') + .then(res => { + if (res.data && res.data.success) { + this.calendar.tasks = res.data.data.tasks || []; + try { + this.calendar.taskMapByName = {}; + (this.calendar.tasks || []).forEach(t => { + const key = (t.task_name || t.taskname || '').trim(); + if (key) this.calendar.taskMapByName[key] = t; + }); + } catch (e) {} + } else { + this.calendar.tasks = []; + this.calendar.taskMapByName = {}; + } + }) + .catch(() => { + this.calendar.tasks = []; + this.calendar.taskMapByName = {}; + }); // 确保source配置存在 if (!config_data.source) { config_data.source = {}; @@ -3577,11 +6067,23 @@ delete_task: "always", refresh_plex: "always", refresh_alist: "always", + season_counts: "always", latest_update_date: "always", + task_progress: "always", + show_status: "always", latest_transfer_file: "always", today_update_indicator: "always" }; } + if (!config_data.button_display.season_counts) { + config_data.button_display.season_counts = "always"; + } + if (!config_data.button_display.task_progress) { + config_data.button_display.task_progress = "always"; + } + if (!config_data.button_display.show_status) { + config_data.button_display.show_status = "always"; + } // 确保最近更新日期配置存在(向后兼容) if (!config_data.button_display.latest_update_date) { config_data.button_display.latest_update_date = "always"; @@ -3594,6 +6096,21 @@ if (!config_data.button_display.today_update_indicator) { config_data.button_display.today_update_indicator = "always"; } + // 确保显示顺序配置存在 + if (!config_data.button_display_order || !Array.isArray(config_data.button_display_order)) { + config_data.button_display_order = [ + "refresh_plex", + "refresh_alist", + "run_task", + "delete_task", + "latest_transfer_file", + "season_counts", + "latest_update_date", + "task_progress", + "show_status", + "today_update_indicator" + ]; + } // 确保文件整理性能配置存在 if (!config_data.file_performance) { config_data.file_performance = { @@ -3616,10 +6133,19 @@ delete config_data.file_performance.large_page_size; delete config_data.file_performance.cache_cleanup_interval; } + // 确保性能设置存在并补默认值(单位:秒) + if (!config_data.performance) { + 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; + } + } this.formData = config_data; setTimeout(() => { this.configModified = false; }, 100); + this.configHasLoaded = true; // 加载任务最新信息(包括记录和文件) this.loadTaskLatestInfo(); @@ -4220,8 +6746,25 @@ }, removeTask(index) { - if (confirm("确定要删除任务 [#" + (index + 1) + ": " + this.formData.tasklist[index].taskname + "] 吗?")) + if (!confirm("确定要删除任务 [#" + (index + 1) + ": " + this.formData.tasklist[index].taskname + "] 吗?")) return; + const task = this.formData.tasklist[index]; + const taskName = task.taskname || task.task_name; + // 一次性任务(skip_calendar)不做日历清理,直接删除并保存 + if (task.skip_calendar === true) { this.formData.tasklist.splice(index, 1); + this.saveConfig(); + return; + } + // 非一次性任务:先请求后端清理(含数据库与海报文件),再删除任务并保存配置 + axios.post('/api/calendar/purge_by_task', { task_name: taskName }) + .then(() => { + this.formData.tasklist.splice(index, 1); + this.saveConfig(); + }) + .catch(() => { + this.formData.tasklist.splice(index, 1); + this.saveConfig(); + }); }, changeShareurl(task) { if (!task.shareurl) @@ -4704,6 +7247,36 @@ if (deleteRecords) { this.loadHistoryRecords(); } + // 仅当删除了记录时,才触发任务/日历刷新(统计依赖记录) + if (deleteRecords && (response.data.deleted_records || 0) > 0) { + (async () => { + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks || []; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + await this.loadTodayUpdatesLocal(); + } catch (e) {} + })(); + } } else { alert('删除失败: ' + response.data.message); } @@ -6196,6 +8769,34 @@ // 清空选择 this.selectedRecords = []; this.lastSelectedRecordIndex = -1; + // 删除历史记录成功后,主动触发任务/日历刷新,确保任务列表的进度与统计热更新 + (async () => { + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks || []; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + await this.loadTodayUpdatesLocal(); + } catch (e) {} + })(); } else { alert(response.data.message || '删除失败'); } @@ -6222,6 +8823,34 @@ // 清空选择 this.selectedRecords = []; this.lastSelectedRecordIndex = -1; + // 批量删除历史记录成功后,触发任务/日历刷新 + (async () => { + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks || []; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + await this.loadTodayUpdatesLocal(); + } catch (e) {} + })(); } else { alert(response.data.message || '删除失败'); } @@ -6245,6 +8874,34 @@ // 清空选择 this.selectedRecords = []; this.lastSelectedRecordIndex = -1; + // 单条删除历史记录成功后,触发任务/日历刷新 + (async () => { + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks || []; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + await this.loadTodayUpdatesLocal(); + } catch (e) {} + })(); } else { alert(response.data.message || '删除失败'); } @@ -6451,6 +9108,36 @@ if (deleteRecords) { this.loadHistoryRecords(); } + // 批量删除:仅当删除了记录时再刷新任务/日历 + if (deleteRecords && totalDeletedRecords > 0) { + (async () => { + try { + const latestRes = await axios.get('/task_latest_info'); + if (latestRes.data && latestRes.data.success) { + const latestFiles = latestRes.data.data.latest_files || {}; + this.taskLatestFiles = latestFiles; + this.taskLatestRecords = latestRes.data.data.latest_records || {}; + this.calendar.progressByTaskName = this.buildProgressByTaskNameFromLatestFiles(latestFiles); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + const tasksResponse = await axios.get('/api/calendar/tasks'); + if (tasksResponse.data && tasksResponse.data.success) { + this.calendar.tasks = tasksResponse.data.data.tasks || []; + this.calendar.taskNames = this.calendar.tasks.map(task => task.task_name); + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + } + } catch (e) {} + try { + await this.loadCalendarEpisodesLocal(); + this.initializeCalendarDates(); + await this.loadTodayUpdatesLocal(); + } catch (e) {} + })(); + } }); } }, @@ -7953,8 +10640,8 @@ } catch (error) { console.warn('无法从图片提取颜色:', error); - // 降级到默认渐变 - return 'radial-gradient(circle, rgba(0,0,0,0.8) 0%, rgba(50,50,50,0.9) 50%, rgba(0,0,0,0.8) 100%)'; + // 降级为统一的深色背景(使用主题变量 --dark-text-color) + return 'var(--dark-text-color)'; } }, handlePosterHover(event, item) { @@ -7963,9 +10650,39 @@ const imgElement = posterElement.querySelector('img'); const overlayElement = posterElement.querySelector('.discovery-poster-overlay'); - if (imgElement && overlayElement && imgElement.complete) { - const gradient = this.createGradientFromImage(imgElement); - overlayElement.style.background = gradient; + if (imgElement && overlayElement) { + if (imgElement.complete) { + const gradient = this.createGradientFromImage(imgElement); + overlayElement.style.background = gradient; + } else { + // 图片未完成加载时,直接使用降级颜色 + overlayElement.style.background = 'var(--dark-text-color)'; + } + } + }, + + // 内容管理页面的海报悬停处理 + handleManagementPosterHover(event, task) { + const posterElement = event.currentTarget; + const overlayElement = posterElement.querySelector('.discovery-poster-overlay'); + + if (overlayElement) { + // 检查任务是否匹配 + const isMatched = task.matched_show_name && task.matched_show_name.trim() !== ''; + + if (isMatched) { + // 匹配的任务:从图片提取颜色 + const imgElement = posterElement.querySelector('img'); + if (imgElement && imgElement.complete) { + const gradient = this.createGradientFromImage(imgElement); + overlayElement.style.background = gradient; + } else { + overlayElement.style.background = 'var(--dark-text-color)'; + } + } else { + // 未匹配的任务:使用固定颜色 + overlayElement.style.background = 'var(--dark-text-color)'; + } } }, handleImageError(event) { @@ -7973,7 +10690,7 @@ event.target.src = '/static/images/no-poster.svg'; }, getProxiedImageUrl(originalUrl) { - // 直接返回原始URL,因为豆瓣移动端API返回的图片URL应该是可以直接访问的 + // 保持直连,发现页加载更快,且不需要取色跨域处理 if (!originalUrl) return '/static/images/no-poster.svg'; return originalUrl; }, @@ -8308,6 +11025,94 @@ isFirstSeason: true }; }, + + // 加载“今日更新”的本地数据,用于指示当日转存的剧集/任务 + async loadTodayUpdatesLocal() { + try { + const res = await axios.get('/api/calendar/today_updates_local'); + if (res.data && res.data.success) { + const items = res.data.data && res.data.data.items ? res.data.data.items : []; + const byTask = {}; + const byShow = {}; + const makeKey = (it) => { + if (it && it.episode_number != null && it.season_number != null) { + const s = String(it.season_number).padStart(2, '0'); + const e = String(it.episode_number).padStart(2, '0'); + return `S${s}E${e}`; + } + if (it && it.air_date) { + return `D:${it.air_date}`; + } + return null; + }; + items.forEach(it => { + if (it && it.task_name) byTask[it.task_name] = true; + const key = makeKey(it); + const sname = (it && it.show_name) ? String(it.show_name).trim() : ''; + if (sname && key) { + if (!byShow[sname]) byShow[sname] = new Set(); + byShow[sname].add(key); + } + }); + // 将 Set 序列化为对象数组以便 Vue 响应式 + const byShowObj = {}; + Object.keys(byShow).forEach(k => { byShowObj[k] = Array.from(byShow[k]); }); + this.calendar.todayUpdatesByTaskName = byTask; + this.calendar.todayUpdatesByShow = byShowObj; + } + } catch (e) { + // 忽略错误,维持上次数据 + } + }, + + // 判断剧集是否属于“今日更新”的已转存集(按剧名 + SxxExx / 日期 匹配) + isEpisodeUpdatedToday(episode) { + try { + if (!episode) return false; + const sname = (episode.show_name || '').trim(); + if (!sname) return false; + const list = this.calendar.todayUpdatesByShow && this.calendar.todayUpdatesByShow[sname]; + if (!list || list.length === 0) return false; + // 拆分模式:逐集匹配 + const makeKey = (ep) => { + if (ep && ep.episode_number != null && ep.season_number != null) { + const s = String(ep.season_number).padStart(2, '0'); + const e = String(ep.episode_number).padStart(2, '0'); + return `S${s}E${e}`; + } + if (ep && ep.air_date) return `D:${ep.air_date}`; + return null; + }; + if (!episode.is_merged) { + const key = makeKey(episode); + if (!key) return false; + return list.includes(key); + } + // 合并模式:任一原始集命中即可 + if (Array.isArray(episode.original_episodes) && episode.original_episodes.length > 0) { + return episode.original_episodes.some(ep => { + const key = makeKey({ + season_number: ep.season_number || episode.season_number, + episode_number: ep.episode_number, + air_date: ep.air_date || episode.air_date + }); + return key && list.includes(key); + }); + } + // 兜底:用合并后的显示信息尝试一次 + const key = makeKey(episode); + return key ? list.includes(key) : false; + } catch (e) { return false; } + }, + + // 判断管理视图中的任务是否有当日更新 + isCalendarTaskUpdatedToday(task) { + try { + const name = task && (task.task_name || task.taskname); + if (!name) return false; + return !!(this.calendar.todayUpdatesByTaskName && this.calendar.todayUpdatesByTaskName[name]); + } catch (e) { return false; } + }, generateMovieSavePath(template, title, year) { // 生成电影保存路径 let savePath = template; @@ -8661,6 +11466,8 @@ // 创建新任务 const newTask = { ...this.createTask.taskData }; + // 一次性任务:跳过日历匹配 + newTask.skip_calendar = true; // 应用全局插件配置 this.applyGlobalPluginConfig(newTask); @@ -8794,9 +11601,24 @@ removeTaskSilently(index) { // 静默删除任务,不显示确认对话框 if (index >= 0 && index < this.formData.tasklist.length) { - this.formData.tasklist.splice(index, 1); - // 保存配置 - this.saveConfig(); + const task = this.formData.tasklist[index]; + const taskName = task.taskname || task.task_name; + // 一次性任务(skip_calendar)不做日历清理,直接删除并保存 + if (task.skip_calendar === true) { + this.formData.tasklist.splice(index, 1); + this.saveConfig(); + return; + } + // 非一次性任务:删除前执行清理 + axios.post('/api/calendar/purge_by_task', { task_name: taskName }) + .then(() => { + this.formData.tasklist.splice(index, 1); + this.saveConfig(); + }) + .catch(() => { + this.formData.tasklist.splice(index, 1); + this.saveConfig(); + }); } }, openCreateTaskDatePicker() { @@ -8804,6 +11626,77 @@ if (this.$refs.createTaskEnddate) { this.$refs.createTaskEnddate.showPicker(); } + }, + + // 添加日历页面窗口大小变化监听器 + addCalendarResizeListener() { + // 移除之前的监听器(如果存在) + if (this.calendarResizeHandler) { + window.removeEventListener('resize', this.calendarResizeHandler); + } + + // 创建新的监听器 + this.calendarResizeHandler = this.debounce(() => { + if (this.activeTab === 'calendar') { + if (this.calendar.viewMode === 'poster' && !this.calendar.manageMode) { + this.updateWeekDates(); + } + // 管理模式下仅触发布局tick,使用相同的列计算逻辑 + if (this.calendar.manageMode) { + this.calendar.layoutTick = Date.now(); + } + } + }, 300); // 300ms防抖 + + // 添加监听器 + window.addEventListener('resize', this.calendarResizeHandler); + }, + + // 防抖函数 + debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + }, + + // 获取追剧日历页面的可用宽度(排除侧边栏) + getCalendarAvailableWidth() { + const windowWidth = window.innerWidth || document.documentElement.clientWidth; + + // 移动设备(小屏幕) + if (windowWidth < 768) { + // 移动设备下侧边栏会折叠,主内容区域占满宽度 + return windowWidth - 20; // 减去左右边距 + } + + // 桌面设备(大屏幕) + let sidebarWidth = 184; // 默认展开状态宽度 + + // 如果侧边栏已折叠 + if (this.sidebarCollapsed) { + sidebarWidth = 54; // 折叠状态宽度 + } + + // 计算主内容区域可用宽度 + const availableWidth = windowWidth - sidebarWidth - 20; // 减去侧边栏宽度和左右边距 + + + + return Math.max(availableWidth, 300); // 确保最小可用宽度为300px + }, + + // 格式化日期为YYYY-MM-DD格式(使用本地时间) + formatDateToYYYYMMDD(date) { + const year = date.getFullYear(); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + return `${year}-${month}-${day}`; } }, mounted() { @@ -8954,6 +11847,11 @@ if (this.activeTab === 'discovery') { this.loadDiscoveryData(); } + + // 如果当前是追剧日历页面,加载日历数据 + if (this.activeTab === 'calendar') { + this.loadCalendarData(); + } }, 500); }, beforeDestroy() { @@ -8961,6 +11859,11 @@ // 移除点击事件监听器 document.removeEventListener('click', this.handleOutsideClick); document.removeEventListener('click', this.handleRenameOutsideClick); + + // 移除日历页面resize监听器 + if (this.calendarResizeHandler) { + window.removeEventListener('resize', this.calendarResizeHandler); + } } }); diff --git a/app/utils/task_extractor.py b/app/utils/task_extractor.py new file mode 100644 index 0000000..d10dc32 --- /dev/null +++ b/app/utils/task_extractor.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +任务信息提取模块 +用于从任务中提取剧名、年份、类型和进度信息 +""" + +import re +import os +from typing import Dict, List, Optional, Tuple +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + +class TaskExtractor: + def __init__(self): + # 剧集编号提取模式 + self.episode_patterns = [ + r'S(\d{1,2})E(\d{1,3})', # S01E01 + r'E(\d{1,3})', # E01 + r'第(\d{1,3})集', # 第1集 + r'第(\d{1,3})期', # 第1期 + r'(\d{1,3})集', # 1集 + r'(\d{1,3})期', # 1期 + ] + + # 日期提取模式 + self.date_patterns = [ + r'(\d{4})-(\d{1,2})-(\d{1,2})', # 2025-01-01 + r'(\d{4})/(\d{1,2})/(\d{1,2})', # 2025/01/01 + r'(\d{4})\.(\d{1,2})\.(\d{1,2})', # 2025.01.01 + ] + + def extract_show_info_from_path(self, save_path: str) -> Dict: + """ + 从保存路径中提取剧名和年份信息 + + Args: + save_path: 任务的保存路径 + + Returns: + 包含剧名和年份的字典 + """ + if not save_path: + return {'show_name': '', 'year': '', 'type': 'other'} + + # 分割路径 + path_parts = save_path.split('/') + + show_name = '' + year = '' + content_type = 'other' + + # 查找包含年份信息的路径部分 + for part in path_parts: + # 匹配年份格式:剧名 (年份)、剧名(年份)、剧名(年份)、剧名 年份 + year_patterns = [ + r'^(.+?)\s*[\((](\d{4})[\))]$', # 剧名 (2025) 或 剧名(2025) + r'^(.+?)\s*(\d{4})$', # 剧名 2025 + r'^(.+?)\s*\((\d{4})\)$', # 剧名(2025) + ] + + for pattern in year_patterns: + match = re.match(pattern, part.strip()) + if match: + show_name = match.group(1).strip() + year = match.group(2) + break + + if show_name and year: + break + + # 如果没有找到年份信息,尝试从任务名称中提取 + if not show_name: + show_name = self.extract_show_name_from_taskname(path_parts[-1] if path_parts else '') + + # 判断内容类型 + content_type = self.determine_content_type(save_path) + + return { + 'show_name': show_name, + 'year': year, + 'type': content_type + } + + def extract_show_name_from_taskname(self, task_name: str) -> str: + """ + 从任务名称中提取纯剧名(去除季信息等) + + Args: + task_name: 任务名称 + + Returns: + 提取的剧名 + """ + if not task_name: + return '' + + # 移除季信息 + season_patterns = [ + r'^(.+?)\s*[Ss](\d{1,2})', # 剧名 S01 + r'^(.+?)\s*第(\d{1,2})季', # 剧名 第1季 + r'^(.+?)\s*Season\s*(\d{1,2})', # 剧名 Season 1 + r'^(.+?)\s*第([一二三四五六七八九十]+)季', # 剧名 第一季 + ] + + for pattern in season_patterns: + match = re.match(pattern, task_name) + if match: + # 提取到季信息前的名称后,进一步清理尾部多余分隔符/空白 + name = match.group(1).strip() + # 去除名称结尾处常见分隔符(空格、横杠、下划线、点、破折号、间隔点、顿号、冒号等) + name = re.sub(r"[\s\-_.—·、::]+$", "", name) + return name + + # 如果没有季信息,直接返回任务名称 + # 未匹配到季信息时,直接清理尾部多余分隔符/空白后返回 + cleaned = task_name.strip() + cleaned = re.sub(r"[\s\-_.—·、::]+$", "", cleaned) + return cleaned + + def determine_content_type(self, save_path: str) -> str: + """ + 根据保存路径判断内容类型 + + Args: + save_path: 保存路径 + + Returns: + 内容类型:tv(剧集)、anime(动画)、variety(综艺)、documentary(纪录片)、other(其他) + """ + if not save_path: + return 'other' + + path_lower = save_path.lower() + + # 根据路径关键词判断类型 + if any(keyword in path_lower for keyword in ['剧集', '电视剧', '电视', '电视节目', '连续剧', '影集', 'tv', 'drama']): + return 'tv' + elif any(keyword in path_lower for keyword in ['动画', '动漫', '动画片', '卡通片', '卡通', 'anime', 'cartoon']): + return 'anime' + elif any(keyword in path_lower for keyword in ['综艺', '真人秀', '综艺节目', '娱乐节目', 'variety', 'show']): + return 'variety' + elif any(keyword in path_lower for keyword in ['纪录片', '记录片', 'documentary', 'doc']): + return 'documentary' + else: + return 'other' + + def extract_progress_from_latest_file(self, latest_file: str) -> Dict: + """ + 从最近转存文件中提取进度信息 + + Args: + latest_file: 最近转存文件信息(如 S02E24 或 2025-08-30) + + Returns: + 包含进度信息的字典 + """ + if not latest_file: + return {'episode_number': None, 'air_date': None, 'progress_type': 'unknown'} + + # 尝试提取集数信息 + for pattern in self.episode_patterns: + match = re.search(pattern, latest_file) + if match: + if 'S' in pattern and 'E' in pattern: + # S01E01 格式 + season = match.group(1) + episode = match.group(2) + return { + 'episode_number': int(episode), + 'season_number': int(season), + 'progress_type': 'episode' + } + else: + # E01 或其他格式 + episode = match.group(1) + return { + 'episode_number': int(episode), + 'season_number': None, + 'progress_type': 'episode' + } + + # 尝试提取日期信息 + for pattern in self.date_patterns: + match = re.search(pattern, latest_file) + if match: + year, month, day = match.groups() + date_str = f"{year}-{month.zfill(2)}-{day.zfill(2)}" + return { + 'episode_number': None, + 'season_number': None, + 'air_date': date_str, + 'progress_type': 'date' + } + + return { + 'episode_number': None, + 'season_number': None, + 'air_date': None, + 'progress_type': 'unknown' + } + + def extract_all_tasks_info(self, tasks: List[Dict], task_latest_files: Dict) -> List[Dict]: + """ + 提取所有任务的信息 + + Args: + tasks: 任务列表 + task_latest_files: 任务最近转存文件信息 + + Returns: + 包含所有任务信息的列表 + """ + logging.debug("TaskExtractor.extract_all_tasks_info 开始") + logging.debug(f"tasks数量: {len(tasks)}") + logging.debug(f"task_latest_files数量: {len(task_latest_files)}") + + tasks_info = [] + + for i, task in enumerate(tasks): + try: + logging.debug(f"处理第{i+1}个任务: {task.get('taskname', '')}") + + task_name = task.get('taskname', '') + save_path = task.get('savepath', '') + latest_file = task_latest_files.get(task_name, '') + + logging.debug(f"task_name: {task_name}") + logging.debug(f"save_path: {save_path}") + logging.debug(f"latest_file: {latest_file}") + + # 提取基本信息 + show_info = self.extract_show_info_from_path(save_path) + logging.debug(f"show_info: {show_info}") + + # 提取进度信息 + progress_info = self.extract_progress_from_latest_file(latest_file) + logging.debug(f"progress_info: {progress_info}") + + # 优先使用任务显式类型(配置或提取出的),否则回退到路径判断 + explicit_type = None + try: + explicit_type = (task.get('calendar_info') or {}).get('extracted', {}).get('content_type') + except Exception: + explicit_type = None + if not explicit_type: + explicit_type = task.get('content_type') + final_type = (explicit_type or show_info['type'] or 'other') + + # 合并信息 + task_info = { + 'task_name': task_name, + 'save_path': save_path, + 'show_name': show_info['show_name'], + 'year': show_info['year'], + 'content_type': final_type, + 'latest_file': latest_file, + 'episode_number': progress_info.get('episode_number'), + 'season_number': progress_info.get('season_number'), + 'air_date': progress_info.get('air_date'), + 'progress_type': progress_info.get('progress_type') + } + + logging.debug(f"task_info: {task_info}") + tasks_info.append(task_info) + + except Exception as e: + logging.debug(f"处理任务 {i+1} 时出错: {e}") + import traceback + traceback.print_exc() + continue + + logging.debug(f"TaskExtractor.extract_all_tasks_info 完成,返回任务数量: {len(tasks_info)}") + return tasks_info + + def get_content_type_display_name(self, content_type: str) -> str: + """ + 获取内容类型的显示名称 + + Args: + content_type: 内容类型代码 + + Returns: + 显示名称 + """ + type_names = { + 'tv': '剧集', + 'anime': '动画', + 'variety': '综艺', + 'documentary': '纪录片', + 'other': '其他' + } + + return type_names.get(content_type, '其他') + + def get_content_types_with_content(self, tasks_info: List[Dict]) -> List[str]: + """ + 获取有内容的任务类型列表 + + Args: + tasks_info: 任务信息列表 + + Returns: + 有内容的类型列表 + """ + types = set() + for task_info in tasks_info: + if task_info['show_name']: # 只统计有剧名的任务 + types.add(task_info['content_type']) + + return sorted(list(types)) diff --git a/quark_auto_save.py b/quark_auto_save.py index 1cb1e25..b3ec69d 100644 --- a/quark_auto_save.py +++ b/quark_auto_save.py @@ -4873,7 +4873,8 @@ def do_save(account, tasklist=[]): else: # 添加基本通知 add_notify(f"✅《{task['taskname']}》新增文件:") - add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") + savepath = task['savepath'] + add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 修正首次运行时对子目录的处理 - 只有在首次运行且有新增的子目录时才显示子目录内容 if has_update_in_root and has_update_in_subdir and is_first_run and len(new_added_dirs) == 0: @@ -5204,7 +5205,8 @@ def do_save(account, tasklist=[]): # 添加成功通知 - 修复问题:确保在有文件时添加通知 if display_files: add_notify(f"✅《{task['taskname']}》新增文件:") - add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") + savepath = task['savepath'] + add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 创建episode_pattern函数用于排序 @@ -5294,7 +5296,8 @@ def do_save(account, tasklist=[]): # 添加成功通知 add_notify(f"✅《{task['taskname']}》新增文件:") - add_notify(f"{re.sub(r'/{2,}', '/', f'/{task['savepath']}')}") + savepath = task['savepath'] + add_notify(f"{re.sub(r'/{2,}', '/', f'/{savepath}')}") # 打印文件列表 for idx, file_name in enumerate(display_files):