Compare commits

...

36 Commits

Author SHA1 Message Date
x1ao4
ce8659bb01
Merge pull request #74 from x1ao4/dev
新增运行日志、播出时间相关功能及其他优化
2025-12-24 23:02:17 +08:00
x1ao4
297282f24b 新增执行周期、运行日志和播出时间的功能说明 2025-12-24 22:28:29 +08:00
x1ao4
91de6e2ae3
新增执行周期、运行日志和播出时间的功能说明 2025-12-24 22:25:58 +08:00
x1ao4
f0b5a98cf8 为模态框统一添加取消按钮
- 为 "选择需转存的文件夹"、"选择保存到的文件夹"、"选择起始文件" 模态框添加取消按钮
- 为 "剧集/顺序/正则命名预览" 模态框(创建/编辑任务模式)添加 footer 和取消按钮
- 统一所有取消按钮样式和格式,与编辑任务模态框保持一致
- 确保取消按钮始终位于右下角第一个位置(在 "上一个/下一个" 按钮之前)
- 添加通用CSS样式,确保样式一致性
2025-12-23 17:33:29 +08:00
x1ao4
f7814dd0a3 优化模态框表格展开功能,实现与转存记录页面一致的行级展开行为
- 修改 toggleModalExpand 函数,点击展开按钮时展开同一行的所有超长字段
- 点击收起按钮时收起同一行的所有字段
- 统一展开按钮图标显示逻辑,该行有字段展开时所有按钮显示向上箭头
- 添加事件参数处理,确保代码安全性和一致性
2025-12-23 16:23:42 +08:00
x1ao4
4771f2545d 修复追剧日历月份显示 BUG 并支持跨月自动切换
- 修复 12 月后显示 13/1 的 bug:从日期字符串中正确解析月份,替代错误的月份计算方式
- 点击跨月日期时自动切换月份视图:检测点击日期是否在当前月份,如不在则自动切换到对应月份
- 新增 getDateMonthFromString 方法:从 YYYY-MM-DD 格式日期字符串中提取月份
- 优化 selectCalendarDate 方法:增加跨月检测和自动切换逻辑,提升用户体验
2025-12-23 16:05:53 +08:00
x1ao4
b682017499 在追剧日历页面增加计数模块和日期悬停信息
- 在追剧日历页面右上角排序组件后方增加计数模块,样式与任务列表一致
- 海报/日历视图:显示选中日期当天的节目数和集数(合并集按实际分集数计算)
- 内容管理模式:显示匹配和未匹配任务总数,悬停显示详细匹配情况
- 日历视图日期号数和海报视图日期导航增加悬停信息,显示对应日期的计数信息
2025-12-23 15:54:51 +08:00
x1ao4
b3e525bb9b 统一不可操作元素的鼠标指针样式
将背景色为 --button-gray-background-color 但不可操作的标题和内容元素的鼠标指针从文本指针改为普通指针,提升用户体验和界面一致性。

修改内容:
- CSS样式调整:
  * 任务数量指示器、表头、输入组文本等不可操作元素使用 cursor: default
  * 系统配置页面模块大标题、模态框标题使用普通指针
  * 海报卡片下方的类型/集数信息使用普通指针
  * 保持可点击元素(按钮、链接、图标)的指针样式不变

- HTML模板调整:
  * 为"账号设置"、"Cookie"、"定时规则"三个大标题添加 title 属性
  * 使其与问号图标的悬停提示信息保持一致

影响范围:
- 任务列表页面、影视发现页面、追剧日历页面
- 系统配置页面、所有模态框
- 表格表头、输入组前缀文本等

安全性:
- 仅修改视觉样式,不影响功能逻辑
- 向后兼容,无破坏性变更
2025-12-23 02:14:35 +08:00
x1ao4
05002019fb 修复排序组件悬停指针样式
- 将任务列表和追剧日历页面的排序下拉框(未下拉状态)的悬停指针从手型改为普通指针
- 保持与下拉菜单交互的一致性
2025-12-23 01:31:08 +08:00
x1ao4
3d3e5fb233 为桌面端追剧日历视图增加选中日期功能
- 桌面端日历视图选中日期的号数高亮显示(使用 focus-border-color)
- 默认进入日历视图时当天日期自动选中并高亮
- 支持点击日历单元格选中日期,再次点击已选中日期可取消选择并恢复为当天
- 在日历视图日期切换组件中增加前后一天按钮(与海报视图一致)
- 支持通过前后一天按钮切换选中日期,跨月时自动切换月份视图
- 移动端选中日期功能保持不变(背景色高亮)
2025-12-23 01:13:58 +08:00
x1ao4
0972a60d91 优化追剧日历海报视图的日期导航交互
主要改进:
1. 日期导航项支持点击选择,选中状态使用 var(--focus-border-color) 高亮显示
2. 悬停样式与分类按钮(全部/剧集/动画/综艺)保持一致
3. 日期偏移逻辑优化:以选中的日期为基准进行前后一天/周偏移
4. 智能视图调整:新日期在视图范围内时只更新选中状态,超出范围时自动调整视图

详细修改:

HTML (app/templates/index.html):
- 日期导航项添加 @click 事件和 :class 选中状态绑定
- 新增 selectPosterDate 函数处理海报视图日期选择

CSS (app/static/css/main.css):
- 日期项添加 cursor: pointer 和过渡动画
- 添加悬停样式(:hover:not(.selected))与分类按钮一致
- 添加选中样式(.selected)使用 focus-border-color 高亮
- 移除原有的 .today 固定高亮样式

JavaScript (app/templates/index.html):
- 新增 selectPosterDate 函数:处理海报视图日期点击选择
- 优化 changeCalendarDate 函数:
  * 海报视图模式下以 selectedDate 为基准计算偏移
  * 检查新日期是否在视图范围内,智能决定是否调整视图
  * 添加完善的错误处理和日期验证
- 优化 goToToday 函数:同步更新 selectedDate 为今天

安全性:
- 所有日期解析操作都有 try-catch 保护
- 日期格式验证和有效性检查
- 解析失败时回退到安全的默认行为

兼容性:
- 日历视图模式保持原有逻辑不变
- 不影响其他功能模块
- 桌面端和移动端均正常工作
2025-12-23 00:35:24 +08:00
x1ao4
9ac84e22c7 使用 CSS 变量统一管理模态框和登录模块的圆角
- 在 :root 中添加 --modal-border-radius 变量,默认值为 12px
- 将所有模态框弹窗和登录模块的圆角值替换为 var(--modal-border-radius)
- 现在可以通过修改一个变量来统一调整所有相关圆角
2025-12-22 23:51:40 +08:00
x1ao4
8b2a88897a 移除追剧日历内容管理模式海报悬停信息中节目名称的显示行数限制 2025-12-22 17:20:53 +08:00
x1ao4
62d2b6e739 为海报卡片悬停信息添加行数限制
为多个页面的海报卡片悬停信息添加显示行数限制,超长内容显示省略号,提升界面整洁度和可读性。

修改内容:

1. 任务列表页面海报视图的任务海报卡片悬停信息:
   - 第二行(匹配到的节目名称):限制为1行
   - 第三行(季标题):限制为1行

2. 追剧日历页面海报视图的集海报卡片悬停信息:
   - 第一行(集标题):限制为2行

3. 追剧日历页面内容管理模式的节目海报卡片悬停信息:
   - 第一行(匹配到的节目名称):限制为2行
   - 第三行(季标题):限制为1行

