From a36c58986b97516448f40ca7aa3cc5c343cd609e Mon Sep 17 00:00:00 2001 From: x1ao4 Date: Fri, 28 Nov 2025 10:33:09 +0800 Subject: [PATCH 01/34] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=85=83=E6=95=B0=E6=8D=AE=E5=90=8E=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E5=88=97=E8=A1=A8=E9=9B=86=E6=95=B0=E4=BF=A1=E6=81=AF?= =?UTF-8?q?=E6=9C=AA=E6=9B=B4=E6=96=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 手动刷新节目的元数据后,追剧日历的集数显示更新了,但任务列表的集数没有更新 - 只有在追剧日历刷新的定时任务刷新后,任务列表的集数数据才正确刷新 原因: - refreshSeasonMetadata 函数刷新成功后,虽然更新了 calendar.tasks,但缺少更新 progressByShowName - 任务列表和管理视图的集数数据依赖于 progressByShowName,因此没有正确更新 修复: - 在 refreshSeasonMetadata 函数中,刷新成功后添加了更新 progressByShowName 的步骤 - 确保任务列表和管理视图的集数数据(已转存集数/已播出集数/节目总集数)能立即正确更新 --- app/templates/index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/templates/index.html b/app/templates/index.html index 25a30e5..ff7d6f2 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -5541,11 +5541,18 @@ 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); + // 重新构建进度映射,确保任务列表和管理视图的集数数据正确更新 + this.calendar.progressByShowName = this.buildProgressByShowNameFromTasks(this.calendar.tasks || [], this.calendar.progressByTaskName); + + // 重新计算内容类型,确保类型按钮能热更新 + const rawTypes = tasksResponse.data.data.content_types || []; + this.updateContentTypes(rawTypes); } } catch (e) {} From 9f2b2c7bfefd92e0f4eb34ea02aece0355faa692 Mon Sep 17 00:00:00 2001 From: x1ao4 Date: Fri, 28 Nov 2025 11:00:46 +0800 Subject: [PATCH 02/34] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AD=A4=E7=AB=8B?= =?UTF-8?q?=E5=86=85=E5=AE=B9=E6=B8=85=E7=90=86=E9=80=BB=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E5=AD=A4=E7=AB=8B=E7=9A=84?= =?UTF-8?q?=20shows=20=E5=92=8C=E6=B5=B7=E6=8A=A5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 cleanup_orphan_data 中添加清理孤立 shows 的逻辑 - 当任务被删除或修改时,自动清理不再被任何任务引用的 shows - 在清理孤立数据后自动清理孤立的海报文件 - 确保数据库和文件系统中的孤立数据都能被及时清理 --- app/run.py | 15 +++++++++++++++ app/sdk/db.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/app/run.py b/app/run.py index 19b0b16..5941b5f 100644 --- a/app/run.py +++ b/app/run.py @@ -1771,6 +1771,11 @@ def update(): try: from app.sdk.db import CalendarDB as _CalDB _CalDB().cleanup_orphan_data(valid_pairs, valid_names) + # 清理孤立数据后,也清理可能残留的孤立海报文件 + try: + cleanup_orphaned_posters() + except Exception: + pass except Exception: pass except Exception as e: @@ -6336,6 +6341,11 @@ def calendar_edit_metadata(): except Exception: pass CalendarDB().cleanup_orphan_data(_valid_pairs, _valid_names) + # 清理孤立数据后,也清理可能残留的孤立海报文件 + try: + cleanup_orphaned_posters() + except Exception: + pass except Exception: pass # 场景二:未提供 new_tmdb_id,但提供了 new_season_number(仅修改季数) @@ -6435,6 +6445,11 @@ def calendar_edit_metadata(): except Exception: pass CalendarDB().cleanup_orphan_data(_valid_pairs, _valid_names) + # 清理孤立数据后,也清理可能残留的孤立海报文件 + try: + cleanup_orphaned_posters() + except Exception: + pass except Exception: pass diff --git a/app/sdk/db.py b/app/sdk/db.py index 3f70771..dc1d86b 100644 --- a/app/sdk/db.py +++ b/app/sdk/db.py @@ -642,7 +642,7 @@ class CalendarDB: result.append(item) return result - # --------- 孤儿数据清理(seasons / episodes / season_metrics / task_metrics) --------- + # --------- 孤儿数据清理(seasons / episodes / season_metrics / task_metrics / shows) --------- @retry_on_locked(max_retries=3, base_delay=0.1) def cleanup_orphan_data(self, valid_task_pairs, valid_task_names): """清理不再与任何任务对应的数据 @@ -655,6 +655,7 @@ class CalendarDB: - task_metrics: 删除 task_name 不在当前任务列表中的记录 - seasons/episodes: 仅保留出现在 valid_task_pairs 内的季与对应所有集;其余删除 - season_metrics: 仅保留出现在 valid_task_pairs 内的记录;其余删除 + - shows: 仅保留出现在 valid_task_pairs 内的 tmdb_id;其余删除(连带删除对应的 seasons/episodes) """ try: cursor = self.conn.cursor() @@ -719,6 +720,47 @@ class CalendarDB: except Exception: pass + # 3) 清理孤立的 shows(仅保留出现在 valid_task_pairs 中的 tmdb_id) + # 从 valid_task_pairs 中提取所有有效的 tmdb_id + valid_tmdb_ids = set() + for tid, sn in pairs: + if tid: + valid_tmdb_ids.add(int(tid)) + + if not valid_tmdb_ids: + # 没有任何有效的 tmdb_id:清空所有 shows + # 注意:episodes 和 seasons 已经在步骤 2 中被清理了 + try: + cursor.execute('DELETE FROM shows') + except Exception: + pass + else: + # 删除不在有效 tmdb_id 列表中的 shows + # 注意:对应的 episodes 和 seasons 在步骤 2 中应该已经被清理了 + # 但为了确保没有残留数据,我们再次清理可能残留的孤立数据 + try: + placeholders = ','.join(['?'] * len(valid_tmdb_ids)) + # 先清理可能残留的孤立 episodes、seasons 和 season_metrics(针对被删除的 shows) + cursor.execute( + f'DELETE FROM episodes WHERE tmdb_id NOT IN ({placeholders})', + list(valid_tmdb_ids) + ) + cursor.execute( + f'DELETE FROM seasons WHERE tmdb_id NOT IN ({placeholders})', + list(valid_tmdb_ids) + ) + cursor.execute( + f'DELETE FROM season_metrics WHERE tmdb_id NOT IN ({placeholders})', + list(valid_tmdb_ids) + ) + # 最后删除孤立的 shows + cursor.execute( + f'DELETE FROM shows WHERE tmdb_id NOT IN ({placeholders})', + list(valid_tmdb_ids) + ) + except Exception: + pass + self.conn.commit() return True except Exception: From e6e1f95a8cac9fc97d8da68354afde8690f9aad3 Mon Sep 17 00:00:00 2001 From: x1ao4 Date: Sat, 29 Nov 2025 21:04:38 +0800 Subject: [PATCH 03/34] =?UTF-8?q?=E5=9C=A8=20WebUI=20=E4=B8=8A=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=E8=BF=90=E8=A1=8C=E6=97=A5=E5=BF=97=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/run.py | 75 ++++++++ app/static/css/main.css | 85 ++++++++- app/templates/index.html | 396 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 551 insertions(+), 5 deletions(-) diff --git a/app/run.py b/app/run.py index 5941b5f..daa7a14 100644 --- a/app/run.py +++ b/app/run.py @@ -18,6 +18,7 @@ from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.date import DateTrigger from queue import Queue +from collections import deque from sdk.cloudsaver import CloudSaver try: from sdk.pansou import PanSou @@ -28,6 +29,7 @@ import subprocess import requests import hashlib import logging +from logging.handlers import RotatingFileHandler import base64 import sys import os @@ -889,6 +891,13 @@ PLUGIN_FLAGS = os.environ.get("PLUGIN_FLAGS", "") DEBUG = os.environ.get("DEBUG", "false").lower() == "true" # 从环境变量获取端口,默认为5005 PORT = int(os.environ.get("PORT", "5005")) +LOG_DIR = os.path.join(parent_dir, "config", "logs") +LOG_FILE_PATH = os.path.join(LOG_DIR, "runtime.log") +MAX_RUNTIME_LOG_LINES = 2000 +RUNTIME_LOG_PATTERN = re.compile( + r'^\[(?P\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]\[(?P[A-Z]+)\]\s?(?P.*)$' +) +os.makedirs(LOG_DIR, exist_ok=True) config_data = {} task_plugins_config_default = {} @@ -1140,6 +1149,63 @@ for _name in ("werkzeug", "apscheduler", "gunicorn.error", "gunicorn.access"): except Exception: pass +# 写入运行日志文件,便于前端实时查看 +try: + _runtime_log_handler = RotatingFileHandler( + LOG_FILE_PATH, + maxBytes=5 * 1024 * 1024, + backupCount=5, + encoding="utf-8" + ) + _runtime_log_handler.setFormatter(_standard_formatter) + _root_logger.addHandler(_runtime_log_handler) +except Exception as e: + logging.warning(f"初始化运行日志文件处理器失败: {e}") + + +def _parse_runtime_log_line(line: str) -> dict: + """解析单行日志文本,提取时间、级别与内容。""" + text = (line or "").rstrip("\n") + entry = { + "raw": text, + "timestamp": "", + "level": "INFO", + "message": text + } + if not text: + return entry + match = RUNTIME_LOG_PATTERN.match(text.strip()) + if match: + entry["timestamp"] = match.group("timestamp") + entry["level"] = match.group("level") + entry["message"] = match.group("message") + return entry + + +def get_recent_runtime_logs(limit: int = 600) -> list: + """获取最近的运行日志行,默认返回 600 条。""" + try: + limit_int = int(limit) + except (ValueError, TypeError): + limit_int = 600 + limit_int = max(100, min(limit_int, MAX_RUNTIME_LOG_LINES)) + if not os.path.exists(LOG_FILE_PATH): + return [] + lines = deque(maxlen=limit_int) + try: + with open(LOG_FILE_PATH, "r", encoding="utf-8", errors="replace") as fp: + for raw_line in fp: + lines.append(raw_line.rstrip("\n")) + except Exception as exc: + logging.warning(f"读取运行日志失败: {exc}") + return [] + logs = [] + for idx, raw_line in enumerate(lines): + entry = _parse_runtime_log_line(raw_line) + entry["id"] = f"{idx}-{hashlib.md5((raw_line + str(idx)).encode('utf-8', 'ignore')).hexdigest()}" + logs.append(entry) + return logs + # --------- 每日任务:在用户设置的刷新时间重算所有季的已播出集数并更新进度 --------- def recompute_all_seasons_aired_daily(): @@ -1915,6 +1981,15 @@ def run_script_now(): ) +@app.route("/api/runtime_logs") +def api_runtime_logs(): + if not is_login(): + return jsonify({"success": False, "message": "未登录"}), 401 + limit = request.args.get("limit", 600) + logs = get_recent_runtime_logs(limit) + return jsonify({"success": True, "logs": logs}) + + # -------------------- 追剧日历:任务提取与匹配辅助 -------------------- def purge_calendar_by_tmdb_id_internal(tmdb_id: int, force: bool = False) -> bool: """内部工具:按 tmdb_id 清理 shows/seasons/episodes,并尝试删除本地海报文件 diff --git a/app/static/css/main.css b/app/static/css/main.css index 85e8db1..2333ff1 100644 --- a/app/static/css/main.css +++ b/app/static/css/main.css @@ -19,7 +19,7 @@ /* --------------- 基础样式 --------------- */ body { font-size: 1rem; - padding-bottom: 110px; + padding-bottom: 15px; color: var(--dark-text-color); } @@ -2394,7 +2394,7 @@ div.jsoneditor-tree button.jsoneditor-button:focus { transition: color 0.2s; /* 添加颜色过渡效果 */ } -/* 侧边栏菜单项图标样式 */ +/* 侧边栏菜导航图标样式 */ .sidebar .nav-link .bi-list-ul { font-size: 1.1rem; position: relative; @@ -2423,8 +2423,12 @@ div.jsoneditor-tree button.jsoneditor-button:focus { font-size: 0.94rem; } +.sidebar .nav-link .bi-terminal { + font-size: 0.95rem; +} + .sidebar .nav-link .bi-power { - font-size: 1.27rem; + font-size: 1.26rem; } .bottom-links .nav-link .bi-book { @@ -6791,6 +6795,12 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil } } +/* 影视发现页面与任务列表海报视图统一底部边距 */ +/* 使用 .discovery-controls 作为标识,因为只有影视发现页面有这个class */ +.discovery-controls ~ * .discovery-grid { + margin-bottom: 0.5px; +} + /* 文件整理页面命名预览模式下的展开状态文本位置调整 - 最高优先级 */ #fileSelectModal[data-modal-type="preview-filemanager"] .table td.col-rename > div[style*="white-space: normal"][style*="word-break: break-word"] { position: relative !important; @@ -7897,7 +7907,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) { /* TMDB 说明文本样式与链接样式(继承颜色、无下划线、悬停不变) */ .tmdb-attribution { margin-top: 4px; - margin-bottom: 4px; + margin-bottom: 2px; color: var(--light-text-color); } .tmdb-attribution a { @@ -8413,6 +8423,71 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) { user-select: text; } +/* 运行日志页面日志行样式:与运行日志弹窗保持一致 */ +.runlog-content .runlog-line { + margin: 0; + padding: 0 0 0 1.5px; /* 桌面端日志行左边距 */ + font-family: monospace; + font-size: 0.85rem; /* 与 #logModal pre 字号一致 */ + line-height: 1.5; /* 行高与弹窗一致 */ + color: var(--dark-text-color); + /* 桌面端:超长内容不换行,直接截断显示 */ + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* 运行日志页面:级别可点击筛选样式 */ +.runlog-content .log-level-clickable { + cursor: pointer; + transition: color 0.2s ease-in-out; + user-select: text; /* 允许选中文本,确保复制时包含级别信息 */ +} + +.runlog-content .log-level-clickable:hover { + color: var(--focus-border-color) !important; +} + +/* --------------- 页面底部元素统一间距 --------------- */ +/* 转存记录和文件整理页面:分页控制区域距离页面底部统一为 20px */ +.pagination-container { + margin-bottom: -90px !important; +} + +/* 日历表格距离页面底部为 20px */ +/* 同时覆盖 padding-bottom,确保样式生效 */ +.calendar-month-mode { + margin-bottom: -86px !important; + padding-bottom: 20px !important; +} + .calendar-filter-row { - margin-bottom: 20px; /* 桌面端保持与下方组件净间距 8px(抵消分类与控制按钮的 -12px 上移) */ + margin-bottom: 20px; +} + +/* --------------- 运行日志页面 --------------- */ +.runlog-content { + min-height: 360px; + max-height: calc(100vh - 146px); + overflow-y: auto; + /* 通过负margin抵消body的padding-bottom(15px),确保日志显示区域底部距离页面底部为20px */ + margin-bottom: -15px !important; + padding: 0 0 20px 0; +} + +@media (max-width: 768px) { + .runlog-content { + max-height: none; + min-height: 240px; + /* 窄屏设备:支持横向滚动,完整显示超长内容 */ + overflow-x: auto; + } + + /* 窄屏设备:日志行允许横向滚动,完整显示内容 */ + .runlog-content .runlog-line { + white-space: nowrap; /* 不换行,保持单行 */ + overflow: unset; /* 移除overflow限制,允许内容溢出到父容器 */ + text-overflow: unset; /* 移除省略号,完整显示 */ + padding-left: 5.5px; /* 窄屏设备日志行左边距 */ + } } \ No newline at end of file diff --git a/app/templates/index.html b/app/templates/index.html index ff7d6f2..d42d3dd 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -389,6 +389,11 @@ 系统配置 +