mirror of
https://github.com/Cp0204/quark-auto-save.git
synced 2026-01-12 15:20:44 +08:00
在编辑元数据模态框内增加了更换海报功能
This commit is contained in:
parent
02d8f60709
commit
9fd9d5cd74
203
app/run.py
203
app/run.py
@ -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)}"})
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user