4. 影视发现页面的海报卡片悬停信息:
   - 制片国家/地区:限制为1行
   - 类型:限制为2行
   - 导演:限制为1行(移除之前的数量限制,改为行数限制)
   - 主演:限制为2行

技术实现:

HTML修改:
- 为指定元素添加 info-line-single 或 info-line-double CSS类
- 任务列表、追剧日历页面:直接在HTML元素上添加类名
- 影视发现页面:通过动态类绑定,根据信息类型应用相应样式

CSS修改:
- 新增 .info-line-single 样式:单行限制,使用 white-space: nowrap 和 text-overflow: ellipsis
- 新增 .info-line-double 样式:双行限制,使用 -webkit-line-clamp: 2 实现多行截断
- 样式作用域限定在 .discovery-poster-overlay 和 .calendar-poster-overlay 内

JavaScript修改:
- 修改 getMovieDetails 函数:返回对象数组而非字符串数组
- 每个对象包含 text(文本内容)和 type(信息类型)字段
- 通过 detail.type 判断信息类型,确保即使某些信息缺失也能正确识别

影响范围:
- 仅影响指定位置的海报悬停信息显示
- 不影响其他功能和其他页面的海报悬停信息
- 正确处理边界情况(信息缺失、格式不完整等)
2025-12-21 23:53:52 +08:00
x1ao4
c999913e9e 优化播出状态刷新任务日志,添加集号显示
问题描述:
当同一天有多集播出时,播出状态刷新任务的日志会重复显示相同的节目名和季号,
用户无法区分是哪一集触发的刷新任务。

修改内容:
1. 修改 recompute_show_aired_progress 函数:
   - 添加可选的 season_number 和 episode_number 参数
   - 当提供 episode_number 时,日志中显示集号信息
   - 当提供 season_number 时,只处理指定的季

2. 修改 schedule_airtime_based_refresh_jobs 函数:
   - 在按播出时间安排任务时,传递 season_number 和 episode_number 参数
   - 确保自动运行的任务日志包含集号信息

效果:
- 之前:日志显示 "[节目名 · 第 X 季] 播出状态刷新任务"
- 现在:日志显示 "[节目名 · 第 X 季 · 第 Y 集] 播出状态刷新任务"

兼容性:
- 所有现有调用点保持兼容(新参数为可选参数)
- 补偿刷新、手动刷新等场景不受影响(这些场景应刷新整个节目)

安全性:
- 使用参数化查询,防止 SQL 注入
- 保持原有异常处理逻辑
- Lambda 函数使用默认参数避免闭包问题
2025-12-21 23:02:39 +08:00
x1ao4
e6634a361e 修复服务器休眠唤醒后 SSE 无法自动重连导致已播出状态不更新的问题
问题描述:
- 服务器休眠后唤醒时,SSE 连接断开
- 原代码在 onerror 中立即调用 close(),阻止了 EventSource 的自动重连
- 导致无法接收到唤醒后的补偿运行通知(daily_aired_update)
- 已播出状态需要手动刷新页面才能更新,而已转存状态可以实时更新

解决方案:
1. 修改 onerror 处理逻辑:移除 close() 调用,让 EventSource 自动重连
2. 页面可见性变化时:从隐藏变为可见时主动尝试重连 SSE
3. 切换到追剧日历页面时:如果 SSE 断开,主动尝试重连

技术细节:
- 不在 onerror 中调用 close(),利用 EventSource 的内置自动重连机制
- 保留轮询作为兜底,确保 SSE 断开时功能仍然可用
- 重连成功后,onopen 会触发,自动停止轮询

影响范围:
- 仅影响 SSE 连接的重连机制
- 不影响其他功能(轮询、监听器、任务列表等)
- 已播出状态现在可以像已转存状态一样实时更新
2025-12-21 18:12:16 +08:00
x1ao4
3adcc2cc50 修复文件命名中没有季编号的节目无法显示待播集日期的问题
在追剧日历的内容管理模式下,部分综艺节目(如 “你好,星期六”)由于文件名仅包含日期而不包含季号(Sxx),导致前端无法解析出 season_number。 此前后端未将数据库匹配到的 latest_season_number 返回给前端,导致前端在计算下一集播出时间时因缺少季号而失败。

本次修改在 enrich_tasks_with_calendar_meta 中明确注入了 latest_season_number 字段,确保前端在文件名解析失败时能正确回退使用数据库记录的最新季号。
2025-12-21 01:48:01 +08:00
x1ao4
d1ae3a26dd 在追剧日历页面增加了排序功能并优化了内容管理页面的卡片悬停信息
新增功能
- 在追剧日历页面右上角添加排序控制组件(样式与任务列表页面一致)
- 支持两种排序方式:节目名称、播出时间
- 支持升降序切换,默认按节目名称升序排列
- 排序选项支持持久化到 localStorage

排序逻辑实现
- 日历视图:集卡片支持按节目名称和播出时间排序
- 海报视图:集卡片支持按节目名称和播出时间排序
- 内容管理模式:节目卡片支持按节目名称和播出时间排序
  - 播出时间排序逻辑与任务列表一致(除日期提取方式)
  - 优先按下一个待播集的播出日期排序,其次按播出时间排序
  - 无播出日期但有播出时间的任务按播出时间排序
  - 既无日期也无时间的任务回退到名称排序

UI 优化
- 调整内容管理页面海报悬停信息顺序:
  - 下一个待播集的播出日期和时间显示为倒数第二行
  - 已转存/已播出/总集数显示为最后一行

