优化海报管理功能,支持用户自定义海报保护和自动清理孤立文件

- 新增 is_custom_poster 数据库字段,标记用户自定义海报
- 实现自定义海报保护机制,防止被 TMDB 自动更新覆盖
- 添加孤立文件自动清理功能,优化存储空间管理
- 优化海报更新逻辑,支持旧文件删除和新文件下载
- 提供手动清理孤立文件的 API 接口
- 保持向后兼容,旧版本数据无缝升级

解决用户自定义海报被覆盖和孤立文件积累的问题
This commit is contained in:
x1ao4 2025-10-12 16:28:12 +08:00
parent 3898c6d41c
commit 2d944600e6
2 changed files with 164 additions and 27 deletions

View File

@ -1179,7 +1179,18 @@ def purge_calendar_by_tmdb_id_internal(tmdb_id: int, force: bool = False) -> boo
match = (cal.get('match') or {})
bound = match.get('tmdb_id') or cal.get('tmdb_id')
if bound and int(bound) == int(tmdb_id):
logging.info(f"跳过清理 tmdb_id={tmdb_id}(仍被其他任务引用)")
# 获取海报文件名用于日志提示
try:
db = CalendarDB()
show = db.get_show(int(tmdb_id))
poster_path = show.get('poster_local_path', '') if show else ''
if poster_path and poster_path.startswith('/cache/images/'):
poster_filename = poster_path.replace('/cache/images/', '')
else:
poster_filename = f"poster_{tmdb_id}"
except Exception:
poster_filename = f"poster_{tmdb_id}"
logging.debug(f"跳过清理海报文件 {poster_filename}(仍被其他任务引用)")
return True
except Exception:
pass
@ -4133,16 +4144,25 @@ def process_single_task_async(task, tmdb_service, cal_db):
latest_season_number = updated_match.get('latest_season_number') or 1
logging.debug(f"process_single_task_async - 最终使用的季数: {latest_season_number}")
poster_local_path = ''
if poster_path:
poster_local_path = download_poster_local(poster_path)
# 获取现有的绑定关系和内容类型,避免被覆盖
existing_show = cal_db.get_show(int(tmdb_id))
existing_bound_tasks = existing_show.get('bound_task_names', '') if existing_show else ''
existing_content_type = existing_show.get('content_type', '') if existing_show else ''
existing_poster_path = existing_show.get('poster_local_path', '') if existing_show else ''
is_custom_poster = bool(existing_show.get('is_custom_poster', 0)) if existing_show else False
poster_local_path = ''
if poster_path:
poster_local_path = download_poster_local(poster_path, int(tmdb_id), existing_poster_path, is_custom_poster)
cal_db.upsert_show(int(tmdb_id), name, first_air, status, poster_local_path, int(latest_season_number), 0, existing_bound_tasks, existing_content_type)
# 如果海报路径发生变化,触发孤立文件清理
if poster_local_path and poster_local_path != existing_poster_path:
try:
cleanup_orphaned_posters()
except Exception as e:
logging.warning(f"清理孤立海报失败: {e}")
# 更新 seasons 表
refresh_url = f"/tv/{tmdb_id}/season/{latest_season_number}"
# 处理季名称
@ -4247,14 +4267,16 @@ def do_calendar_bootstrap() -> tuple:
poster_path = tmdb_service.get_poster_path_with_language(int(tmdb_id)) if tmdb_service else (details.get('poster_path') or '')
latest_season_number = match.get('latest_season_number') or 1
poster_local_path = ''
if poster_path:
poster_local_path = download_poster_local(poster_path)
# 获取现有的绑定关系和内容类型,避免被覆盖
existing_show = cal_db.get_show(int(tmdb_id))
existing_bound_tasks = existing_show.get('bound_task_names', '') if existing_show else ''
existing_content_type = existing_show.get('content_type', '') if existing_show else ''
existing_poster_path = existing_show.get('poster_local_path', '') if existing_show else ''
is_custom_poster = bool(existing_show.get('is_custom_poster', 0)) if existing_show else False
poster_local_path = ''
if poster_path:
poster_local_path = download_poster_local(poster_path, int(tmdb_id), existing_poster_path, is_custom_poster)
cal_db.upsert_show(int(tmdb_id), name, first_air, status, poster_local_path, int(latest_season_number), 0, existing_bound_tasks, existing_content_type)
refresh_url = f"/tv/{tmdb_id}/season/{latest_season_number}"
# 处理季名称
@ -4342,6 +4364,49 @@ def clear_all_posters():
logging.error(f"清空海报文件失败: {e}")
return False
def cleanup_orphaned_posters():
"""清理孤立的旧海报文件(不在数据库中的海报文件)"""
try:
from app.sdk.db import CalendarDB
cal_db = CalendarDB()
# 获取数据库中所有正在使用的海报路径
shows = cal_db.get_all_shows()
used_posters = set()
for show in shows:
poster_path = show.get('poster_local_path', '')
if poster_path and poster_path.startswith('/cache/images/'):
poster_name = poster_path.replace('/cache/images/', '')
used_posters.add(poster_name)
# 扫描缓存目录中的所有文件
if os.path.exists(CACHE_IMAGES_DIR):
orphaned_files = []
for filename in os.listdir(CACHE_IMAGES_DIR):
if filename not in used_posters:
file_path = os.path.join(CACHE_IMAGES_DIR, filename)
if os.path.isfile(file_path):
orphaned_files.append(file_path)
# 删除孤立文件
deleted_count = 0
for file_path in orphaned_files:
try:
os.remove(file_path)
deleted_count += 1
except Exception as e:
logging.warning(f"删除孤立海报文件失败: {e}")
# 只在删除了文件时才输出日志
if deleted_count > 0:
logging.info(f"海报清理完成,删除了 {deleted_count} 个孤立文件")
return deleted_count > 0
return False
except Exception as e:
logging.error(f"清理孤立海报失败: {e}")
return False
def redownload_all_posters():
"""重新下载所有海报"""
try:
@ -4373,8 +4438,11 @@ def redownload_all_posters():
# 获取新的海报路径
new_poster_path = tmdb_service.get_poster_path_with_language(int(tmdb_id))
if new_poster_path:
# 获取现有海报信息
existing_poster_path = show.get('poster_local_path', '')
is_custom_poster = bool(show.get('is_custom_poster', 0))
# 下载新海报
poster_local_path = download_poster_local(new_poster_path)
poster_local_path = download_poster_local(new_poster_path, int(tmdb_id), existing_poster_path, is_custom_poster)
if poster_local_path:
# 更新数据库中的海报路径
cal_db.update_show_poster(int(tmdb_id), poster_local_path)
@ -4389,14 +4457,27 @@ def redownload_all_posters():
logging.error(f"重新下载海报失败 (TMDB ID: {show.get('tmdb_id')}): {e}")
continue
# 重新下载完成后,清理孤立的旧海报文件
try:
cleanup_orphaned_posters()
except Exception as e:
logging.warning(f"清理孤立海报失败: {e}")
return success_count > 0
except Exception as e:
logging.error(f"重新下载所有海报失败: {e}")
return False
def download_poster_local(poster_path: str) -> str:
"""下载 TMDB 海报到本地 static/cache/images 下等比缩放为宽400px返回相对路径。"""
def download_poster_local(poster_path: str, tmdb_id: int = None, existing_poster_path: str = None, is_custom_poster: bool = False) -> str:
"""下载 TMDB 海报到本地 static/cache/images 下等比缩放为宽400px返回相对路径。
Args:
poster_path: TMDB海报路径
tmdb_id: TMDB ID用于检查自定义海报标记
existing_poster_path: 现有的海报路径用于检查是否需要更新
is_custom_poster: 是否为自定义海报标记
"""
try:
if not poster_path:
return ''
@ -4410,6 +4491,25 @@ def download_poster_local(poster_path: str) -> str:
if os.path.exists(file_path):
return f"/cache/images/{safe_name}"
# 如果提供了现有海报路径,检查是否需要更新
if existing_poster_path and tmdb_id is not None:
existing_file_name = existing_poster_path.replace('/cache/images/', '')
new_file_name = safe_name
# 如果文件名不同说明TMDB海报有变更
if existing_file_name != new_file_name:
# 检查是否有自定义海报标记
if is_custom_poster:
return existing_poster_path
else:
# 删除旧文件
try:
old_file_path = os.path.join(folder, existing_file_name)
if os.path.exists(old_file_path):
os.remove(old_file_path)
except Exception as e:
logging.warning(f"删除旧海报文件失败: {e}")
# 文件不存在,需要下载
base = "https://image.tmdb.org/t/p/w400"
url = f"{base}{poster_path}"
@ -4828,7 +4928,9 @@ def calendar_refresh_show():
poster_local_path = ''
try:
if poster_path:
poster_local_path = download_poster_local(poster_path)
existing_poster_path = existing_show.get('poster_local_path', '') if existing_show else ''
is_custom_poster = bool(existing_show.get('is_custom_poster', 0)) if existing_show else False
poster_local_path = download_poster_local(poster_path, int(tmdb_id), existing_poster_path, is_custom_poster)
elif existing_show and existing_show.get('poster_local_path'):
poster_local_path = existing_show.get('poster_local_path')
except Exception:
@ -4849,6 +4951,14 @@ def calendar_refresh_show():
content_type
)
# 如果海报路径发生变化,触发孤立文件清理
existing_poster_path = existing_show.get('poster_local_path', '') if existing_show else ''
if poster_local_path and poster_local_path != existing_poster_path:
try:
cleanup_orphaned_posters()
except Exception as e:
logging.warning(f"清理孤立海报失败: {e}")
try:
notify_calendar_changed('refresh_show')
# 通知前端指定节目海报可能已更新,用于触发按节目维度的缓存穿透
@ -5168,7 +5278,13 @@ def calendar_edit_metadata():
saved_path = download_custom_poster(custom_poster_url, current_tmdb_id, target_safe_name)
if saved_path:
# 若文件名发生变化则需要同步数据库并清理旧文件
# 更新数据库路径,标记为自定义海报
try:
cal_db.update_show_poster(int(current_tmdb_id), saved_path, is_custom_poster=1)
except Exception as e:
logging.warning(f"更新海报路径失败: {e}")
# 若文件名发生变化则需要删除旧文件
try:
existing_path = ''
if show_row:
@ -5183,14 +5299,15 @@ def calendar_edit_metadata():
os.remove(old_path)
except Exception as e:
logging.warning(f"删除旧海报失败: {e}")
# 更新数据库路径
try:
cal_db.update_show_poster(int(current_tmdb_id), saved_path)
except Exception as e:
logging.warning(f"更新海报路径失败: {e}")
except Exception:
# 即使对比失败也不影响功能
pass
# 用户上传自定义海报后,总是触发孤立文件清理
try:
cleanup_orphaned_posters()
except Exception as e:
logging.warning(f"清理孤立海报失败: {e}")
# 成功更新自定义海报(静默)
changed = True
@ -5762,6 +5879,21 @@ def cleanup_episode_patterns_config(config_data):
logging.error(f"清理剧集识别规则配置失败: {str(e)}")
return False
@app.route("/api/calendar/cleanup_orphaned_posters", methods=["POST"])
def cleanup_orphaned_posters_api():
"""手动清理孤立的旧海报文件"""
try:
if not is_login():
return jsonify({"success": False, "message": "未登录"})
success = cleanup_orphaned_posters()
if success:
return jsonify({"success": True, "message": "孤立海报文件清理完成"})
else:
return jsonify({"success": True, "message": "没有发现需要清理的孤立文件"})
except Exception as e:
return jsonify({"success": False, "message": f"清理孤立海报失败: {str(e)}"})
if __name__ == "__main__":
init()
reload_tasks()

View File

@ -303,6 +303,10 @@ class CalendarDB:
columns = [column[1] for column in cursor.fetchall()]
if 'content_type' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN content_type TEXT')
# 检查 is_custom_poster 字段是否存在,如果不存在则添加
if 'is_custom_poster' not in columns:
cursor.execute('ALTER TABLE shows ADD COLUMN is_custom_poster INTEGER DEFAULT 0')
# seasons
cursor.execute('''
@ -341,11 +345,11 @@ class CalendarDB:
self.conn.close()
# shows
def upsert_show(self, tmdb_id:int, name:str, year:str, status:str, poster_local_path:str, latest_season_number:int, last_refreshed_at:int=0, bound_task_names:str="", content_type:str=""):
def upsert_show(self, tmdb_id:int, name:str, year:str, status:str, poster_local_path:str, latest_season_number:int, last_refreshed_at:int=0, bound_task_names:str="", content_type:str="", is_custom_poster:int=0):
cursor = self.conn.cursor()
cursor.execute('''
INSERT INTO shows (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO shows (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type, is_custom_poster)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tmdb_id) DO UPDATE SET
name=excluded.name,
year=excluded.year,
@ -354,8 +358,9 @@ class CalendarDB:
latest_season_number=excluded.latest_season_number,
last_refreshed_at=excluded.last_refreshed_at,
bound_task_names=excluded.bound_task_names,
content_type=excluded.content_type
''', (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type))
content_type=excluded.content_type,
is_custom_poster=excluded.is_custom_poster
''', (tmdb_id, name, year, status, poster_local_path, latest_season_number, last_refreshed_at, bound_task_names, content_type, is_custom_poster))
self.conn.commit()
def bind_task_to_show(self, tmdb_id:int, task_name:str):
@ -579,10 +584,10 @@ class CalendarDB:
cursor.execute('UPDATE shows SET latest_season_number=? WHERE tmdb_id=?', (latest_season_number, tmdb_id))
self.conn.commit()
def update_show_poster(self, tmdb_id: int, poster_local_path: str):
"""更新节目的海报路径"""
def update_show_poster(self, tmdb_id: int, poster_local_path: str, is_custom_poster: int = 0):
"""更新节目的海报路径和自定义标记"""
cursor = self.conn.cursor()
cursor.execute('UPDATE shows SET poster_local_path=? WHERE tmdb_id=?', (poster_local_path, tmdb_id))
cursor.execute('UPDATE shows SET poster_local_path=?, is_custom_poster=? WHERE tmdb_id=?', (poster_local_path, is_custom_poster, tmdb_id))
self.conn.commit()
def get_all_shows(self):