From 2d944600e61ea3d6217d2b47f05542cf8f4626b5 Mon Sep 17 00:00:00 2001 From: x1ao4 Date: Sun, 12 Oct 2025 16:28:12 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=B7=E6=8A=A5=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E8=87=AA=E5=AE=9A=E4=B9=89=E6=B5=B7=E6=8A=A5=E4=BF=9D?= =?UTF-8?q?=E6=8A=A4=E5=92=8C=E8=87=AA=E5=8A=A8=E6=B8=85=E7=90=86=E5=AD=A4?= =?UTF-8?q?=E7=AB=8B=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 is_custom_poster 数据库字段,标记用户自定义海报 - 实现自定义海报保护机制,防止被 TMDB 自动更新覆盖 - 添加孤立文件自动清理功能,优化存储空间管理 - 优化海报更新逻辑,支持旧文件删除和新文件下载 - 提供手动清理孤立文件的 API 接口 - 保持向后兼容,旧版本数据无缝升级 解决用户自定义海报被覆盖和孤立文件积累的问题 --- app/run.py | 170 ++++++++++++++++++++++++++++++++++++++++++++------ app/sdk/db.py | 21 ++++--- 2 files changed, 164 insertions(+), 27 deletions(-) diff --git a/app/run.py b/app/run.py index 7818b65..fb30a40 100644 --- a/app/run.py +++ b/app/run.py @@ -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() diff --git a/app/sdk/db.py b/app/sdk/db.py index ec79658..942837c 100644 --- a/app/sdk/db.py +++ b/app/sdk/db.py @@ -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):