技术实现
- 添加排序状态管理:calendar.sortBy 和 calendar.sortOrder
- 实现获取下一个待播集播出日期的函数:getTaskNextAirDateForSort
- 实现悬停信息显示函数:getTaskNextAirDateTimeDisplayForCalendar
- 添加排序选项变化监听器,确保视图实时更新
2025-12-20 01:08:23 +08:00
x1ao4
645ef231bc 在任务列表页面新增按播出时间排序功能
- 新增任务列表排序字段 airtime,支持升降序
- 基于 “已转存集数下一集” 的播出日期和本地播出时间作为排序键
- 优先按日期排序,其次按时间,无播出日期的任务统一排在末尾,完全无日期和时间时回退到任务名称排序
- 列表视图和海报视图共用同一排序逻辑,保持显示一致性
2025-12-18 16:00:16 +08:00
x1ao4
12346fe5ec 在追剧日历的集数悬停信息中增加节目级播出时间信息 2025-12-17 18:24:57 +08:00
x1ao4
d19f8f3383 增加已播出但未转存集的显示样式
功能改进:
- 日历视图:为已播出但未转存的集增加浅绿色背景(#e6f7ee)
  - 已转存:浅蓝色背景(#e6f1ff)
  - 已播出未转存:浅绿色背景(#e6f7ee)
  - 未播出未转存:浅灰色背景(#f7f7f9)
- 移动端表格:已播出未转存集显示绿色条(#28a745)
- 海报视图:
  - 已转存集:右上角显示 bi-check2-all 图标
  - 已播出未转存集:右上角显示 bi-check2 图标
  - 未播出未转存集:不显示标识

技术实现:
- 后端:在 /api/calendar/episodes_local 接口中为每集添加 is_aired 字段
  - 使用 is_episode_aired 函数精确计算(考虑播出时间)
  - 实现批量查询优化,将数据库查询从 1000+ 次降到 2-3 次
  - 添加完整的异常处理和回退机制
- 前端:新增 isEpisodeAiredForDisplay 方法判断已播出状态
  - 优先使用后端返回的 is_aired 字段,保证前后端一致
  - 兼容旧版本数据(无 is_aired 时回退到日期比较)
  - 在日历视图和海报视图中应用新的样式逻辑
2025-12-17 17:48:38 +08:00
x1ao4
b56aec6505 优化任务列表海报视图卡片悬停信息的下一集播出日期的获取逻辑 2025-12-16 17:24:53 +08:00
x1ao4
dcc3943187 在任务列表海报视图的卡片悬停信息中增加已转存集数的下一集的播出日期
任务列表海报视图的卡片悬停信息第四行之前只显示节目的本地播出时间,改为显示已转存集数的下一集的本地播出日期和本地播出时间,方便判断和了解下一集的播出情况
2025-12-16 16:45:30 +08:00
x1ao4
06adc79a17 新增剧集播出日期偏移功能
- 支持在编辑元数据模态框内为(本地)播出时间显示和编辑播出日期偏移参数,对于转换时区后的本地播出时间,若产生了日期偏移,将自动补全偏移参数,也支持修改、添加或删除日期偏移参数,删除日期偏移将使用剧集在发行地的原始播出日期作为播出日期进行判定或显示
- 日期偏移会影响剧集的本地播出日期,从而影响剧集已播出集数和任务进度的计算
2025-12-16 15:27:51 +08:00
x1ao4
03d9a0d0b7 修改 QASX Token 的显示样式并优化了部分提示信息 2025-12-14 15:10:52 +08:00
x1ao4
4a2acea19e 完善 Trakt 初次配置逻辑并优化了 API 配置模块
- 新增首次配置 Trakt Client ID 时全量同步播出时间并重算进度逻辑
- 优化系统配置页面:合并 API 配置模块并优化间距和交互体验
2025-12-12 21:29:08 +08:00
x1ao4
07abca763e 新增基于 Trakt 精确播出时间的本地时区已播出统计与显示及相关优化 2025-12-12 18:17:20 +08:00
x1ao4
8f0b079059 把运行日志显示范围的默认值改为 1 天 2025-12-02 21:00:56 +08:00
x1ao4
8c08b00db7 在运行日志页面增加 >>> 与任务名称快速筛选功能
- 为运行日志中的 >>> 增加点击快速内容筛选,重复点击可清除筛选
- 为 “任务名称: xxxx” 中的任务名增加点击快速任务筛选,复用原有任务块筛选逻辑
2025-12-01 20:32:49 +08:00
x1ao4
9269f66cd5 将日志备份文件数量从 5 个改为 2 个 2025-11-30 22:34:53 +08:00
x1ao4
12cf1bb190 新增运行日志显示范围设置选项,支持按时间范围显示日志
- 将日志显示方式从按条数限制改为按时间范围限制(默认显示最近 3 天的日志)
- 在系统配置的性能设置中添加 '运行日志显示范围' 配置项,允许用户自定义天数
- 保持向后兼容:API 仍支持 limit 参数,旧配置自动使用默认值
2025-11-30 21:41:35 +08:00
x1ao4
1045fa1934 优化运行日志页面的加载体验:移除加载动画和提示,改为静默加载
- 移除首次加载和刷新时显示的 Spinner 动画和 '加载日志中...' 提示
- 优化 '暂无匹配的日志' 提示的显示逻辑:仅在加载完成且确实无匹配日志时显示
- 加载过程中保持静默,不显示任何提示信息
- 保持日志筛选、轮询、页面切换等所有功能正常工作
2025-11-30 20:45:01 +08:00
x1ao4
b8e278fadb 修复运行日志页面筛选器清空后滚动位置异常的问题
为级别筛选和内容筛选添加滚动位置保存和恢复功能,确保清空筛选后始终回到筛选前的位置,无论筛选是否有结果,与任务筛选保持一致的交互体验
2025-11-29 21:55:14 +08:00
x1ao4
e6e1f95a8c 在 WebUI 上增加了运行日志查看页面 2025-11-29 21:04:38 +08:00
x1ao4
9f2b2c7bfe 完善孤立内容清理逻辑,自动清理孤立的 shows 和海报文件
- 在 cleanup_orphan_data 中添加清理孤立 shows 的逻辑
- 当任务被删除或修改时,自动清理不再被任何任务引用的 shows
- 在清理孤立数据后自动清理孤立的海报文件
- 确保数据库和文件系统中的孤立数据都能被及时清理
2025-11-28 11:00:46 +08:00
x1ao4
a36c58986b 修复手动刷新元数据后任务列表集数信息未更新的问题
问题:
- 手动刷新节目的元数据后,追剧日历的集数显示更新了,但任务列表的集数没有更新
- 只有在追剧日历刷新的定时任务刷新后,任务列表的集数数据才正确刷新

原因:
- refreshSeasonMetadata 函数刷新成功后,虽然更新了 calendar.tasks,但缺少更新 progressByShowName
- 任务列表和管理视图的集数数据依赖于 progressByShowName,因此没有正确更新

修复:
- 在 refreshSeasonMetadata 函数中,刷新成功后添加了更新 progressByShowName 的步骤
- 确保任务列表和管理视图的集数数据(已转存集数/已播出集数/节目总集数)能立即正确更新
2025-11-28 10:33:09 +08:00
8 changed files with 4822 additions and 515 deletions

View File

@ -21,6 +21,9 @@
- **追剧日历**:支持在追剧日历页面查看和浏览任务对应电视节目的信息和播出时间表,追踪电视节目的播出情况,了解任务的完成进度。追剧日历支持海报视图和日历视图两种视图,可方便快捷的了解订阅内容的实时状态。
- **字幕命名规则**:支持在全局设置字幕文件的语言代码后缀,在重命名字幕文件时自动添加语言代码后缀,如 `.zh.srt`、`.zh.ass` 等。
- **状态筛选**:支持在任务列表、转存记录、追剧日历页面按照任务状态(进度状态或对应节目状态)筛选任务(记录或节目),便于筛除次要信息。
- **执行周期**:支持按自选周期执行(自选)和按任务进度执行(自动)两种执行周期判断模式,按任务进度执行将自动跳过进度达到 100% 的任务,也就是说如果已播出的最新集已完成转存,后续的定时任务将自动跳过该任务,如果已播出的集还有没转存的,那么这个任务会在每次的定时时间都被执行,直到进度到达 100%。如此可有效减少播出前和转存后的无意义执行。
- **运行日志**:支持在 WebUI 上直接查看容器的运行日志,允许进行筛选和搜索。
- **播出时间**:支持通过 Trakt API 自动获取匹配节目的准确播出时间,并转换为本地时间,也支持自定义播出时间和播出日期偏移,用于准确体现节目的播出进度和任务的完成进度,也可以用于设置任务的延迟运行。并支持在海报卡片和追剧日历上查看单集的准确播出状态(时间)和下一集的准确播出日期及时间。
本项目修改后的版本为个人需求定制版,目的是满足我自己的使用需求,某些(我不用的)功能可能会因为修改而出现 BUG不一定会被修复。若你要使用本项目请知晓本人不是程序员我无法保证本项目的稳定性如果你在使用过程中发现了 BUG可以在 Issues 中提交,但不保证每个 BUG 都能被修复,请谨慎使用,风险自担。
@ -54,7 +57,7 @@
- 任务管理
- [x] 支持多组任务
- [x] 任务结束期限,期限后不执行此任务
- [x] 可单独指定子任务星期几执行
- [x] **支持按自选周期执行(自选)和按任务进度执行(自动)两种执行周期判断模式(支持混用)**
- [x] **支持通过任务名称跳转 TMDB、豆瓣相关页面**
- [x] **支持通过影视发现页面浏览豆瓣热门影视榜单、快速创建任务(支持智能填充任务配置)**
@ -68,7 +71,7 @@
- [x] 支持多个通知推送渠道 <sup>[?](https://github.com/x1ao4/quark-auto-save-x/wiki/通知设置)</sup>
- [x] 支持多账号(多账号签到、**文件管理**,仅首账号转存)
- [x] 支持网盘文件下载、strm 文件生成等功能 <sup>[?](https://github.com/x1ao4/quark-auto-save-x/wiki/插件设置)</sup>
- [x] **支持通过追剧日历功能了解订阅内容的播出情况**
- [x] **支持通过追剧日历功能了解订阅内容的播出情况和对应文件的转存情况**
## 部署
### Docker 部署
@ -110,6 +113,7 @@ services:
| `WEBUI_PASSWORD` | 密码 |
| `PLUGIN_FLAGS` | 插件标志,如使用 `-emby,-aria2` 来禁用某些插件 |
| `PORT` | 端口Host 模式可使用此变量更换端口 |
| `DEBUG` | 调试模式开关,可使用 `true``false` 来开启或关闭,调试模式将输出更详细的日志信息 |
### 青龙部署
程序也支持以青龙定时任务的方式运行,但该方式无法使用 WebUI 管理任务,需手动修改配置文件。

2178
app/run.py

File diff suppressed because it is too large Load Diff

View File

@ -361,15 +361,37 @@ class CalendarDB:
)
''')
# 检查 content_type 字段是否存在,如果不存在则添加
# 检查 shows 表的新增字段(兼容旧版本)
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')
# 检查 is_custom_poster 字段是否存在,如果不存在则添加
columns.append('content_type')
# 自定义海报标记
if 'is_custom_poster' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN is_custom_poster INTEGER DEFAULT 0')
columns.append('is_custom_poster')
# 本地播出时间Trakt + 时区转换后的节目级播出时间,格式 HH:MM
if 'local_air_time' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN local_air_time TEXT')
columns.append('local_air_time')
# 节目级原始播出时间Trakt airs.time源时区下的 HH:MM
if 'air_time_source' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN air_time_source TEXT')
columns.append('air_time_source')
# 节目级播出地时区Trakt airs.timezone例如 America/New_York
if 'air_timezone' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN air_timezone TEXT')
columns.append('air_timezone')
# 日期偏移air_date 转换为 air_date_local 时的日期偏移天数,+1表示延后一天-1表示提前一天0表示无偏移
if 'air_date_offset' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN air_date_offset INTEGER DEFAULT 0')
columns.append('air_date_offset')
# 标记 air_date_offset 是否由用户手动设置(通过 WebUI 编辑元数据页面)
if 'air_date_offset_manually_set' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN air_date_offset_manually_set INTEGER DEFAULT 0')
columns.append('air_date_offset_manually_set')
# seasons
cursor.execute('''
@ -400,6 +422,21 @@ class CalendarDB:
UNIQUE (tmdb_id, season_number, episode_number)
)
''')
# 迁移:为 episodes 表新增 Trakt 播出时间相关字段(兼容旧版本)
try:
cursor.execute('PRAGMA table_info(episodes)')
ep_columns = [column[1] for column in cursor.fetchall()]
if 'air_datetime_utc' not in ep_columns:
cursor.execute('ALTER TABLE episodes ADD COLUMN air_datetime_utc TEXT')
ep_columns.append('air_datetime_utc')
if 'air_datetime_local' not in ep_columns:
cursor.execute('ALTER TABLE episodes ADD COLUMN air_datetime_local TEXT')
ep_columns.append('air_datetime_local')
if 'air_date_local' not in ep_columns:
cursor.execute('ALTER TABLE episodes ADD COLUMN air_date_local TEXT')
except Exception:
# 迁移失败不影响主流程,后续逻辑会根据列是否存在做兼容处理
pass
# season_metrics缓存每季的三项计数
cursor.execute('''
@ -599,11 +636,22 @@ class CalendarDB:
''', (tmdb_id, season_number, episode_number, name, overview, air_date, runtime, ep_type, updated_at))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_episode_air_date_local(self, tmdb_id: int, season_number: int, episode_number: int, air_date_local: str):
"""更新单集的本地播出日期"""
cursor = self.conn.cursor()
cursor.execute('''
UPDATE episodes
SET air_date_local = ?
WHERE tmdb_id = ? AND season_number = ? AND episode_number = ?
''', (air_date_local, tmdb_id, season_number, episode_number))
self.conn.commit()
@retry_on_locked(max_retries=3, base_delay=0.1)
def list_latest_season_episodes(self, tmdb_id:int, latest_season:int):
cursor = self.conn.cursor()
cursor.execute('''
SELECT episode_number, name, overview, air_date, runtime, type
SELECT episode_number, name, overview, air_date, runtime, type, air_date_local
FROM episodes
WHERE tmdb_id=? AND season_number=?
ORDER BY episode_number ASC
@ -617,6 +665,7 @@ class CalendarDB:
'air_date': r[3],
'runtime': r[4],
'type': r[5],
'air_date_local': r[6] if len(r) > 6 else None,
} for r in rows
]
@ -642,7 +691,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 +704,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 +769,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:
@ -742,6 +833,214 @@ class CalendarDB:
row = cursor.fetchone()
return row[0] if row else None
# 节目本地播出时间管理(基于 Trakt 节目级 aired_time + 时区转换)
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_show_local_air_time(self, tmdb_id: int, local_air_time: str):
"""
更新节目在本地时区的统一播出时间格式HH:MM
若传入空字符串或 None则清空该字段回退到全局播出集数刷新时间
"""
cursor = self.conn.cursor()
value = (local_air_time or '').strip()
cursor.execute('UPDATE shows SET local_air_time=? WHERE tmdb_id=?', (value, tmdb_id))
self.conn.commit()
return cursor.rowcount > 0
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_show_local_air_time(self, tmdb_id: int):
"""获取节目在本地时区的统一播出时间HH:MM无则返回 None。"""
cursor = self.conn.cursor()
try:
cursor.execute('SELECT local_air_time FROM shows WHERE tmdb_id=?', (tmdb_id,))
except Exception:
# 旧版本可能没有该字段,直接返回 None
return None
row = cursor.fetchone()
if not row:
return None
value = row[0]
if value is None:
return None
value = str(value).strip()
return value or None
@retry_on_locked(max_retries=3, base_delay=0.1)
def update_show_air_schedule(self, tmdb_id: int, local_air_time=None, air_time_source=None, air_timezone=None, air_date_offset=None, air_date_offset_manually_set=None):
"""
批量更新节目的播出时间相关字段
- local_air_time: 本地时区统一播出时间HH:MM
- air_time_source: 源时区播出时间Trakt airs.timeHH:MM
- air_timezone: 源时区名称Trakt airs.timezone例如 America/New_York
- air_date_offset: 日期偏移天数+1表示延后一天-1表示提前一天0表示无偏移
- air_date_offset_manually_set: 标记 air_date_offset 是否由用户手动设置1表示手动设置0表示自动计算
传入 None 表示不更新该字段传入空字符串表示清空
"""
fields = []
params = []
if local_air_time is not None:
fields.append("local_air_time=?")
params.append((local_air_time or "").strip())
if air_time_source is not None:
fields.append("air_time_source=?")
params.append((air_time_source or "").strip())
if air_timezone is not None:
fields.append("air_timezone=?")
params.append((air_timezone or "").strip())
if air_date_offset is not None:
fields.append("air_date_offset=?")
params.append(int(air_date_offset) if air_date_offset is not None else 0)
if air_date_offset_manually_set is not None:
fields.append("air_date_offset_manually_set=?")
params.append(1 if air_date_offset_manually_set else 0)
if not fields:
return False
cursor = self.conn.cursor()
params.append(tmdb_id)
sql = f'UPDATE shows SET {", ".join(fields)} WHERE tmdb_id=?'
cursor.execute(sql, params)
rowcount = cursor.rowcount
self.conn.commit()
return rowcount > 0
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_show_air_schedule(self, tmdb_id: int):
"""
获取节目播出时间相关配置
- local_air_time: 本地统一播出时间HH:MM
- air_time_source: 源时区播出时间HH:MM
- air_timezone: 源时区名称
- air_date_offset: 日期偏移天数+1表示延后一天-1表示提前一天0表示无偏移
如果发现没有 air_date_offset 但有时区信息会自动从已有集数据计算并补上偏移值
"""
cursor = self.conn.cursor()
try:
cursor.execute(
'SELECT local_air_time, air_time_source, air_timezone, air_date_offset, air_date_offset_manually_set FROM shows WHERE tmdb_id=?',
(tmdb_id,),
)
except Exception:
# 兼容旧版本数据库(可能没有 air_date_offset 或 air_date_offset_manually_set 字段)
try:
# 先尝试查询包含 air_date_offset 但不包含 air_date_offset_manually_set 的情况
try:
cursor.execute(
'SELECT local_air_time, air_time_source, air_timezone, air_date_offset FROM shows WHERE tmdb_id=?',
(tmdb_id,),
)
row = cursor.fetchone()
if not row:
return None
air_time_source = (row[1] or "").strip() if row[1] is not None else ""
air_timezone = (row[2] or "").strip() if row[2] is not None else ""
air_date_offset = int(row[3]) if row[3] is not None else 0
air_date_offset_manually_set = 0 # 旧数据默认为未手动设置
return {
"local_air_time": (row[0] or "").strip() if row[0] is not None else "",
"air_time_source": air_time_source,
"air_timezone": air_timezone,
"air_date_offset": air_date_offset,
"air_date_offset_manually_set": bool(air_date_offset_manually_set),
}
except Exception:
# 如果连 air_date_offset 字段都没有,使用最旧的查询方式
cursor.execute(
'SELECT local_air_time, air_time_source, air_timezone FROM shows WHERE tmdb_id=?',
(tmdb_id,),
)
row = cursor.fetchone()
if not row:
return None
air_time_source = (row[1] or "").strip() if row[1] is not None else ""
air_timezone = (row[2] or "").strip() if row[2] is not None else ""
air_date_offset = 0
air_date_offset_manually_set = 0
return {
"local_air_time": (row[0] or "").strip() if row[0] is not None else "",
"air_time_source": air_time_source,
"air_timezone": air_timezone,
"air_date_offset": air_date_offset,
"air_date_offset_manually_set": bool(air_date_offset_manually_set),
}
except Exception:
return None
row = cursor.fetchone()
if not row:
return None
air_time_source = (row[1] or "").strip() if row[1] is not None else ""
air_timezone = (row[2] or "").strip() if row[2] is not None else ""
air_date_offset = int(row[3]) if row[3] is not None else 0
air_date_offset_manually_set = int(row[4]) if row[4] is not None else 0
# 重要:不要自动计算并覆盖偏移值!
# 如果用户手动设置了偏移值即使为0应该保持原值
# 只有在确实没有偏移值NULL且有时区信息时才尝试自动计算
# 但这里我们不再自动计算,因为可能会覆盖用户手动设置的值
# 如果需要自动计算,应该在初始同步时进行,而不是在读取时
result = {
"local_air_time": (row[0] or "").strip() if row[0] is not None else "",
"air_time_source": air_time_source,
"air_timezone": air_timezone,
"air_date_offset": air_date_offset,
"air_date_offset_manually_set": bool(air_date_offset_manually_set),
}
return result
def _calculate_date_offset_from_existing_episodes(self, tmdb_id: int) -> int:
"""
从已有集数据计算日期偏移用于自动补上旧数据的偏移值
"""
try:
cursor = self.conn.cursor()
# 获取最新季的前几集,比较 air_date 和 air_date_local
cursor.execute(
"""
SELECT e.air_date, e.air_date_local
FROM episodes e
INNER JOIN shows s ON e.tmdb_id = s.tmdb_id
WHERE e.tmdb_id=? AND e.season_number=?
AND e.air_date IS NOT NULL AND e.air_date != ''
AND e.air_date_local IS NOT NULL AND e.air_date_local != ''
ORDER BY e.episode_number ASC
LIMIT 5
""",
(int(tmdb_id), self.get_show(int(tmdb_id)).get('latest_season_number') or 1)
)
rows = cursor.fetchall()
if not rows:
return 0
# 计算每集的日期差异,取最常见的偏移值
offsets = []
from datetime import datetime as _dt
for air_date, air_date_local in rows:
try:
date_orig = _dt.strptime(str(air_date), "%Y-%m-%d").date()
date_local = _dt.strptime(str(air_date_local), "%Y-%m-%d").date()
offset = (date_local - date_orig).days
offsets.append(offset)
except Exception:
continue
if not offsets:
return 0
# 返回最常见的偏移值(如果所有集的偏移都相同,则使用该值)
if len(set(offsets)) == 1:
return offsets[0]
# 如果偏移不一致,返回最常见的偏移值
from collections import Counter
most_common = Counter(offsets).most_common(1)
if most_common:
return most_common[0][0]
return 0
except Exception:
return 0
@retry_on_locked(max_retries=3, base_delay=0.1)
def get_shows_by_content_type(self, content_type:str):
"""根据内容类型获取节目列表"""

View File

@ -128,6 +128,20 @@ class TMDBService:
return result['results'][0]
return None
def search_tv_show_all(self, query: str, year: str = None) -> List[Dict]:
"""搜索电视剧,返回所有搜索结果"""
params = {
'query': query,
}
# 如果提供了年份,添加到参数中
if year:
params['first_air_date_year'] = year
result = self._make_request('/search/tv', params)
if result and result.get('results'):
return result['results']
return []
def get_tv_show_details(self, tv_id: int) -> Optional[Dict]:
"""获取电视剧详细信息"""
return self._make_request(f'/tv/{tv_id}')

189
app/sdk/trakt_service.py Normal file
View File

@ -0,0 +1,189 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Trakt 服务模块
基于 TMDB ID 映射到 Trakt 节目并使用节目级 aired_time + timezone 获取精确播出时间
再转换为本地时区的统一播出时间格式HH:MM
"""
import logging
from datetime import datetime, date, time as dtime
from typing import Optional, Dict, Any
import requests
try:
# Python 3.9+ 标准库时区支持
from zoneinfo import ZoneInfo
except Exception: # pragma: no cover - 兼容极老环境
ZoneInfo = None # type: ignore
logger = logging.getLogger(__name__)
class TraktService:
"""
Trakt API 轻量封装
- 通过 TMDB ID 查找 Trakt 节目
- 获取节目级播出时间 aired_time + timezone
- 将播出地时区的 daily airtime 转换为本地时区 HH:MM
"""
def __init__(self, client_id: Optional[str] = None, base_url: str = "https://api.trakt.tv"):
self.client_id = (client_id or "").strip()
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
# 统一请求头
self.session.headers.update(
{
"Content-Type": "application/json",
"trakt-api-version": "2",
"trakt-api-key": self.client_id or "",
}
)
def is_configured(self) -> bool:
"""检查 Trakt 是否已配置有效的 Client ID。"""
return bool(self.client_id)
# ----------------- HTTP 基础封装 -----------------
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
"""发起 GET 请求,失败时记录日志并返回 None不抛出到上层。"""
if not self.is_configured():
return None
url = f"{self.base_url}{path}"
try:
resp = self.session.get(url, params=params or {}, timeout=10)
resp.raise_for_status()
return resp.json()
except Exception as e:
logger.warning(f"Trakt GET 请求失败: {url}, err={e}")
return None
# ----------------- 节目级信息 -----------------
def get_show_by_tmdb_id(self, tmdb_id: int) -> Optional[Dict[str, Any]]:
"""
通过 TMDB ID 查找 Trakt 节目
返回结构示例
{
"trakt_id": 195268,
"slug": "it-welcome-to-derry",
"title": "IT: Welcome to Derry",
"year": 2025
}
"""
try:
if not self.is_configured():
return None
if not tmdb_id:
return None
path = f"/search/tmdb/{int(tmdb_id)}"
data = self._get(path, params={"type": "show"})
if not data:
return None
# Trakt 搜索返回列表,取第一个 show 结果
item = None
for entry in data:
if entry.get("type") == "show" and entry.get("show"):
item = entry.get("show") or {}
break
if not item:
return None
return {
"trakt_id": item.get("ids", {}).get("trakt"),
"slug": item.get("ids", {}).get("slug"),
"title": item.get("title") or "",
"year": item.get("year"),
}
except Exception as e:
logger.warning(f"通过 TMDB ID 获取 Trakt 节目失败: tmdb_id={tmdb_id}, err={e}")
return None
def get_show_airtime(self, trakt_show_id: Any) -> Optional[Dict[str, str]]:
"""
获取节目级播出时间信息aired_time + timezone
Trakt 节目详情通常包含:
{
"airs": {
"day": "sunday",
"time": "21:00",
"timezone": "America/New_York"
},
...
}
"""
try:
if not self.is_configured():
return None
if not trakt_show_id:
return None
path = f"/shows/{trakt_show_id}"
data = self._get(path, params={"extended": "full"})
if not data:
return None
airs = data.get("airs") or {}
aired_time = (airs.get("time") or "").strip()
timezone = (airs.get("timezone") or "").strip()
if not aired_time or not timezone:
return None
return {"aired_time": aired_time, "timezone": timezone}
except Exception as e:
logger.warning(f"获取 Trakt 节目播出时间失败: trakt_show_id={trakt_show_id}, err={e}")
return None
# ----------------- 时区转换 -----------------
def convert_show_airtime_to_local(
self, aired_time: str, source_tz: str, local_tz: str
) -> Optional[str]:
"""
将播出地时区的 daily airtime 转换为本地时区的 HH:MM
参数:
aired_time: 播出地时间字符串形式 '21:00'
source_tz: 播出地时区 'America/New_York'
local_tz: 本地时区 'Asia/Shanghai'
返回:
本地时区 HH:MM 字符串失败时返回 None
"""
try:
aired_time = (aired_time or "").strip()
source_tz = (source_tz or "").strip()
local_tz = (local_tz or "").strip()
if not aired_time or not source_tz or not local_tz:
return None
if ZoneInfo is None:
# 环境不支持 zoneinfo 时,退化为直接返回原始播出时间
return aired_time
# 解析 HH:MM
try:
hh, mm = [int(x) for x in aired_time.split(":")]
except Exception:
return None
# 使用任意日期承载“每日播出时间”含义,这里选择今天
today = date.today()
naive_dt = datetime.combine(today, dtime(hour=hh, minute=mm))
try:
src_zone = ZoneInfo(source_tz)
dst_zone = ZoneInfo(local_tz)
except Exception:
# 时区字符串非法时,保守返回原 time
return aired_time
src_dt = naive_dt.replace(tzinfo=src_zone)
local_dt = src_dt.astimezone(dst_zone)
return local_dt.strftime("%H:%M")
except Exception as e:
logger.warning(
f"转换节目播出时间到本地时区失败: aired_time={aired_time}, source_tz={source_tz}, local_tz={local_tz}, err={e}"
)
return None
# 方便其它模块直接导入一个全局实例时再按需注入 client_id
trakt_service: Optional[TraktService] = None

View File

@ -14,12 +14,13 @@
--focus-border-color: #0D53FF; /* 输入框聚焦时的边框颜色 */
--shadow-spread: 0; /* 统一阴影扩散距离设为0 */
--button-gray-background-color: #ededf0; /* 按钮灰色背景颜色 */
--modal-border-radius: 12px; /* 模态框弹窗和登录模块的统一圆角 */
}
/* --------------- 基础样式 --------------- */
body {
font-size: 1rem;
padding-bottom: 110px;
padding-bottom: 15px;
color: var(--dark-text-color);
}
@ -188,8 +189,13 @@ main .row.title {
margin-left: -15px; /* 添加负边距向左移动2px */
}
/* 精准调整QASX API 模块与上方模块的垂直间距减小 8px */
main .row.title[title^="QASX API"] {
/* 精准调整:性能设置模块与上方模块的垂直间距减小 8px */
main .row.title[title*="配置文件加载、数据缓存和自动刷新的关键参数"] {
margin-top: 12px;
}
/* 精准调整API 模块与上方模块的垂直间距减小 8px */
main .row.title[title*="配置与任务处理、节目元数据获取及外部服务交互相关的API访问凭证"] {
margin-top: 12px;
}
@ -202,6 +208,7 @@ main .row.title h2 {
margin-top: 0;
line-height: 1.5;
padding-left: 0px; /* 标题文字左内边距 */
cursor: default; /* 不可操作的模块大标题使用普通指针 */
}
/* 标题旁边的问号图标容器样式 */
@ -1104,6 +1111,7 @@ select.form-control {
display: flex;
align-items: center;
justify-content: center;
cursor: pointer; /* 可点击的图标按钮使用指针 */
}
.input-group-text:has(.bi-google):hover,
@ -1166,6 +1174,7 @@ textarea.form-control {
border-color: var(--border-color) !important; /* 使用变量替代硬编码颜色 */
background-color: #ededf0!important; /* 修改背景色为更浅的灰色 */
border-width: 1px !important; /* 确保边框宽度为1px */
cursor: default; /* 不可操作的配置选项标题使用普通指针 */
}
.input-group-prepend .input-group-text {
@ -1253,6 +1262,7 @@ table.table thead th {
height: 40px !important; /* 确保表头高度为40px */
line-height: 24px !important; /* 设置行高以确保文字垂直居中 */
box-sizing: border-box !important; /* 确保边框包含在总高度内 */
cursor: default; /* 不可操作的表头使用普通指针 */
}
/* 表头悬停样式 */
@ -1457,7 +1467,7 @@ button.close:focus,
}
#logModal .modal-content {
border-radius: 6px;
border-radius: var(--modal-border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1);
}
@ -1467,8 +1477,8 @@ button.close:focus,
background-color: #fff;
border-bottom: 1px solid var(--border-color);
padding: 11px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: var(--modal-border-radius);
border-top-right-radius: var(--modal-border-radius);
}
#logModal .modal-title {
@ -1477,6 +1487,7 @@ button.close:focus,
color: var(--dark-text-color);
display: flex;
align-items: center;
cursor: default; /* 不可操作的模态框标题使用普通指针 */
}
#logModal .modal-title b {
@ -1546,7 +1557,7 @@ button.close:focus,
#fileSelectModal .modal-content,
#createTaskModal .modal-content,
#editMetadataModal .modal-content {
border-radius: 6px;
border-radius: var(--modal-border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1);
}
@ -1558,8 +1569,8 @@ button.close:focus,
background-color: #fff;
border-bottom: 1px solid var(--border-color);
padding: 11px 16px;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
border-top-left-radius: var(--modal-border-radius);
border-top-right-radius: var(--modal-border-radius);
}
#fileSelectModal .modal-title,
@ -1570,6 +1581,7 @@ button.close:focus,
color: var(--dark-text-color);
display: flex;
align-items: center;
cursor: default; /* 不可操作的模态框标题使用普通指针 */
}
#fileSelectModal .modal-title b,
@ -1663,6 +1675,7 @@ button.close:focus,
/* 编辑元数据模态框:输入前标题灰底,统一输入高度与字体,与创建任务保持一致 */
#editMetadataModal .input-group-prepend .input-group-text {
background-color: var(--button-gray-background-color) !important;
cursor: default; /* 不可操作的前缀文本使用普通指针 */
}
#editMetadataModal .form-control,
#editMetadataModal .input-group-text,
@ -1839,6 +1852,7 @@ button.close:focus,
z-index: 5;
vertical-align: middle; /* 添加:垂直居中对齐 */
height: 40px !important; /* 添加:自动高度,确保与内容一致 */
cursor: default; /* 不可操作的表头使用普通指针 */
}
/* 模态框表格列宽设置 - 基于内容类型 */
@ -2253,7 +2267,7 @@ div.jsoneditor-tree button.jsoneditor-button:focus {
.login-card {
width: 340px;
background-color: white;
border-radius: 10px;
border-radius: var(--modal-border-radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
padding: 0;
text-align: center;
@ -2394,7 +2408,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 +2437,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 {
@ -2724,6 +2742,11 @@ body {
color: var(--dark-text-color);
}
/* 所有模态框标题使用普通指针 */
.modal-title {
cursor: default; /* 不可操作的模态框标题使用普通指针 */
}
/* 设置输入框占位符的颜色 */
.form-control::placeholder {
color: var(--light-text-color); /* 修改占位符颜色 */
@ -4913,6 +4936,17 @@ select.task-filter-select,
max-width: 80%;
}
#batchRenameModal .modal-content {
border-radius: var(--modal-border-radius);
border: 1px solid var(--border-color);
box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1);
}
#batchRenameModal .modal-header {
border-top-left-radius: var(--modal-border-radius);
border-top-right-radius: var(--modal-border-radius);
}
#batchRenameModal .table {
margin-bottom: 0;
}
@ -5227,6 +5261,7 @@ table.selectable-files th {
height: 40px !important; /* 确保表头高度为40px */
line-height: 24px !important; /* 设置行高以确保文字垂直居中 */
box-sizing: border-box !important; /* 确保边框包含在总高度内 */
cursor: default; /* 不可操作的表头使用普通指针 */
}
/* 文件整理页面表格单元格样式,与转存记录页面保持一致 */
@ -5911,6 +5946,19 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
color: var(--dark-text-color) !important;
}
/* 文件选择模态框的取消按钮样式(通用样式,覆盖所有情况) */
#fileSelectModal .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;
}
#fileSelectModal .modal-footer .btn-cancel:hover {
background-color: #e0e2e6 !important;
border-color: #e0e2e6 !important;
color: var(--dark-text-color) !important;
}
/* --------------- 模态框层级管理 --------------- */
/* 当从创建任务模态框中打开文件选择模态框时,确保文件选择模态框显示在上层 */
#createTaskModal.show ~ #fileSelectModal {
@ -6007,6 +6055,7 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
justify-content: center;
padding: 0 8px;
transition: all 0.2s ease;
cursor: pointer; /* 可点击的按钮使用指针 */
}
#createTaskModal .input-group-text:hover {
@ -6020,12 +6069,14 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
background-color: var(--button-gray-background-color) !important;
border-color: var(--border-color) !important;
color: var(--dark-text-color) !important;
cursor: default; /* 不可操作的后缀文本使用普通指针 */
}
#createTaskModal .input-group-text:has(input[type="checkbox"]):hover {
background-color: var(--button-gray-background-color) !important;
border-color: var(--border-color) !important;
color: var(--dark-text-color) !important;
cursor: default; /* 悬停时也保持普通指针 */
}
/* 创建任务模态框中的按钮样式 - 完全复制任务列表样式 */
@ -6521,8 +6572,24 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
.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 {
color: #00C853;
padding: 2px 0; /* 基础上下 padding左右 padding 由子类设置 */
}
/* 已转存集 */
.calendar-transferred-badge-transferred {
padding-left: 9px;
padding-right: 10px;
}
/* 已播出未转存集 */
.calendar-transferred-badge-aired {
padding-left: 7.5px;
padding-right: 8px;
}
.calendar-transferred-badge i {
line-height: 1;
display: inline-block;
@ -6530,10 +6597,10 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
/* 使用多向阴影模拟加粗图标为字体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;
0 0.3px 0 currentColor,
0 -0.3px 0 currentColor,
0.3px 0 0 currentColor,
-0.3px 0 0 currentColor;
}
.discovery-create-task {
@ -6744,6 +6811,7 @@ body .selectable-files tr.selected-file:has([style*="white-space: normal"]) .fil
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: default; /* 不可操作的类型/集数信息使用普通指针 */
}
.genre-slash {
@ -6791,6 +6859,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;
@ -7025,6 +7099,11 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
.calendar-controls .btn-group {
margin: 0; /* 由 gap 控制间距 */
}
/* 追剧日历页面的排序组件:覆盖任务列表的 -4px 偏移,保持与其他按钮对齐 */
.calendar-controls .tasklist-sort-controls {
margin-top: 0 !important;
}
}
/* 统一日历控制按钮的样式 */
@ -7117,10 +7196,17 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
height: 32px;
font-size: 0.95rem;
color: var(--dark-text-color);
cursor: default;
cursor: pointer;
transition: background-color 0.2s, border-color 0.2s, color 0.2s;
}
.calendar-date-item.today {
.calendar-date-item:hover:not(.selected) {
background-color: var(--dark-text-color);
border-color: var(--dark-text-color);
color: white;
}
.calendar-date-item.selected {
background-color: var(--focus-border-color);
border-color: var(--focus-border-color);
color: white;
@ -7302,12 +7388,13 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
font-size: 0.95rem;
color: var(--dark-text-color);
border-right: 1px solid var(--border-color); /* 显示内部分割线 */
background-color: #f7f7f9;
background-color: var(--button-gray-background-color);
/* 防止文本被挤压成竖排 */
min-width: 100px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: default; /* 不可操作的星期表头使用普通指针 */
}
/* 移除星期导航最后一列的右侧分割线,避免溢出 */
@ -7386,6 +7473,13 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
display: none; /* 隐藏蓝色圆形背景 */
}
/* 桌面端:选中日期的号数高亮显示(使用 focus-border-color */
@media (min-width: 577px) {
.calendar-month-cell.selected .calendar-month-date {
color: var(--focus-border-color);
}
}
.calendar-month-cell.has-episodes {
background-color: transparent !important; /* 去除有播出集背景色 */
}
@ -7402,7 +7496,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
font-size: 0.85rem;
padding: 6px; /* 等距6px */
line-height: 1; /* 统一行高,消除上下视觉不等距 */
/* 日历视图:默认保持原按钮灰背景 */
/* 日历视图:默认保持原按钮灰背景(未转存且未播出) */
background-color: #f7f7f9;
border-radius: 6px; /* 圆角6px */
display: flex;
@ -7410,11 +7504,16 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
gap: 4px;
}
/* 日历视图:当该集已达转存进度时,卡片背景改为导航悬停浅蓝色 */
/* 日历视图:当该集已达转存进度时,卡片背景改为导航悬停浅蓝色 */
.calendar-month-episode:has(.episode-number.episode-number-reached) {
background-color: #e6f1ff;
}
/* 日历视图:未转存但已播出的集,卡片背景为浅绿色 */
.calendar-month-episode:has(.episode-number.episode-number-aired):not(:has(.episode-number.episode-number-reached)) {
background-color: #E6F7EE;
}
.episode-title {
font-weight: 400; /* 常规体 */
flex: 1;
@ -7584,11 +7683,14 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
}
/* 超小模式:表格内部的卡片背景色覆盖(仅表格体内紧凑卡片) */
.calendar-month-body .calendar-month-episode {
background-color: var(--border-color) !important; /* 普通状态背景色 */
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-month-body .calendar-month-episode:has(.episode-number.episode-number-aired):not(:has(.episode-number.episode-number-reached)) {
background-color: #28a745 !important; /* 已播出但未转存背景色 */
}
}
@ -7603,12 +7705,15 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
padding: 6px;
line-height: 1;
transform: none; /* 还原不上移 */
background-color: #f7f7f9; /* 桌面端默认背景 */
background-color: #f7f7f9; /* 桌面端默认背景:未转存且未播出 */
border-radius: 6px;
}
.calendar-selected-episodes .calendar-month-episode:has(.episode-number.episode-number-reached) {
background-color: #e6f1ff; /* 桌面端已转存背景 */
}
.calendar-selected-episodes .calendar-month-episode:has(.episode-number.episode-number-aired):not(:has(.episode-number.episode-number-reached)) {
background-color: #E6F7EE; /* 桌面端已播出但未转存背景 */
}
/* 移动端:下方列表的集号样式与桌面端一致(显示完整 SxxExx */
.calendar-selected-episodes .calendar-month-episode .episode-number {
@ -7875,11 +7980,16 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
border-color: #0A42CC !important;
}
/* 显示设置:拖拽时显示“移动”而非“添加”视觉提示 */
/* 显示设置:拖拽时显示"移动"而非"添加"视觉提示 */
.draggable-item {
cursor: move; /* 显示移动光标 */
}
/* 显示设置可拖动配置项的标题也使用拖拽指针 */
.draggable-item .input-group-text {
cursor: move !important; /* 可拖动配置项的标题使用拖拽指针 */
}
.draggable-item:active {
opacity: 1;
}
@ -7894,10 +8004,16 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
cursor: text;
}
/* QASX API Token 显示框背景色:与普通输入框保持一致,覆盖 disabled 状态的灰色背景 */
.form-control.token-display:disabled,
.form-control.token-display[readonly] {
background-color: #fff !important; /* 与普通输入框背景色保持一致 */
}
/* TMDB 说明文本样式与链接样式(继承颜色、无下划线、悬停不变) */
.tmdb-attribution {
margin-top: 4px;
margin-bottom: 4px;
margin-bottom: 2px;
color: var(--light-text-color);
}
.tmdb-attribution a {
@ -7907,7 +8023,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
.tmdb-attribution a:hover,
.tmdb-attribution a:focus {
text-decoration: none;
color: inherit;
color: var(--focus-border-color);
}
/* 任务列表:类型筛选按钮与上方名称筛选区域的间距 */
@ -7941,7 +8057,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
font-size: 0.95rem; /* 与左侧类型按钮一致 */
}
/* 左侧“按”按钮:保留边框,去右边框,与中间边框重叠 */
/* 左侧"按"按钮:保留边框,去右边框,与中间边框重叠 */
.tasklist-sort-pill-icon {
width: 31px;
min-width: 31px;
@ -7950,6 +8066,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
border-right: none !important;
border-radius: 6px 0 0 6px;
background-color: var(--button-gray-background-color);
cursor: default; /* 不可操作的"按"按钮使用普通指针 */
}
/* 中间下拉:白底,负责显示唯一可见边框(上下左右均有) */
@ -7961,7 +8078,7 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
-webkit-appearance: none;
-moz-appearance: none;
background: #fff !important;
cursor: pointer;
cursor: default;
background-image: none;
height: 32px;
line-height: 30px;
@ -8409,10 +8526,131 @@ div:has(> .collapse:not(.show)):has(+ .row.title[title^="资源搜索"]) {
}
.tasklist-count-number {
cursor: text;
cursor: default;
user-select: text;
}
/* 追剧日历计数模块减少左侧与排序组件的间距8pxml-2是8px减少8px后为0 */
.calendar-count-indicator {
margin-left: 0 !important;
}
/* 运行日志页面日志行样式:与运行日志弹窗保持一致 */
.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;
}
/* 运行日志页面:内容中可点击的快速筛选元素样式(>>> 和任务名称) */
.runlog-content .runtime-log-arrow-clickable,
.runlog-content .runtime-log-taskname-clickable {
cursor: pointer;
user-select: text; /* 允许正常选中复制文本 */
}
.runlog-content .runtime-log-arrow-clickable:hover,
.runlog-content .runtime-log-taskname-clickable:hover {
color: var(--focus-border-color);
}
/* --------------- 页面底部元素统一间距 --------------- */
/* 转存记录和文件整理页面:分页控制区域距离页面底部统一为 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; /* 窄屏设备日志行左边距 */
}
}
/* API 配置模块样式 */
.api-config-group + .api-config-group {
margin-top: 8px;
}
.api-label-link {
text-decoration: none;
color: inherit;
cursor: pointer;
}
.api-label-link:hover,
.api-label-link:focus {
color: var(--focus-border-color);
text-decoration: none;
}
/* 海报悬停信息行数限制样式 */
/* 单行限制:最多显示一行,超长部分截断显示省略号 */
.discovery-poster-overlay .info-line-single,
.calendar-poster-overlay .info-line-single {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
/* 双行限制:最多显示两行,如果两行还不能显示完整,超长部分截断显示省略号 */
.discovery-poster-overlay .info-line-double,
.calendar-poster-overlay .info-line-double {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
white-space: normal;
word-wrap: break-word;
}

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,6 @@ 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):
# 剧集编号提取模式
@ -233,31 +229,19 @@ class TaskExtractor:
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
@ -283,16 +267,11 @@ class TaskExtractor:
'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: