在编辑元数据模态框内增加了更换海报功能

This commit is contained in:
x1ao4 2025-09-15 15:49:17 +08:00
parent 02d8f60709
commit 9fd9d5cd74
2 changed files with 310 additions and 6 deletions

View File

@ -573,6 +573,13 @@ app.json.sort_keys = False
app.jinja_env.variable_start_string = "[["
app.jinja_env.variable_end_string = "]]"
# 注册 AVIF MIME 类型,确保静态路由能正确返回 Content-Type
try:
import mimetypes
mimetypes.add_type('image/avif', '.avif')
except Exception:
pass
scheduler = BackgroundScheduler()
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.INFO,
@ -690,7 +697,15 @@ def favicon():
# 将 /cache/images/* 映射到宿主缓存目录,供前端访问
@app.route('/cache/images/<path:filename>')
def serve_cache_images(filename):
return send_from_directory(CACHE_IMAGES_DIR, filename)
resp = send_from_directory(CACHE_IMAGES_DIR, filename)
try:
# 禁用强缓存,允许协商缓存
resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0'
resp.headers['Pragma'] = 'no-cache'
resp.headers['Expires'] = '0'
except Exception:
pass
return resp
# 登录页面
@ -4382,6 +4397,122 @@ def download_poster_local(poster_path: str) -> str:
return ''
def download_custom_poster(poster_url: str, tmdb_id: int, target_safe_name: str = None) -> str:
"""下载自定义海报到本地,尽量覆盖原有海报文件名,返回相对路径。
注意不引入任何额外依赖不进行等比缩放如需缩放应在容器具备工具时再扩展
"""
try:
if not poster_url or not tmdb_id:
return ''
folder = CACHE_IMAGES_DIR
# 目标文件名:若提供则使用现有文件名覆盖;否则根据内容类型/URL推断扩展名
default_ext = 'jpg'
safe_name = (target_safe_name or '').strip()
if not safe_name:
# 尝试从 URL 推断扩展名
guessed_ext = None
try:
from urllib.parse import urlparse
path = urlparse(poster_url).path
base = os.path.basename(path)
if '.' in base:
guessed_ext = base.split('.')[-1].lower()
except Exception:
guessed_ext = None
ext = guessed_ext or default_ext
# 简单标准化
if ext in ('jpeg',):
ext = 'jpg'
safe_name = f"poster_{tmdb_id}.{ext}"
file_path = os.path.join(folder, safe_name)
# 确保目录存在
os.makedirs(folder, exist_ok=True)
# 处理 file:// 前缀
if poster_url.lower().startswith('file://'):
poster_url = poster_url[7:]
# 处理本地文件路径
if poster_url.startswith('/') or poster_url.startswith('./'):
# 本地文件路径
if os.path.exists(poster_url):
# 若提供了目标名但其扩展名与源文件明显不符(例如源为 .avif则改用基于 tmdb_id 的标准文件名并匹配扩展
try:
src_ext = os.path.splitext(poster_url)[1].lower().lstrip('.')
except Exception:
src_ext = ''
if src_ext in ('jpeg',):
src_ext = 'jpg'
if src_ext in ('jpg', 'png', 'webp', 'avif'):
target_ext = os.path.splitext(safe_name)[1].lower().lstrip('.') if '.' in safe_name else ''
if target_ext != src_ext:
safe_name = f"poster_{tmdb_id}.{src_ext or default_ext}"
file_path = os.path.join(folder, safe_name)
import shutil
shutil.copy2(poster_url, file_path)
return f"/cache/images/{safe_name}"
else:
logging.warning(f"本地海报文件不存在: {poster_url}")
return ''
else:
# 网络URL
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
r = requests.get(poster_url, timeout=15, headers=headers)
if r.status_code != 200:
logging.warning(f"下载自定义海报失败: {poster_url}, 状态码: {r.status_code}")
return ''
# 检查内容类型是否为图片(无法严格判断扩展名,这里仅基于响应头)
content_type = (r.headers.get('content-type') or '').lower()
if not any(img_type in content_type for img_type in ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/avif']):
logging.warning(f"自定义海报URL不是有效的图片格式: {poster_url}, 内容类型: {content_type}")
# 不强制失败放行保存部分服务不返回标准content-type
# return ''
# 若未指定目标文件名,或指定的扩展与内容类型不一致,则根据 content-type 修正扩展名
need_fix_ext = False
if (target_safe_name or '').strip():
# 校验已有目标名的扩展
current_ext = os.path.splitext(safe_name)[1].lower().lstrip('.') if '.' in safe_name else ''
if ('image/avif' in content_type and current_ext != 'avif') or \
('image/webp' in content_type and current_ext != 'webp') or \
(('image/jpeg' in content_type or 'image/jpg' in content_type) and current_ext not in ('jpg','jpeg')) or \
('image/png' in content_type and current_ext != 'png'):
need_fix_ext = True
if not (target_safe_name or '').strip() or need_fix_ext:
ext_map = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/avif': 'avif',
}
chosen_ext = None
for k, v in ext_map.items():
if k in content_type:
chosen_ext = v
break
if chosen_ext:
base_no_ext = f"poster_{tmdb_id}"
safe_name = f"{base_no_ext}.{chosen_ext}"
file_path = os.path.join(folder, safe_name)
with open(file_path, 'wb') as f:
f.write(r.content)
logging.info(f"成功保存自定义海报: {poster_url} -> {file_path}")
return f"/cache/images/{safe_name}"
except Exception as e:
logging.error(f"下载自定义海报失败: {e}")
return ''
# 刷新:拉取最新一季所有集,按有无 runtime 进行增量更新
@app.route("/api/calendar/refresh_latest_season")
def calendar_refresh_latest_season():
@ -4709,6 +4840,7 @@ def calendar_edit_metadata():
new_content_type = (data.get('new_content_type') or '').strip()
new_tmdb_id = data.get('new_tmdb_id')
new_season_number = data.get('new_season_number')
custom_poster_url = (data.get('custom_poster_url') or '').strip()
if not task_name:
return jsonify({"success": False, "message": "缺少任务名称"})
@ -4968,6 +5100,70 @@ def calendar_edit_metadata():
changed = True
# 处理自定义海报
if custom_poster_url:
# 获取当前任务的TMDB ID
current_tmdb_id = None
if new_tmdb_id:
current_tmdb_id = int(new_tmdb_id)
elif old_tmdb_id:
current_tmdb_id = int(old_tmdb_id)
if current_tmdb_id:
try:
# 尝试读取现有节目,若有已有海报,则覆盖同名文件以实现原位替换
show_row = None
try:
show_row = cal_db.get_show(int(current_tmdb_id))
except Exception:
show_row = None
target_safe_name = None
if show_row and (show_row.get('poster_local_path') or '').strip():
try:
existing_path = (show_row.get('poster_local_path') or '').strip()
# 期望格式:/cache/images/<filename>
target_safe_name = existing_path.split('/')[-1]
except Exception:
target_safe_name = None
# 覆盖保存(不进行缩放,不新增依赖)
saved_path = download_custom_poster(custom_poster_url, current_tmdb_id, target_safe_name)
if saved_path:
# 若文件名发生变化则需要同步数据库并清理旧文件
try:
existing_path = ''
if show_row:
existing_path = (show_row.get('poster_local_path') or '').strip()
if saved_path != existing_path:
# 删除旧文件(若存在且在缓存目录下)
try:
if existing_path and existing_path.startswith('/cache/images/'):
old_name = existing_path.replace('/cache/images/', '')
old_path = os.path.join(CACHE_IMAGES_DIR, old_name)
if os.path.exists(old_path):
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
logging.info(f"成功更新自定义海报: TMDB ID {current_tmdb_id}, 路径: {saved_path}")
changed = True
else:
logging.warning(f"自定义海报保存失败: {custom_poster_url}")
except Exception as e:
logging.error(f"处理自定义海报失败: {e}")
else:
logging.warning("无法处理自定义海报缺少TMDB ID")
if changed:
Config.write_json(CONFIG_PATH, config_data)
@ -4984,6 +5180,11 @@ def calendar_edit_metadata():
msg = '元数据更新成功'
if did_rematch:
msg = '元数据更新成功,已重新匹配并刷新元数据'
if custom_poster_url:
if '已重新匹配' in msg:
msg = '元数据更新成功,已重新匹配并刷新元数据,自定义海报已更新'
else:
msg = '元数据更新成功,自定义海报已更新'
return jsonify({"success": True, "message": msg})
except Exception as e:
return jsonify({"success": False, "message": f"保存失败: {str(e)}"})

