Merge pull request #74 from x1ao4/dev

新增运行日志、播出时间相关功能及其他优化
This commit is contained in:
x1ao4 2025-12-24 23:02:17 +08:00 committed by GitHub
commit ce8659bb01
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 4816 additions and 513 deletions

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: