diff --git a/public/style.css b/public/style.css
index 9189b202..cbc23193 100644
--- a/public/style.css
+++ b/public/style.css
@@ -493,7 +493,8 @@ body {
margin: 1rem;
display: flex;
flex-direction: column;
- gap: 2rem;
+ gap: 1rem;
+ /* [修复] 原 2rem 间距在菜单项较多时白白占用约 32px 纵向空间,导致列表更容易溢出 */
position: sticky;
top: 1rem;
height: calc(100vh - 2rem);
@@ -524,15 +525,28 @@ body {
gap: 0.5rem;
overflow-y: auto;
min-height: 0;
- /* Hide scrollbar for Chrome, Safari and Opera */
- -ms-overflow-style: none;
- /* IE and Edge */
- scrollbar-width: none;
- /* Firefox */
+ padding-right: 4px;
+ /* [修复] 菜单项多达 10 条时常溢出,此前滚动条被隐藏导致用户以为「菜单变少」。
+ 改为细滚动条:既保留视觉简洁,又让溢出可被发现、可滚动。 */
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.sidebar-nav::-webkit-scrollbar {
- display: none;
+ width: 6px;
+}
+
+.sidebar-nav::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.sidebar-nav::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.2);
+ border-radius: 3px;
+}
+
+.sidebar-nav::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.35);
}
.nav-item {
@@ -565,6 +579,18 @@ body {
box-shadow: var(--shadow-md);
}
+/* [修复] 视口较矮时(笔记本常见 700~800px)侧栏进一步收紧,避免底部菜单被挤出可视区 */
+@media (max-height: 900px) {
+ .sidebar {
+ padding: 1.25rem 1rem;
+ gap: 0.75rem;
+ }
+
+ .nav-item {
+ padding: 0.625rem 1rem;
+ }
+}
+
.sidebar-divider {
height: 1px;
background: var(--glass-border);
@@ -4812,11 +4838,23 @@ input:-webkit-autofill:active {
padding: 0.25rem 0.5rem;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.06);
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ min-width: 0;
+ overflow: hidden;
+}
+
+/* ID 文本单独截断,避免挤掉后面的类型标签 */
+.col-id .col-id-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
- display: inline-block;
- max-width: fit-content;
+ min-width: 0;
+}
+
+.col-id .kind-tag {
+ flex-shrink: 0;
}
.col-size {
@@ -4928,15 +4966,21 @@ input:-webkit-autofill:active {
/* 快照ID显示优化 */
.col-id {
- display: block !important;
+ display: flex !important;
+ flex-wrap: wrap;
font-family: monospace;
font-size: 0.85rem;
color: var(--text-secondary);
- word-break: break-all;
- white-space: normal;
margin-top: -0.25rem;
}
+ .col-id .col-id-text {
+ overflow: visible;
+ text-overflow: unset;
+ white-space: normal;
+ word-break: break-all;
+ }
+
/* 操作按钮区域:使用网格布局,一行三个按钮 */
.snapshot-actions {
display: grid !important;
@@ -6876,6 +6920,31 @@ input:-webkit-autofill:active {
background: var(--badge-color, currentColor);
}
+.olb-detail-table {
+ width: 100%;
+ border-collapse: collapse;
+ table-layout: fixed;
+ font-size: 0.85rem;
+}
+
+.olb-detail-table td {
+ padding: 0.45rem 0.6rem;
+ vertical-align: top;
+ border-bottom: 1px solid rgba(148, 163, 184, 0.15);
+ word-break: break-all;
+}
+
+.olb-detail-key {
+ width: 96px;
+ white-space: nowrap;
+ color: var(--text-secondary);
+ font-weight: 500;
+}
+
+.olb-detail-val {
+ color: var(--text-primary);
+}
+
.olb-user-tag {
display: inline-block;
padding: 1px 7px;
diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts
index c2fca4a7..52e4bdc4 100644
--- a/src/defaultConfig.ts
+++ b/src/defaultConfig.ts
@@ -138,7 +138,6 @@ const config: LX.Config = {
'openlist.password': '', // OpenList 密码
'openlist.token': '', // OpenList 用户令牌(与账号密码二选一)
'openlist.root': '/lx_music', // OpenList 中 lx-music 备份根目录
- 'openlist.keepLocalAfterUpload': true, // 上传 OpenList 后是否保留本地文件
'openlist.verifyInterval': 5, // 远端可用性校验周期(分钟)
'openlist.syncInterval': 60, // 双向同步周期(分钟)
'openlist.userBackups': '[]', // 各用户备份目录配置(JSON 数组)
diff --git a/src/index.ts b/src/index.ts
index 15f7aa87..22313c05 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -94,8 +94,8 @@ const getConfigHash = (filePath: string) => {
const dataPath = envParams.DATA_PATH ?? path.join(__dirname, '../data')
const resolvedConfigPath = process.env.CONFIG_PATH || path.join(dataPath, 'config.js')
-// [本地配置备份] 每天一份 config-YYYY-MM-DD.js,保留可配置天数(见 configBackup.*)
-let lastBackupDate = ''
+// [本地配置备份] 按周期生成 config-<槽位>.js,保留可配置天数(见 configBackup.*)
+let lastBackupSlot = ''
const getConfigBackupDir = (): string => {
const dir = global.lx?.config['configBackup.dir']
@@ -122,7 +122,37 @@ const getTimestampStr = () => {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
}
-// 清理超过保留期的本地配置备份(按文件 mtime 判断)
+// 本地快照备份文件名:snapshot_
-<日期_时分秒>[-序号]。每次落盘生成独立文件,不覆盖历史,可按时间点回溯。
+const snapshotBackupPrefix = (name: string) => `snapshot_${name}-`
+const isSnapshotBackupOf = (file: string, name: string): boolean => {
+ if (file === `snapshot_${name}`) return true // 兼容旧版无时间戳的副本
+ const prefix = snapshotBackupPrefix(name)
+ if (!file.startsWith(prefix)) return false
+ return /^\d{4}-\d{2}-\d{2}_\d{6}(?:-\d+)?$/.test(file.slice(prefix.length))
+}
+
+// 按同一保留期清理过期的本地快照副本(snapshots// 下的 snapshot_* 文件)
+const cleanOldSnapshotBackups = (backupDir: string, cutoff: number) => {
+ const root = path.join(backupDir, 'snapshots')
+ if (!fs.existsSync(root)) return
+ for (const user of fs.readdirSync(root, { withFileTypes: true })) {
+ if (!user.isDirectory()) continue
+ const dir = path.join(root, user.name)
+ try {
+ for (const file of fs.readdirSync(dir)) {
+ if (!file.startsWith('snapshot_')) continue
+ const fp = path.join(dir, file)
+ try {
+ const stat = fs.statSync(fp)
+ if (stat.isFile() && stat.mtimeMs < cutoff) fs.unlinkSync(fp)
+ } catch { /* ignore */ }
+ }
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir)
+ } catch { /* ignore */ }
+ }
+}
+
+// 清理超过保留期的本地配置备份与快照备份(按文件 mtime 判断)
const cleanOldConfigBackups = () => {
const backupDir = getConfigBackupDir()
try {
@@ -135,6 +165,7 @@ const cleanOldConfigBackups = () => {
if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp)
} catch { /* ignore */ }
}
+ cleanOldSnapshotBackups(backupDir, cutoff)
} catch (err) {
console.error('[Config] Failed to clean old config backups:', err)
}
@@ -167,25 +198,67 @@ const migrateConfigBackups = (targetBackupDir: string) => {
}
}
-// 每天自动备份一次(同日覆盖),备份后顺带清理过期文件
+// 按配置的备份周期生成槽位键:周期≥24h 按天(同日覆盖),否则带时分(每周期一个文件)
+// 备份后顺带清理过期文件
const backupConfig = () => {
if (global.lx?.config['configBackup.enable'] === false) return
const backupDir = getConfigBackupDir()
migrateConfigBackups(backupDir)
+ const intervalHours = Math.max(1, Number(global.lx?.config?.['configBackup.intervalHours']) || 24)
+ const now = new Date()
const dateStr = getTodayStr()
- if (dateStr === lastBackupDate) return
+ const slotKey = intervalHours >= 24
+ ? dateStr
+ : `${dateStr}-${pad2(now.getHours())}${pad2(now.getMinutes())}`
+ if (slotKey === lastBackupSlot) return
try {
if (!global.lx?.configPath || !fs.existsSync(global.lx.configPath)) return
fs.mkdirSync(backupDir, { recursive: true })
- fs.copyFileSync(global.lx.configPath, path.join(backupDir, `config-${dateStr}.js`))
- lastBackupDate = dateStr
- console.log(`[Config] Local backup saved to ${backupDir}/config-${dateStr}.js`)
+ fs.copyFileSync(global.lx.configPath, path.join(backupDir, `config-${slotKey}.js`))
+ lastBackupSlot = slotKey
+ if (global.lx) global.lx.lastConfigBackupAt = Date.now()
+ console.log(`[Config] Local backup saved to ${backupDir}/config-${slotKey}.js`)
} catch (err) {
console.error('[Config] Failed to backup config:', err)
}
cleanOldConfigBackups()
}
+// 本地快照备份:当本地备份开关打开时,把刚落盘的歌单/不喜欢快照复制一份带时间戳的副本到备份目录。
+// 每次落盘都独立成文件(秒级时间戳 + 同秒序号),保证可按时间点回溯,不覆盖历史。
+// 复用 configBackup.enable 开关与 getConfigBackupDir 目录约定("跟配置走")
+const backupSnapshotToLocal = (srcPath: string, user: string, name: string) => {
+ if (global.lx?.config['configBackup.enable'] === false) return
+ if (!srcPath || !fs.existsSync(srcPath) || !user || !name) return
+ const backupDir = getConfigBackupDir()
+ const destDir = path.join(backupDir, 'snapshots', user)
+ try {
+ fs.mkdirSync(destDir, { recursive: true })
+ const base = `${snapshotBackupPrefix(name)}${getTimestampStr()}`
+ let dest = path.join(destDir, base)
+ let seq = 1
+ while (fs.existsSync(dest)) dest = path.join(destDir, `${base}-${seq++}`)
+ fs.copyFileSync(srcPath, dest)
+ } catch (err) {
+ console.error('[Config] Failed to backup snapshot locally:', err)
+ }
+}
+
+// 删除某个快照时,清理其所有本地历史副本(含旧版无时间戳副本),避免孤儿文件
+const removeSnapshotLocalBackups = (user: string, name: string) => {
+ if (!user || !name) return
+ const destDir = path.join(getConfigBackupDir(), 'snapshots', user)
+ if (!fs.existsSync(destDir)) return
+ try {
+ for (const file of fs.readdirSync(destDir)) {
+ if (!isSnapshotBackupOf(file, name)) continue
+ try { fs.unlinkSync(path.join(destDir, file)) } catch { /* ignore */ }
+ }
+ } catch (err) {
+ console.error('[Config] Failed to remove snapshot local backups:', err)
+ }
+}
+
// 手动立即备份一次(精确到秒时间戳,不覆盖当天的自动备份)
const backupConfigNow = (): { success: boolean, filename?: string, error?: string } => {
const backupDir = getConfigBackupDir()
@@ -236,6 +309,8 @@ global.lx = {
saveConfig: saveConfigToFile,
backupConfigNow,
getConfigBackupDir,
+ backupSnapshotToLocal,
+ removeSnapshotLocalBackups,
}
const mergeConfigFileEnv = (config: Partial>) => {
@@ -278,6 +353,14 @@ const margeConfig = (p: string) => {
if (config[key] !== undefined) newConfig[key] = config[key]
}
+ // [向后兼容] 旧版 Subsonic 独立端口靠 subsonic.port > 0 直接监听,无 standalone 开关概念。
+ // 升级到引入 subsonic.standalone(默认 false)的版本后,旧配置缺失该键会按默认值静默关闭独立端口,
+ // 造成「升级前已有端口值却被关掉」。故对「原始 config 缺开关键且有端口值(>0)」的旧配置默认视为开启;
+ // 仅当原始 config 未显式设置该键时生效(用户显式设为 false 则尊重其意图,不被覆盖)。
+ if (config['subsonic.standalone'] === undefined && (Number(config['subsonic.port']) || 0) > 0) {
+ newConfig['subsonic.standalone'] = true
+ }
+
console.log('[配置] 加载配置文件: ' + p)
if (newConfig.users.length) {
const users: LX.UserConfig[] = []
@@ -861,10 +944,44 @@ if (!fs.existsSync(openLibDir)) {
// 启动前最后保存一次合并后的配置,确保环境变量被固化到 config.js 中
saveConfigToFile()
-// 每日清理过期本地配置备份(保留最近 7 天)
+// 本地配置备份调度:每 N 小时一次,可选锚点时间(HH:MM)对齐到该时刻首次触发
+const getNextRunTime = (anchor: string, intervalMs: number, now: number): number => {
+ const m = /^(\d{1,2}):(\d{2})$/.exec(anchor)
+ if (!m) return now + intervalMs
+ const base = new Date(now)
+ base.setHours(Number(m[1]), Number(m[2]), 0, 0)
+ const diff = now - base.getTime()
+ let t = base.getTime() + Math.ceil(Math.max(diff, 0) / intervalMs) * intervalMs
+ if (t <= now) t += intervalMs
+ return t
+}
+let configBackupTimer: ReturnType | undefined
+const scheduleConfigBackup = () => {
+ const intervalHours = Math.max(1, Number(global.lx?.config?.['configBackup.intervalHours']) || 24)
+ const intervalMs = intervalHours * 60 * 60 * 1000
+ const anchor = String(global.lx?.config?.['configBackup.scheduleTime'] || '00:00').trim()
+ const now = Date.now()
+ const nextRun = getNextRunTime(anchor, intervalMs, now)
+ if (global.lx) global.lx.nextConfigBackupAt = nextRun
+ configBackupTimer = setTimeout(() => {
+ backupConfig()
+ scheduleConfigBackup()
+ }, nextRun - now)
+ if (typeof configBackupTimer.unref === 'function') configBackupTimer.unref()
+}
+
+// 启动即备份一次,并按当前配置调度后续自动备份
+backupConfig()
+scheduleConfigBackup()
+if (global.lx) global.lx.rescheduleConfigBackup = () => {
+ if (configBackupTimer) clearTimeout(configBackupTimer)
+ scheduleConfigBackup()
+}
+
+// 每日清理过期本地配置备份(保留最近 N 天;即便备份被禁用也照常执行)
cleanOldConfigBackups()
-const configBackupTimer = setInterval(cleanOldConfigBackups, 24 * 60 * 60 * 1000)
-if (typeof configBackupTimer.unref === 'function') configBackupTimer.unref()
+const configCleanupTimer = setInterval(cleanOldConfigBackups, 24 * 60 * 60 * 1000)
+if (typeof configCleanupTimer.unref === 'function') configCleanupTimer.unref()
startServer(global.lx.config.port, '0.0.0.0')
diff --git a/src/modules/dislike/dislikeDataManage.ts b/src/modules/dislike/dislikeDataManage.ts
index 142ffc52..84669200 100644
--- a/src/modules/dislike/dislikeDataManage.ts
+++ b/src/modules/dislike/dislikeDataManage.ts
@@ -77,6 +77,12 @@ export class DislikeDataManage {
.join('\n')
}
+ // 从指定快照内容恢复当前 dislike 数据(管理接口回滚用)
+ restore = async(data: LX.Dislike.DislikeRules) => {
+ this.dislikeRules = (data && Array.isArray(data.dislikeList)) ? { dislikeList: data.dislikeList } : { dislikeList: [] }
+ this.normalizeRules()
+ }
+
addDislikeInfo = async(infos: LX.Dislike.DislikeSongInfo[]) => {
// Check for duplicates
const currentRules = new Set(this.dislikeRules.dislikeList.map(s => this.buildDislikeRule(s)))
diff --git a/src/modules/dislike/manage.ts b/src/modules/dislike/manage.ts
index 52294ce8..fb03dfbb 100644
--- a/src/modules/dislike/manage.ts
+++ b/src/modules/dislike/manage.ts
@@ -51,5 +51,14 @@ export class DislikeManage {
getDislikeRules = async() => {
return await this.dislikeDataManage.getDislikeRules()
}
+
+ // 管理接口:将指定 dislike 快照恢复为当前数据(回滚)
+ restoreSnapshot = async(name: string) => {
+ const data = await this.snapshotDataManage.getSnapshot(name)
+ if (!data) throw new Error('Snapshot not found')
+ await this.dislikeDataManage.restore(data)
+ this.snapshotDataManage.clearClients()
+ this.snapshotDataManage.setLatest(name)
+ }
}
diff --git a/src/modules/dislike/snapshotDataManage.ts b/src/modules/dislike/snapshotDataManage.ts
index 6a69f13f..e388c8ff 100644
--- a/src/modules/dislike/snapshotDataManage.ts
+++ b/src/modules/dislike/snapshotDataManage.ts
@@ -76,6 +76,19 @@ export class SnapshotDataManage {
this.saveSnapshotInfoThrottle()
}
+ // 回滚时清空客户端快照键,迫使其下次同步重新拉取最新数据
+ clearClients = () => {
+ this.snapshotInfo.clients = {}
+ this.clientSnapshotKeys = []
+ this.saveSnapshotInfoThrottle()
+ }
+
+ // 将指定快照标记为 latest,使「当前 dislike 数据」回滚到该快照
+ setLatest = (name: string) => {
+ this.snapshotInfo.latest = name
+ this.saveSnapshotInfoThrottle()
+ }
+
getSnapshot = async(name: string) => {
const filePath = path.join(this.snapshotDir, `snapshot_${name}`)
let listData: LX.Dislike.DislikeRules
@@ -100,6 +113,10 @@ export class SnapshotDataManage {
const filePath = path.join(this.snapshotDir, `snapshot_${name}`)
try {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2))
+ // 不喜欢快照落盘后,防抖触发一次云端备份(与歌单快照、缓存落盘上传同一入口;按配置备份 snapshots 类型)
+ void (globalThis as any).lx?.scheduleOpenlistSync?.()
+ // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定)
+ void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name)
} catch (err) {
syncLog.error(err)
throw err
@@ -114,23 +131,102 @@ export class SnapshotDataManage {
} catch (err) {
syncLog.error(err)
}
+ // 一并清理本地备份目录中该快照的所有带时间戳历史副本(含旧版无时间戳副本),避免孤儿文件
+ void (globalThis as any).lx?.removeSnapshotLocalBackups?.(this.userDataManage.userName, name)
+ }
+
+ /** 列出不喜欢快照(与歌单快照 getSnapshotListWithMeta 同构,供 /api/data/snapshots 合并展示) */
+ getSnapshotListWithMeta = async () => {
+ const list: Array<{ id: string, time: number, size: number }> = []
+ try {
+ const files = await fs.promises.readdir(this.snapshotDir)
+ for (const file of files) {
+ if (!file.startsWith('snapshot_')) continue
+ const name = file.replace('snapshot_', '')
+ const filePath = path.join(this.snapshotDir, file)
+ try {
+ const stat = await fs.promises.stat(filePath)
+ list.push({ id: name, time: stat.mtimeMs, size: stat.size })
+ } catch { /* ignore missing files */ }
+ }
+ } catch (err) {
+ syncLog.error(err)
+ }
+ return list.sort((a, b) => b.time - a.time)
}
+ /**
+ * 动态更新快照目录并迁移历史数据(无需重启即时生效)
+ * 与歌单快照(//snapshot)区分,dislike 快照使用 //dislike/snapshot
+ * @param targetBackupPath 新的 snapshot.backupPath 配置字符串
+ */
+ updateSnapshotDir = (targetBackupPath?: string) => {
+ const backupPathConf = (targetBackupPath !== undefined ? targetBackupPath : (global.lx.config['snapshot.backupPath'] || '')).trim()
+ const oldSnapshotDir = this.snapshotDir
+ let newSnapshotDir = ''
+
+ if (backupPathConf) {
+ try {
+ const base = path.isAbsolute(backupPathConf)
+ ? backupPathConf
+ : path.join(global.lx.dataPath, backupPathConf)
+ newSnapshotDir = path.join(base, this.userDataManage.userName, File.dislikeDir, File.dislikeSnapshotDir)
+ checkAndCreateDirSync(newSnapshotDir)
+ } catch (err) {
+ syncLog.warn(`[DislikeSnapshot] 无法创建自定义快照目录 (${backupPathConf}),已安全回退到默认目录:`, err)
+ newSnapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir)
+ }
+ } else {
+ newSnapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir)
+ }
+ checkAndCreateDirSync(newSnapshotDir)
+
+ // 迁移旧快照目录中的历史文件
+ const sourceDir = oldSnapshotDir || path.join(this.dislikeDir, File.dislikeSnapshotDir)
+ if (newSnapshotDir !== sourceDir && fs.existsSync(sourceDir)) {
+ try {
+ let count = 0
+ for (const name of fs.readdirSync(sourceDir)) {
+ const src = path.join(sourceDir, name)
+ const dst = path.join(newSnapshotDir, name)
+ if (!fs.existsSync(dst)) {
+ try {
+ fs.renameSync(src, dst)
+ count++
+ } catch (e) {
+ syncLog.error(`[DislikeSnapshot] 迁移快照文件失败 (${name}):`, e)
+ }
+ }
+ }
+ if (count > 0) {
+ syncLog.info(`[DislikeSnapshot] 用户 ${this.userDataManage.userName} 不喜欢快照迁移成功 (共 ${count} 个文件 -> ${newSnapshotDir})`)
+ }
+ if (fs.existsSync(sourceDir) && fs.readdirSync(sourceDir).length === 0) {
+ try { fs.rmdirSync(sourceDir) } catch { }
+ }
+ } catch (err) {
+ syncLog.error(`[DislikeSnapshot] 用户 ${this.userDataManage.userName} 不喜欢快照迁移失败:`, err)
+ }
+ }
+
+ this.snapshotDir = newSnapshotDir
+ }
+
constructor(userDataManage: UserDataManage) {
this.userDataManage = userDataManage
this.dislikeDir = path.join(userDataManage.userDir, File.dislikeDir)
checkAndCreateDirSync(this.dislikeDir)
- this.snapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir)
- checkAndCreateDirSync(this.snapshotDir)
-
+ this.snapshotDir = ''
this.snapshotInfoFilePath = path.join(this.dislikeDir, File.dislikeSnapshotInfoJSON)
this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath)
? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString())
: { latest: null, time: 0, list: [], clients: {} }
+ this.updateSnapshotDir()
+
this.saveSnapshotInfoThrottle = throttle(() => {
fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => {
if (err) console.error(err)
diff --git a/src/modules/list/snapshotDataManage.ts b/src/modules/list/snapshotDataManage.ts
index 27bad221..c0982ecb 100644
--- a/src/modules/list/snapshotDataManage.ts
+++ b/src/modules/list/snapshotDataManage.ts
@@ -93,6 +93,10 @@ export class SnapshotDataManage {
const filePath = path.join(this.snapshotDir, `snapshot_${name}`)
try {
fs.writeFileSync(filePath, data)
+ // 歌单快照落盘后,防抖触发一次云端备份(与缓存落盘上传同一入口;按配置备份 snapshots 类型)
+ void (globalThis as any).lx?.scheduleOpenlistSync?.()
+ // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定)
+ void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name)
} catch (err) {
syncLog.error(err)
throw err
@@ -108,6 +112,10 @@ export class SnapshotDataManage {
const date = new Date(time)
fs.utimesSync(filePath, date, date)
}
+ // 歌单快照落盘后,防抖触发一次云端备份(与缓存落盘上传同一入口;按配置备份 snapshots 类型)
+ void (globalThis as any).lx?.scheduleOpenlistSync?.()
+ // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定)
+ void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name)
} catch (err) {
syncLog.error(err)
throw err
@@ -122,6 +130,8 @@ export class SnapshotDataManage {
} catch (err) {
syncLog.error(err)
}
+ // 一并清理本地备份目录中该快照的所有带时间戳历史副本(含旧版无时间戳副本),避免孤儿文件
+ void (globalThis as any).lx?.removeSnapshotLocalBackups?.(this.userDataManage.userName, name)
}
getSnapshotListWithMeta = async () => {
diff --git a/src/server/integrations/subsonic.ts b/src/server/integrations/subsonic.ts
index 2fa0aad3..a7215c30 100644
--- a/src/server/integrations/subsonic.ts
+++ b/src/server/integrations/subsonic.ts
@@ -7764,4 +7764,12 @@ class SubsonicHandler {
}
}
+/** 歌单/列表类内存缓存统一失效:回滚等修改权威歌单数据后调用,避免 Subsonic API 在 TTL 内返回陈旧数据 */
+export function invalidateSubsonicPlaylistCache(): void {
+ playlistDetailCache.clear()
+ playlistRadioCache = null
+ sharedPlaylistCoverCache.clear()
+ leaderboardCoverCache.clear()
+}
+
export const subsonicHandler = new SubsonicHandler()
diff --git a/src/server/openlistBackup.ts b/src/server/openlistBackup.ts
index 9737964c..b1666d0f 100644
--- a/src/server/openlistBackup.ts
+++ b/src/server/openlistBackup.ts
@@ -86,7 +86,6 @@ interface BackupConfig {
password: string
token: string
root: string
- keepLocalAfterUpload: boolean
verifyInterval: number
includeNonAudio: boolean
syncInterval: number
@@ -107,7 +106,6 @@ function getConfig(): BackupConfig {
password: c['openlist.password'] || '',
token: c['openlist.token'] || '',
root: c['openlist.root'] || '/lx_music',
- keepLocalAfterUpload: c['openlist.keepLocalAfterUpload'] !== false,
verifyInterval: Number(c['openlist.verifyInterval'] || 5),
includeNonAudio: c['openlist.includeNonAudio'] === true,
syncInterval: Number(c['openlist.syncInterval'] || 60),
@@ -130,19 +128,33 @@ function parseUserBackups(raw: any): UserBackupCfg[] {
/** 去掉结尾斜杠(兼容用户误填的 / 或 \),用于规范化 externalUrl */
const normalizeTrailingSlash = (u: string) => u.replace(/[\\/]+$/, '')
-/** 解析条目的有效模式:显式 mode 优先;否则沿用全局 keepLocalAfterUpload 的语义 */
-function effectiveMode(entry: UserBackupCfg, cfg: BackupConfig): SyncMode {
- if (entry.mode) return entry.mode
- return cfg.keepLocalAfterUpload === false ? 'upload-delete' : 'upload'
+// 这些 folder_type 属于“应用运行规则/用户数据”(系统配置、歌单与不喜欢快照、统一模型用户数据)。
+// 它们的本地副本必须保留:OpenList 的“不保留本地(upload-delete)”对它们不生效,
+// 否则删除本地会改变应用运行规则(配置丢失、歌单/快照无法恢复)。见业务约束。
+const PROTECTED_LOCAL_TYPES = new Set(['system', 'data', 'list'])
+
+/** 解析条目的有效模式:唯一来源为条目显式 mode;未配置时默认 upload(保留本地)。
+ * 「上传后删除本地」只能由显式 mode === 'upload-delete' 表达,不再有全局兜底开关。 */
+function effectiveMode(entry: UserBackupCfg, _cfg?: BackupConfig): SyncMode {
+ return entry.mode || 'upload'
}
-/** 从已有备份行推断其模式(用于验证/删除环节) */
+/** 从已有备份行推断其模式(用于验证/删除环节)。
+ * 受保护类型(系统配置/歌单与不喜欢快照/统一模型用户数据)禁止 upload-delete:
+ * 即便条目显式设为 upload-delete 或历史行残留该值,也回退为 upload,确保本地配置与快照永不被删除。
+ * 其余类型(cache/music)维持原语义,仅显式 upload-delete 会删本地。 */
function effectiveModeFromRow(row: any, cfg: BackupConfig): SyncMode {
- if (row.folder_type === 'data') return 'upload'
- const entry = cfg.userBackups.find(u => u.user === row.user && u.enabled)
- if (entry) return effectiveMode(entry, cfg)
- if (row.mode) return row.mode as SyncMode
- return cfg.keepLocalAfterUpload === false ? 'upload-delete' : 'upload'
+ const derive = (): SyncMode => {
+ const entry = cfg.userBackups.find(u => u.user === row.user && u.enabled)
+ if (entry) return effectiveMode(entry, cfg)
+ if (row.mode) return row.mode as SyncMode
+ return 'upload'
+ }
+ if (PROTECTED_LOCAL_TYPES.has(row.folder_type)) {
+ const m = derive()
+ return m === 'upload-delete' ? 'upload' : m
+ }
+ return derive()
}
function normalizeUser(u?: string): string {
@@ -254,6 +266,93 @@ function resolveUserDir(entry: UserBackupCfg, folderType: 'cache' | 'music' | 'l
}
}
+/** 所有真实用户(含系统用户 _open),用于快照类备份对所有用户生效,不受 userBackups 勾选限制。 */
+function getAllUserNames(): string[] {
+ const names: string[] = []
+ const users = (global.lx.config as any).users
+ if (Array.isArray(users)) for (const u of users) if (u && u.name) names.push(u.name)
+ if (!names.includes('_open')) names.push('_open')
+ return names
+}
+
+/**
+ * 统一数据备份(selectedData)模式下,歌单/不喜欢快照等记录的 user 被记为 _system,
+ * 但本地路径形如 dataPath/users//...(如 users/m_6f8f57/list/snapshot/xxx),
+ * 其中 是用户的真实数据目录名(与逻辑用户名未必相同,m -> m_6f8f57)。
+ * 此函数从路径反推真实逻辑用户名,供展示层按用户归位使用。
+ */
+let dirNameToUserCache: Record | null = null
+function buildDirNameToUserMap(): Record {
+ const map: Record = {}
+ for (const u of getAllUserNames()) {
+ try {
+ const d = getUserDirname(u)
+ if (d) map[d] = u
+ } catch { /* ignore */ }
+ }
+ return map
+}
+function resolveUsernameFromPath(p?: string | null): string | null {
+ if (!p) return null
+ const m = String(p).replace(/\\/g, '/').match(/\/users\/([^/]+)\//)
+ if (!m) return null
+ if (dirNameToUserCache === null) dirNameToUserCache = buildDirNameToUserMap()
+ return dirNameToUserCache[m[1]] || null
+}
+
+/** 解析某用户 dislike 快照目录(与歌单快照同一 snapshot.backupPath 体系,但使用独立的 dislike/snapshot 子目录,避免互相覆盖)。 */
+function resolveDislikeSnapshotDir(user: string): string {
+ const backupPathConf = ((global.lx.config as any)['snapshot.backupPath'] || '').trim()
+ let dir: string
+ if (backupPathConf) {
+ const base = path.isAbsolute(backupPathConf) ? backupPathConf : path.join(global.lx.dataPath, backupPathConf)
+ dir = path.join(base, user, File.dislikeDir, File.dislikeSnapshotDir)
+ } else {
+ const userDir = path.join(global.lx.userPath, getUserDirname(user))
+ dir = path.join(userDir, File.dislikeDir, File.dislikeSnapshotDir)
+ }
+ try { fs.mkdirSync(dir, { recursive: true }) } catch { /* ignore */ }
+ return dir
+}
+
+/** 上传单个用户某类快照目录(list 或 dislike)到 OpenList,folder_type 仍记 'list'(复用旧表字段,避免 schema 变更)。 */
+async function backupUserSnapshotKind(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary, user: string, kind: 'list' | 'dislike'): Promise {
+ const dir = kind === 'list' ? resolveSnapshotDir(user) : resolveDislikeSnapshotDir(user)
+ if (!dir || !fs.existsSync(dir)) return
+ for (const lp of walkLocal(dir, 'list')) {
+ const rel = path.relative(dir, lp).replace(/\\/g, '/')
+ // 传统布局下 dislike 快照置于 user/list/dislike/ 子目录,与歌单快照区分;统一数据模式(selectedData)不走此路。
+ const remoteSub = kind === 'dislike' ? 'dislike/' : ''
+ const remotePath = cl.resolveRemotePath(`${user}/list/${remoteSub}${rel}`)
+ const existing = getBackupByLocalPath(user, 'list', lp)
+ if ((existing?.deleted_at || existing?.status === 'deleted') && !localChanged(existing)) continue
+ if (existing && (enqueuedIds.has(`f:${existing.id}`) || existing.status === 'completed' || existing.status === 'uploading')) continue
+ if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES) continue
+ if (existing?.status === 'verified' && existing.remote_path === remotePath && !localChanged(existing)) {
+ try {
+ const remote = await cl.getFileInfo(remotePath)
+ const modified = parseMod(remote.modified)
+ if (remote.size === existing.size && (modified == null || existing.remote_mtime == null || modified === existing.remote_mtime)) continue
+ } catch (e: any) {
+ if (!(e instanceof OpenListNotFoundError)) { summary.errors.push(rel + ': ' + (e?.message || e)); continue }
+ }
+ }
+ const row = upsertBackupFile({ user, folderType: 'list', localPath: lp, remotePath, name: rel, mode: 'upload', direction: 'upload', status: 'pending' })
+ enqueueUpload(row, cl, 'upload')
+ summary.uploaded++
+ }
+}
+
+/** 对所有真实用户备份歌单与不喜欢快照(传统模式下脱离 userBackups 用户勾选;统一数据模式下仅在手动 folder='list' 时补充)。 */
+async function backupAllSnapshots(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary, opts: { user?: string; folder?: 'cache' | 'music' | 'list' }): Promise {
+ if (opts.folder && opts.folder !== 'list') return
+ for (const user of getAllUserNames()) {
+ if (opts.user && user !== opts.user) continue
+ await backupUserSnapshotKind(cl, cfg, summary, user, 'list')
+ await backupUserSnapshotKind(cl, cfg, summary, user, 'dislike')
+ }
+}
+
function isSubPath(child: string, parent: string): boolean {
const np = path.normalize(parent)
const nc = path.normalize(child)
@@ -724,6 +823,69 @@ export function onFileCached(
lazyStartScheduler()
}
+/**
+ * 保存配置、某用户模式变为 upload-delete 时,对本地 cache/music 存量文件触发一次性迁移:
+ * - 已有 verified/completed 远端记录且本地未变 → 跳过上传,直接删除本地(含已 verified 附件)
+ * - 其余本地文件 → 登记为 upload-delete 并上传,verifyPending 后续按大小一致确认后删除本地
+ * 仅 cache/music 生效;system/data/list 受 PROTECTED_LOCAL_TYPES 保护,永不删本地。
+ */
+export async function triggerBackfillUploadDelete(user: string): Promise {
+ const cfg = getConfig()
+ if (!cfg.enable) return
+ const entry = cfg.userBackups.find(u => u.user === user && u.enabled)
+ if (!entry) return
+ const cl = getClient()
+ if (!cl) return
+ ensureBackupTables()
+
+ const folders: Array<'cache' | 'music'> = ['cache', 'music']
+ for (const folderType of folders) {
+ if (!folderEnabled(user, folderType, cfg)) continue
+ const dir = resolveUserDir(entry, folderType, cfg)
+ if (!dir || !fs.existsSync(dir)) continue
+ const locals = walkLocal(dir, folderType)
+ for (const localPath of locals) {
+ const existing = getBackupByLocalPath(user, folderType, localPath)
+ // 分支1:远端已存在且已确认 → 跳过上传,直接删本地(含已 verified 附件)
+ if (existing && !existing.deleted_at && (existing.status === 'verified' || existing.status === 'completed') && !localChanged(existing)) {
+ try {
+ if (existing.local_path && fs.existsSync(existing.local_path)) {
+ fs.unlinkSync(existing.local_path)
+ removeLocalCache(existing)
+ softDeleteLocalFile(existing.user, existing.local_path)
+ }
+ for (const a of listAttachments(existing.id)) {
+ if (a.status === 'verified' && a.local_path && fs.existsSync(a.local_path) && countLocalPathRefs(a.local_path, existing.id, a.id) === 0) {
+ try { fs.unlinkSync(a.local_path); softDeleteLocalFile(a.user, a.local_path) } catch { /* ignore */ }
+ }
+ }
+ } catch { /* ignore */ }
+ continue
+ }
+ // 分支2:登记为 upload-delete 并上传,verifyPending 后续按大小校验删除本地
+ const relative = path.relative(dir, localPath).replace(/\\/g, '/')
+ const remotePath = cl.resolveRemotePath(`${user}/${folderType}/${relative}`)
+ const item = getIndexItemByFilename(path.basename(localPath), user)
+ const row = upsertBackupFile({
+ user,
+ folderType,
+ localPath,
+ remotePath,
+ source: existing?.source || null,
+ songmid: existing?.songmid || null,
+ quality: existing?.quality || null,
+ mode: 'upload-delete',
+ status: 'pending',
+ direction: 'upload',
+ cacheMetadata: item ? JSON.stringify(item) : existing?.cache_metadata,
+ })
+ void enqueueUpload(row, cl, 'upload-delete')
+ if (cfg.includeNonAudio) queueAttachmentsForFile(user, folderType, localPath, row, dir, cl, cfg)
+ }
+ }
+ lazyStartScheduler()
+}
+
/**
* 订阅通用「文件落盘」事件(由 fileCache 在音频写入后发出,事件本身与 openlist 无关),
* 落盘即排程一次防抖全量同步。这样 fileCache 不直接依赖 openlist 实现,仅发中性事件。
@@ -782,7 +944,7 @@ export async function verifyPending(): Promise {
continue
}
const mode = effectiveModeFromRow(row, cfg)
- if (mode === 'upload-delete' && row.local_path && fs.existsSync(row.local_path)) {
+ if (mode === 'upload-delete' && !PROTECTED_LOCAL_TYPES.has(row.folder_type) && row.local_path && fs.existsSync(row.local_path)) {
if (!row.local_hash || !await cl.verifyFile(row.remote_path, row.local_hash) || hashFile(row.local_path) !== row.local_hash) {
setBackupStatus(row.id, 'failed', { error: '远端内容校验失败,保留本地文件', retryCount: (row.retry_count || 0) + 1 })
continue
@@ -790,7 +952,7 @@ export async function verifyPending(): Promise {
}
if (!rowEnabled(row) || getBackupById(row.id)?.deleted_at) continue
setBackupStatus(row.id, 'verified', { size: info.size, remotePath: row.remote_path, remoteMtime: parseMod(info.modified), error: null, retryCount: 0 })
- if (mode === 'upload-delete' && row.local_path && fs.existsSync(row.local_path)) {
+ if (mode === 'upload-delete' && !PROTECTED_LOCAL_TYPES.has(row.folder_type) && row.local_path && fs.existsSync(row.local_path)) {
try {
fs.unlinkSync(row.local_path)
removeLocalCache(row)
@@ -982,7 +1144,8 @@ async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg:
async function uploadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: BackupConfig, folderType: 'cache' | 'music' | 'list', summary: SyncSummary): Promise {
const dir = resolveUserDir(entry, folderType, cfg)
if (!dir) return
- const mode = effectiveMode(entry, cfg)
+ // list 是歌单/不喜欢快照,属受保护类型:upload-delete 对其不生效,入库模式固定为 upload
+ const mode = folderType === 'list' ? 'upload' : effectiveMode(entry, cfg)
for (const lp of walkLocal(dir, folderType)) {
const rel = path.relative(dir, lp).replace(/\\/g, '/')
const remotePath = cl.resolveRemotePath(entry.user + '/' + folderType + '/' + rel)
@@ -1066,6 +1229,55 @@ async function backupSystemConfigFile(cl: OpenListClient, _cfg: BackupConfig, su
summary.uploaded++
}
+/**
+ * 额外保留带时间戳的系统配置「时间点副本」,用于回溯历史配置。
+ * - 主镜像(_data/config.js 或 _system/config.js)文件名保持不变,恢复逻辑只扫 _data,
+ * 因此历史副本放在独立的 _system/config-history/ 下,不参与恢复、也不会被 backupType 识别。
+ * - 文件名含内容指纹(md5 前 8 位):配置未变化时不会重复产生副本。
+ * - 仅当系统配置确实被云端备份时才触发(统一模型的 system 类型 / 旧模型 backupSystemConfig),
+ * 并按 configBackup.retentionDays 清理过期的历史副本。
+ */
+let lastConfigHistoryHash = ''
+async function backupSystemConfigHistory(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary): Promise {
+ const lx: any = (globalThis as any).lx
+ const configPath: string = lx?.configPath || process.env.CONFIG_PATH || path.join(lx?.dataPath || '.', 'config.js')
+ if (!configPath || !fs.existsSync(configPath)) return
+ const selectedData = lx?.config?.['backup.openlist.types'] !== undefined
+ const shouldBackupSystem = selectedData
+ ? getBackupTypes('openlist', lx?.config as any).includes('system')
+ : cfg.backupSystemConfig
+ if (!shouldBackupSystem) return
+
+ const fingerprint = crypto.createHash('md5').update(fs.readFileSync(configPath)).digest('hex').slice(0, 8)
+ if (fingerprint === lastConfigHistoryHash) return
+
+ const historyDir = cl.resolveRemotePath('_system/config-history')
+ await cl.mkdir(historyDir)
+ let files: Awaited> = []
+ try { files = await cl.listFilesRecursive(historyDir) } catch { /* 空目录/列举失败按空处理 */ }
+ const historyNameRe = /^config-\d{4}-\d{2}-\d{2}_\d{6}-[0-9a-f]{8}\.js$/
+ // 进程重启后内存指纹丢失,用远端已有的同名指纹文件兜底去重
+ if (files.some(f => !f.isDir && f.name.endsWith(`-${fingerprint}.js`) && historyNameRe.test(f.name))) {
+ lastConfigHistoryHash = fingerprint
+ return
+ }
+ const d = new Date()
+ const pad = (n: number) => String(n).padStart(2, '0')
+ const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`
+ await cl.upload(configPath, `${historyDir}/config-${stamp}-${fingerprint}.js`)
+ lastConfigHistoryHash = fingerprint
+ summary.uploaded++
+
+ const days = Number(lx?.config?.['configBackup.retentionDays'])
+ const cutoff = Date.now() - (Number.isFinite(days) && days > 0 ? days : 7) * 24 * 60 * 60 * 1000
+ for (const f of files) {
+ if (f.isDir || !historyNameRe.test(f.name)) continue
+ const modified = parseMod(f.modified)
+ if (modified == null || modified >= cutoff) continue
+ try { await cl.remove(f.path) } catch { /* ignore */ }
+ }
+}
+
function dataBackupContext() {
return { dataPath: global.lx.dataPath, configPath: global.lx.configPath, config: global.lx.config as any }
}
@@ -1154,7 +1366,7 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | '
if (!entry.enabled) continue
if (opts.user && entry.user !== opts.user) continue
const mode = effectiveMode(entry, cfg)
- const folders: Array<'cache' | 'music' | 'list'> = opts.folder ? [opts.folder] : (selectedData ? ['cache'] : ['cache', 'music', 'list'])
+ const folders: Array<'cache' | 'music'> = opts.folder && opts.folder !== 'list' ? [opts.folder] : (selectedData ? ['cache'] : ['cache', 'music'])
for (const ft of folders) {
if (selectedData && ft !== 'cache') continue
const dir = resolveUserDir(entry, ft, cfg)
@@ -1173,6 +1385,10 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | '
}
}
if (selectedData && !opts.user && !opts.folder) await backupSelectedData(cl, summary)
+ // 歌单与不喜欢快照:对所有真实用户备份,不受 userBackups 用户勾选限制(传统模式默认走此路;统一数据模式仅手动 folder='list' 时补充)
+ if (!selectedData || opts.folder === 'list') {
+ await backupAllSnapshots(cl, cfg, summary, opts)
+ }
while (activeUploads || uploadQueue.length) await sleep(20)
for (const row of listBackupByStatus('failed')) {
if (rowEnabled(row, cfg) && (!opts.user || row.user === opts.user) && (!opts.folder || row.folder_type === opts.folder)) summary.errors.push(row.user + '/' + row.name + ': ' + row.error)
@@ -1184,6 +1400,14 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | '
summary.errors.push('system-config: ' + (e?.message || e))
}
}
+ // 全量同步时额外留一份带时间戳的系统配置时间点副本(主镜像文件名不变)
+ if (!opts.user && !opts.folder) {
+ try {
+ await backupSystemConfigHistory(cl, cfg, summary)
+ } catch (e: any) {
+ summary.errors.push('system-config-history: ' + (e?.message || e))
+ }
+ }
return summary
}
@@ -1203,7 +1427,13 @@ export function listBackups(): any[] {
WHERE f.deleted_at IS NULL
ORDER BY f.updated_at DESC
`).all() as any[]
- return files.map((f) => ({ ...f, local_exists: !!f.local_exists, attachments: listAttachments(f.id) }))
+ return files.map((f) => {
+ // 统一数据备份模式下,歌单/不喜欢快照等记录的 user 被记为 _system,但路径里含 users/;
+ // 此处按路径映射回真实逻辑用户名,使前端按用户筛选时能正确归位(如 m_6f8f57 -> m)。
+ // 仅覆盖展示层 user 字段,不改写 DB,避免破坏 openlist_local_files 的 user 一致性与唯一约束。
+ const user = f.user === '_system' ? (resolveUsernameFromPath(f.local_path || f.remote_path) || '_system') : f.user
+ return { ...f, user, local_exists: !!f.local_exists, attachments: listAttachments(f.id) }
+ })
}
/**
diff --git a/src/server/server.ts b/src/server/server.ts
index d70574d9..76abc943 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -19,6 +19,7 @@ import { parseDislikeRules, splitSingers } from '@/modules/dislike/match'
import { encodeAlbumRule } from '@/modules/dislike/utils'
import { normalizeText } from '@/server/utils/songVersion'
import { invalidateDislikeCache } from '@/server/utils/dislikeCache'
+import { invalidateSubsonicPlaylistCache } from '@/server/integrations/subsonic'
import { createMsg2call } from 'message2call'
import { handleFileManager } from './integrations/fileManager'
import formidable from 'formidable'
@@ -26,7 +27,7 @@ import formidable from 'formidable'
import musicSdkRaw from '@/modules/utils/musicSdk/index.js'
const musicSdk = musicSdkRaw as any
import { initUserApis, callUserApiGetMusicUrl, isSourceSupported, getLoadedApis, getLoadedApisCount } from './sources/userApi'
-import { startVerifyScheduler, runSyncNow, listBackups, retryBackup, deleteOpenlistBackups, registerFileCacheSyncHook, restoreOpenlistData } from '@/server/openlistBackup'
+import { startVerifyScheduler, runSyncNow, listBackups, retryBackup, deleteOpenlistBackups, registerFileCacheSyncHook, restoreOpenlistData, triggerBackfillUploadDelete } from '@/server/openlistBackup'
import { BACKUP_TYPE_INFO, getBackupTypes, normalizeBackupTypes } from '@/utils/backupPolicy'
import { OpenListClient } from '@/server/openlistClient'
@@ -1945,7 +1946,12 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
const userSpace = getUserSpace(verifiedUser)
try {
- const list = await userSpace.listManage.getSnapshotList()
+ const listSnaps = await userSpace.listManage.getSnapshotList()
+ const dislikeSnaps = userSpace.dislikeManage ? await userSpace.dislikeManage.snapshotDataManage.getSnapshotListWithMeta() : []
+ const list = [
+ ...listSnaps.map(s => ({ ...s, kind: 'list' })),
+ ...dislikeSnaps.map(s => ({ ...s, kind: 'dislike' })),
+ ].sort((a, b) => (b.time || 0) - (a.time || 0))
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
@@ -1992,7 +1998,10 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
return
}
try {
- const data = await userSpace.listManage.getSnapshot(id)
+ let data: any = await userSpace.listManage.getSnapshot(id)
+ if (!data && userSpace.dislikeManage) {
+ data = await userSpace.dislikeManage.snapshotDataManage.getSnapshot(id)
+ }
if (!data) {
res.writeHead(404)
res.end('Not Found')
@@ -2039,10 +2048,16 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
const userSpace = getUserSpace(verifiedUser)
try {
const body = await readBody(req)
- const { id } = JSON.parse(body)
+ const { id, kind } = JSON.parse(body)
if (!id) throw new Error('Missing id')
- await userSpace.listManage.restoreSnapshot(id)
+ if (kind === 'dislike' && userSpace.dislikeManage) {
+ await userSpace.dislikeManage.restoreSnapshot(id)
+ invalidateDislikeCache(verifiedUser)
+ } else {
+ await userSpace.listManage.restoreSnapshot(id)
+ invalidateSubsonicPlaylistCache()
+ }
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
@@ -2239,8 +2254,12 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
const { id } = JSON.parse(body)
if (!id) throw new Error('Missing id')
- // 调用刚刚在 ListManage 中添加的方法
- await userSpace.listManage.removeSnapshot(id)
+ let removed = false
+ try { await userSpace.listManage.removeSnapshot(id); removed = true } catch { /* 尝试不喜欢快照 */ }
+ if (!removed && userSpace.dislikeManage) {
+ try { await userSpace.dislikeManage.snapshotDataManage.removeSnapshot(id); removed = true } catch { /* ignore */ }
+ }
+ if (!removed) throw new Error('快照不存在或删除失败')
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ success: true }))
@@ -7302,6 +7321,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
'system.allowUnsafeVM': global.lx.config['system.allowUnsafeVM'] || false,
'configBackup.enable': global.lx.config['configBackup.enable'] ?? true,
'configBackup.retentionDays': global.lx.config['configBackup.retentionDays'] ?? 7,
+ 'configBackup.intervalHours': global.lx.config['configBackup.intervalHours'] ?? 24,
+ 'configBackup.scheduleTime': global.lx.config['configBackup.scheduleTime'] ?? '00:00',
'configBackup.dir': global.lx.config['configBackup.dir'] ?? '',
'snapshot.backupPath': global.lx.config['snapshot.backupPath'] ?? '',
'openlist.enable': global.lx.config['openlist.enable'] ?? false,
@@ -7310,7 +7331,6 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
'openlist.password': global.lx.config['openlist.password'] || '',
'openlist.token': global.lx.config['openlist.token'] || '',
'openlist.root': global.lx.config['openlist.root'] || '/lx_music',
- 'openlist.keepLocalAfterUpload': global.lx.config['openlist.keepLocalAfterUpload'] !== false,
'openlist.verifyInterval': global.lx.config['openlist.verifyInterval'] || 5,
'openlist.syncInterval': global.lx.config['openlist.syncInterval'] || 60,
'openlist.userBackups': global.lx.config['openlist.userBackups'] || '[]',
@@ -7503,7 +7523,6 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
if (newConfig['openlist.password'] !== undefined) global.lx.config['openlist.password'] = newConfig['openlist.password']
if (newConfig['openlist.token'] !== undefined) global.lx.config['openlist.token'] = newConfig['openlist.token']
if (newConfig['openlist.root'] !== undefined) global.lx.config['openlist.root'] = newConfig['openlist.root']
- if (newConfig['openlist.keepLocalAfterUpload'] !== undefined) global.lx.config['openlist.keepLocalAfterUpload'] = newConfig['openlist.keepLocalAfterUpload']
if (newConfig['openlist.verifyInterval'] !== undefined) global.lx.config['openlist.verifyInterval'] = parseInt(newConfig['openlist.verifyInterval']) || 5
if (newConfig['openlist.syncInterval'] !== undefined) global.lx.config['openlist.syncInterval'] = parseInt(newConfig['openlist.syncInterval']) || 60
if (newConfig['openlist.userBackups'] !== undefined) global.lx.config['openlist.userBackups'] = newConfig['openlist.userBackups']
@@ -7533,11 +7552,24 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
}
if (newConfig['subsonic.enableDebug'] !== undefined) global.lx.config['subsonic.enableDebug'] = newConfig['subsonic.enableDebug']
// [本地配置备份] configBackup 配置
- if (newConfig['configBackup.enable'] !== undefined) global.lx.config['configBackup.enable'] = !!newConfig['configBackup.enable']
+ let configBackupChanged = false
+ if (newConfig['configBackup.enable'] !== undefined) { global.lx.config['configBackup.enable'] = !!newConfig['configBackup.enable']; configBackupChanged = true }
if (newConfig['configBackup.retentionDays'] !== undefined) {
const rd = Number(newConfig['configBackup.retentionDays'])
global.lx.config['configBackup.retentionDays'] = Number.isFinite(rd) && rd > 0 ? Math.floor(rd) : 7
+ configBackupChanged = true
}
+ if (newConfig['configBackup.intervalHours'] !== undefined) {
+ const ih = parseInt(newConfig['configBackup.intervalHours'])
+ global.lx.config['configBackup.intervalHours'] = Number.isFinite(ih) && ih >= 1 && ih <= 8760 ? Math.floor(ih) : 24
+ configBackupChanged = true
+ }
+ if (newConfig['configBackup.scheduleTime'] !== undefined) {
+ const st = String(newConfig['configBackup.scheduleTime']).trim()
+ global.lx.config['configBackup.scheduleTime'] = /^([01]?\d|2[0-3]):[0-5]\d$/.test(st) ? st : ''
+ configBackupChanged = true
+ }
+ if (configBackupChanged) global.lx.rescheduleConfigBackup?.()
if (newConfig['subsonic.onlineSearch'] !== undefined) global.lx.config['subsonic.onlineSearch'] = newConfig['subsonic.onlineSearch']
if (newConfig['subsonic.onlineSearchMode'] !== undefined) global.lx.config['subsonic.onlineSearchMode'] = newConfig['subsonic.onlineSearchMode']
if (newConfig['subsonic.onlineSearchSources'] !== undefined) global.lx.config['subsonic.onlineSearchSources'] = newConfig['subsonic.onlineSearchSources']
@@ -7578,9 +7610,12 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
const raw = global.lx.config['openlist.userBackups']
let rules: any[] = []
try { rules = typeof raw === 'string' ? JSON.parse(raw) : raw || [] } catch { /* 校验警告已返回 */ }
- if (Array.isArray(rules) && rules.some(r => r?.enabled && (global.lx.config['backup.openlist.types'] === undefined || r.backupCache === true) && (r.mode === 'upload-delete' || (!r.mode && global.lx.config['openlist.keepLocalAfterUpload'] === false)))) {
+ const udRules = Array.isArray(rules) ? rules.filter(r => r?.enabled && (global.lx.config['backup.openlist.types'] === undefined || r.backupCache === true) && r.mode === 'upload-delete') : []
+ if (udRules.length) {
global.lx.config['subsonic.playCacheFirst'] = true
global.lx.config['subsonic.playCacheFirstUseOpenlist'] = true
+ // 保存为 upload-delete 时,对存量本地文件触发一次性迁移(异步,不阻塞保存响应)
+ for (const r of udRules) void triggerBackfillUploadDelete(r.user)
}
}
if (newConfig['subsonic.quality.enabled'] !== undefined) global.lx.config['subsonic.quality.enabled'] = !!newConfig['subsonic.quality.enabled']
@@ -7674,14 +7709,19 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
// 优先从文件名解析精确时间戳
let timestamp = 0
const manualMatch = file.match(/^config-manual-(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})\.js$/)
- const autoMatch = file.match(/^config-(\d{4})-(\d{2})-(\d{2})\.js$/)
+ const autoMatch = file.match(/^config-(\d{4})-(\d{2})-(\d{2})(?:-(\d{2})(\d{2}))?\.js$/)
if (manualMatch) {
const [, y, m, d, h, min, s] = manualMatch
timestamp = new Date(Number(y), Number(m) - 1, Number(d), Number(h), Number(min), Number(s)).getTime()
} else if (autoMatch) {
- // 自动备份若与 mtime/birthtime 在同一天,优先使用文件修改时间以展示具体时刻;否则取日期当天 00:00
- timestamp = stat.mtimeMs || stat.birthtimeMs || 0
+ // 带时分后缀的文件解析为精确时刻;否则使用文件修改时间以展示具体时刻
+ if (autoMatch[4] !== undefined) {
+ const [, y, m, d, h, min] = autoMatch
+ timestamp = new Date(Number(y), Number(m) - 1, Number(d), Number(h), Number(min), 0).getTime()
+ } else {
+ timestamp = stat.mtimeMs || stat.birthtimeMs || 0
+ }
}
if (!timestamp) {
@@ -7710,6 +7750,10 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
backupDir,
autoBackupEnabled: global.lx.config['configBackup.enable'] !== false,
retentionDays: global.lx.config['configBackup.retentionDays'] || 7,
+ intervalHours: global.lx.config['configBackup.intervalHours'] || 24,
+ scheduleTime: global.lx.config['configBackup.scheduleTime'] || '00:00',
+ nextBackupAt: global.lx.nextConfigBackupAt || 0,
+ lastBackupAt: global.lx.lastConfigBackupAt || 0,
list,
}))
} catch (err: any) {
@@ -7893,6 +7937,42 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi
return
}
+ // [OpenList 同步 API] 批量重试备份记录(对选中的失败/异常项逐一提交重试)
+ if (pathname === '/api/openlist/retry-batch' && req.method === 'POST') {
+ const auth = req.headers['x-frontend-auth']
+ if (auth !== global.lx.config['admin.password']) {
+ res.writeHead(401, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ success: false, error: 'Unauthorized' }))
+ return
+ }
+ void readBody(req).then(async (body) => {
+ try {
+ const cfg = JSON.parse(body || '{}')
+ const ids = Array.isArray(cfg.ids) ? cfg.ids.map((x: any) => Number(x)).filter((x: number) => x > 0) : []
+ if (!ids.length) {
+ res.writeHead(422, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ success: false, error: '缺少有效的 id 列表' }))
+ return
+ }
+ let ok = 0
+ let fail = 0
+ for (const id of ids) {
+ try {
+ const rb = await retryBackup(id)
+ if (rb && rb.ok) ok++
+ else fail++
+ } catch { fail++ }
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ success: true, retried: ok, failed: fail }))
+ } catch (e: any) {
+ res.writeHead(500, { 'Content-Type': 'application/json' })
+ res.end(JSON.stringify({ success: false, error: e?.message || String(e) }))
+ }
+ })
+ return
+ }
+
if (pathname === '/api/config/backups/download' && req.method === 'GET') {
const auth = req.headers['x-frontend-auth']
if (auth !== global.lx.config['admin.password']) {
@@ -9149,6 +9229,12 @@ export const startServer = async (port: number, ip: string) => {
// if (status.status) await handleStopServer()
startupLog.info(`starting sync server in ${process.env.NODE_ENV == 'production' ? 'production' : 'development'}`)
+ let appVersion = 'unknown'
+ try {
+ const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8'))
+ if (pkg && pkg.version) appVersion = String(pkg.version)
+ } catch { }
+ startupLog.info(`LX Music Server version: v${appVersion}`)
const proxyEnabled = global.lx.config['proxy.all.enabled']
const proxyAddress = global.lx.config['proxy.all.address']
console.log(`[网络代理] 音乐 SDK 代理状态: ${proxyEnabled ? `已启用 (${proxyAddress})` : '未启用'}`)
diff --git a/src/server/sources/userApi.ts b/src/server/sources/userApi.ts
index 30ce91ca..6ba45bf2 100644
--- a/src/server/sources/userApi.ts
+++ b/src/server/sources/userApi.ts
@@ -368,7 +368,7 @@ export async function loadUserApi(apiInfo: UserApiInfo): Promise {
count: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).count?.(...args) },
countReset: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).countReset?.(...args) },
assert: (condition?: boolean, ...data: any[]) => { if (global.lx.config['debug.enabled']) console.assert(condition, ...data) },
- dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) console.dir(...args) },
+ dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dir(...args) },
dirxml: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dirxml?.(...args) },
error: (...args: any[]) => { console.error(...args) }
}
diff --git a/src/types/app.d.ts b/src/types/app.d.ts
index 8924f9d6..1b7852f6 100644
--- a/src/types/app.d.ts
+++ b/src/types/app.d.ts
@@ -13,6 +13,11 @@ declare global {
saveConfig: () => void
backupConfigNow?: () => { success: boolean, filename?: string, error?: string }
getConfigBackupDir?: () => string
+ backupSnapshotToLocal?: (srcPath: string, user: string, name: string) => void
+ removeSnapshotLocalBackups?: (user: string, name: string) => void
+ nextConfigBackupAt?: number
+ lastConfigBackupAt?: number
+ rescheduleConfigBackup?: () => void
lastCpuSample?: { idle: number, total: number }
lastProcessSample?: { cpu: NodeJS.CpuUsage, time: number }
subsonicPortConflict?: {
diff --git a/src/types/config.d.ts b/src/types/config.d.ts
index 5916f99c..80dcee0d 100644
--- a/src/types/config.d.ts
+++ b/src/types/config.d.ts
@@ -238,6 +238,18 @@ declare namespace LX {
*/
'configBackup.dir'?: string
+ /**
+ * 本地配置自动备份周期(小时),默认 24;留空或非法值回退为 24
+ */
+ 'configBackup.intervalHours'?: number
+
+ /**
+ * 本地配置自动备份锚点时间(HH:MM),默认 00:00(即每天 0 点)。
+ * 若设置,则在该时刻首次触发,之后每 intervalHours 小时循环;
+ * 留空(或清除)则按每天 00:00(每 intervalHours 小时,从 00:00 起)触发
+ */
+ 'configBackup.scheduleTime'?: string
+
/**
* 歌单快照额外备份路径(留空则存于用户数据目录 list/snapshot)。
* 相对路径基于 data 目录,绝对路径直接使用
@@ -544,8 +556,6 @@ declare namespace LX {
'openlist.token'?: string
/** OpenList 中 lx-music 备份根目录 */
'openlist.root'?: string
- /** 上传 OpenList 后是否保留本地文件 */
- 'openlist.keepLocalAfterUpload'?: boolean
/** 远端可用性校验周期(分钟) */
'openlist.verifyInterval'?: number
/** 双向同步周期(分钟) */
diff --git a/src/user/index.ts b/src/user/index.ts
index 32e3f4b5..5d1ccf95 100644
--- a/src/user/index.ts
+++ b/src/user/index.ts
@@ -93,6 +93,11 @@ export const updateAllUserSnapshotDirs = (newBackupPath?: string) => {
} catch (e) {
console.error(`[Snapshot] Failed to hot-update snapshot dir for user ${name}:`, e)
}
+ try {
+ space.dislikeManage.snapshotDataManage.updateSnapshotDir(newBackupPath)
+ } catch (e) {
+ console.error(`[DislikeSnapshot] Failed to hot-update snapshot dir for user ${name}:`, e)
+ }
}
}
diff --git a/src/utils/backupPolicy.ts b/src/utils/backupPolicy.ts
index 9797258c..b579351c 100644
--- a/src/utils/backupPolicy.ts
+++ b/src/utils/backupPolicy.ts
@@ -100,9 +100,12 @@ export function scanBackupFiles(context: BackupContext, types: readonly BackupTy
const snapshots = String(context.config?.['snapshot.backupPath'] || '')
if (types.includes('snapshots') && snapshots) {
const base = path.isAbsolute(snapshots) ? snapshots : path.join(context.dataPath, snapshots)
- // 外部快照目录只映射每个用户的 snapshot 子目录。
+ // 外部快照目录只映射每个用户的 snapshot 子目录(歌单与 dislike)。
if (fs.existsSync(base)) for (const entry of fs.readdirSync(base, { withFileTypes: true })) {
- if (entry.isDirectory() && !entry.isSymbolicLink()) walk(path.join(base, entry.name, 'snapshot'), `_snapshots/${entry.name}/snapshot/`)
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
+ walk(path.join(base, entry.name, 'snapshot'), `_snapshots/${entry.name}/snapshot/`)
+ walk(path.join(base, entry.name, 'dislike', 'snapshot'), `_snapshots/${entry.name}/dislike/snapshot/`)
+ }
}
}
return files