View File

@ -2753,6 +2753,18 @@
</div>
</div>
</div>
<!-- 更换海报 -->
<div class="row">
<div class="col-12 mb-2">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text" style="background-color: var(--button-gray-background-color);">更换海报</span>
</div>
<input type="text" class="form-control" v-model="editMetadata.form.custom_poster_url" placeholder="输入海报地址,支持网络地址或映射后的本地地址">
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
@ -2775,6 +2787,11 @@
versionTips: "",
plugin_flags: "[[ plugin_flags ]]",
weekdays: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
// 全局缓存穿透时间戳(兜底)
imageCacheBustTick: 0,
// 按节目维度的缓存穿透时间戳
imageCacheBustById: {}, // { [tmdb_id:number]: number }
imageCacheBustByShowName: {}, // { [show_name:string]: number }
sidebarCollapsed: false,
showCloudSaverPassword: false,
showWebuiPassword: false,
@ -5151,7 +5168,8 @@
task_name: currentName,
content_type: task.content_type || '',
tmdb_id: '',
season_number: currentSeason || 1
season_number: currentSeason || 1,
custom_poster_url: ''
},
display: {
matched_label: matchedName ? `${matchedName}${matchedYear ? ' (' + matchedYear + ')' : ''}` : '未匹配',
@ -5202,14 +5220,16 @@
new_task_name: this.editMetadata.form.task_name,
new_content_type: this.editMetadata.form.content_type,
new_tmdb_id: this.editMetadata.form.tmdb_id,
new_season_number: this.editMetadata.form.season_number
new_season_number: this.editMetadata.form.season_number,
custom_poster_url: this.editMetadata.form.custom_poster_url
};
// 如果没有任何变化则直接关闭
const noNameChange = (payload.new_task_name || '') === (this.editMetadata.original.task_name || '');
const noTypeChange = (payload.new_content_type || '') === (this.editMetadata.original.content_type || '');
const noRematch = !(payload.new_tmdb_id && String(payload.new_tmdb_id).trim()) && !(payload.new_season_number && String(payload.new_season_number).trim());
if (noNameChange && noTypeChange && noRematch) {
const noPosterChange = !(payload.custom_poster_url && String(payload.custom_poster_url).trim());
if (noNameChange && noTypeChange && noRematch && noPosterChange) {
$('#editMetadataModal').modal('hide');
return;
}
@ -5221,6 +5241,36 @@
// 热更新任务与日历
// 避免触发“未保存修改”提示:本次更新由后端变更引发
this.suppressConfigModifiedOnce = true;
// 本次操作来自编辑元数据,不应提示未保存
this.configModified = false;
// 触发海报缓存穿透(按节目维度):尽可能多地命中该节目的不同名称键
try {
const nowTick = Date.now();
const eid = (this.editMetadata.display && this.editMetadata.display.matched_tmdb_id) || (this.editMetadata.original && this.editMetadata.original.tmdb_id);
const taskName = (this.editMetadata.original && this.editMetadata.original.task_name) || '';
const matchedLabel = (this.editMetadata.display && this.editMetadata.display.matched_label) || '';
const normalizeMatchedName = (label) => {
try {
// 去掉类似 "名称 (2023)" 的年份后缀
const m = String(label).trim().match(/^(.*?)(\s*\(\d{4}\))?$/);
return (m && m[1]) ? m[1].trim() : String(label).trim();
} catch (e) { return String(label || '').trim(); }
};
const normalizedMatched = normalizeMatchedName(matchedLabel);
// 从任务映射中找 show_name
let showName = '';
try {
if (taskName && this.calendar && this.calendar.taskMapByName && this.calendar.taskMapByName[taskName]) {
showName = (this.calendar.taskMapByName[taskName].show_name || '').trim();
}
} catch (e) {}
if (eid) { this.$set(this.imageCacheBustById, eid, nowTick); }
if (taskName) { this.$set(this.imageCacheBustByShowName, taskName, nowTick); }
if (showName) { this.$set(this.imageCacheBustByShowName, showName, nowTick); }
if (normalizedMatched) { this.$set(this.imageCacheBustByShowName, normalizedMatched, nowTick); }
if (!eid && !taskName && !showName && !normalizedMatched) { this.imageCacheBustTick = nowTick; }
} catch (e) { this.imageCacheBustTick = Date.now(); }
// 并行刷新:优先尽快更新任务列表的类型按钮与映射
try {
const tasksPromise = axios.get('/api/calendar/tasks');
@ -5259,6 +5309,34 @@
} catch (e) { /* 忽略类型热更新异常 */ }
}
await refreshPromise;
// 再次触发缓存穿透,保证刷新后的数据也使用新时间戳
try {
const nowTick = Date.now();
const eid = (this.editMetadata.display && this.editMetadata.display.matched_tmdb_id) || (this.editMetadata.original && this.editMetadata.original.tmdb_id);
const taskName = (this.editMetadata.original && this.editMetadata.original.task_name) || '';
const matchedLabel = (this.editMetadata.display && this.editMetadata.display.matched_label) || '';
const normalizeMatchedName = (label) => {
try {
const m = String(label).trim().match(/^(.*?)(\s*\(\d{4}\))?$/);
return (m && m[1]) ? m[1].trim() : String(label).trim();
} catch (e) { return String(label || '').trim(); }
};
const normalizedMatched = normalizeMatchedName(matchedLabel);
let showName = '';
try {
if (taskName && this.calendar && this.calendar.taskMapByName && this.calendar.taskMapByName[taskName]) {
showName = (this.calendar.taskMapByName[taskName].show_name || '').trim();
}
} catch (e) {}
if (eid) { this.$set(this.imageCacheBustById, eid, nowTick); }
if (taskName) { this.$set(this.imageCacheBustByShowName, taskName, nowTick); }
if (showName) { this.$set(this.imageCacheBustByShowName, showName, nowTick); }
if (normalizedMatched) { this.$set(this.imageCacheBustByShowName, normalizedMatched, nowTick); }
if (!eid && !taskName && !showName && !normalizedMatched) { this.imageCacheBustTick = nowTick; }
} catch (e) { this.imageCacheBustTick = Date.now(); }
// 刷新链路结束后确保未保存标记为false
this.configModified = false;
} catch (e) { /* 忽略后台刷新异常,前端不报错 */ }
} else {
this.showToast(res.data.message || '保存失败');
@ -5732,7 +5810,20 @@
const path = episode.poster_local_path.startsWith('/')
? episode.poster_local_path
: '/' + episode.poster_local_path;
return path;
// 优先使用按节目维度的缓存穿透参数
let tick = 0;
try {
const eid = (episode.tmdb_id || episode.tv_id || episode.id);
const sname = (episode.show_name || '').trim();
if (eid && this.imageCacheBustById && this.imageCacheBustById[eid]) {
tick = this.imageCacheBustById[eid];
} else if (sname && this.imageCacheBustByShowName && this.imageCacheBustByShowName[sname]) {
tick = this.imageCacheBustByShowName[sname];
} else {
tick = this.imageCacheBustTick || 0;
}
} catch (e) { tick = this.imageCacheBustTick || 0; }
return tick ? `${path}?t=${tick}` : path;
}
// 如果没有海报,返回默认图片
@ -6564,6 +6655,15 @@
this.loadTasklistMetadata();
// 启动任务列表的后台监听
this.startTasklistAutoWatch();
// 该分支的系统性加载不应触发“未保存”提示
try {
this.suppressConfigModifiedOnce = true;
// 异步队列尾部再置一次,覆盖可能的深层变更
this.$nextTick(() => {
this.suppressConfigModifiedOnce = true;
this.configModified = false;
});
} catch (e) {}
} else if (this.activeTab === 'tasklist') {
// 离开任务列表页面时停止后台监听
this.stopTasklistAutoWatch();
@ -6639,7 +6739,8 @@
return task;
});
// 直接赋值,排序通过计算属性处理
// 直接赋值,排序通过计算属性处理(属于系统性加载,抑制未保存提示)
this.suppressConfigModifiedOnce = true;
this.formData.tasklist = config_data.tasklist || [];
// 获取所有任务父目录
@ -6821,6 +6922,8 @@
config_data.performance.calendar_refresh_interval_seconds = 21600;
}
}
// 后端加载配置属于系统性赋值,不应触发未保存提示
this.suppressConfigModifiedOnce = true;
this.formData = config_data;
setTimeout(() => {