()
- set.albums.set(albumName, sSet)
+ scoped.albums.set(albumName, sSet)
}
const singers = splitSingers(x.artistName)
for (const s of singers) sSet.add(s)
@@ -70,8 +71,9 @@ export async function getCachedDislikeRuleSet(
if (Array.isArray(artistsArr)) {
const { normalizeText } = require('@/modules/dislike/match')
for (const x of artistsArr) {
+ const scoped = scopeDislikeRules(set, x.source)
const singerName = normalizeText(String(x.name || ''))
- if (singerName) set.singerNames.add(singerName)
+ if (singerName) scoped.singerNames.add(singerName)
}
}
}
diff --git a/src/types/config.d.ts b/src/types/config.d.ts
index f47d6279..84e32526 100644
--- a/src/types/config.d.ts
+++ b/src/types/config.d.ts
@@ -394,12 +394,16 @@ declare namespace LX {
*/
'subsonic.linkRatingToDislike'?: boolean
+
+
/**
- * 解耦开关(反向): 不喜欢 -> 评星 是否自动联动 (默认 false)
- * true 时, 网页端 / Subsonic 给某歌点「不喜欢」会回写该用户 subsonic-meta.json 的 ratings 映射(低星),
- * 使 Subsonic 客户端能看到这颗星; false 时, 「不喜欢」仅写入原生 dislike 规则, 不回写评星, 与评星解耦。
+ * 是否向 Subsonic 客户端暴露 1~5 星评星歌单 (默认 true)
+ * 虚拟只读歌单(不落盘、不影响 LX 客户端「我的歌单」):id 为 rating_1 ~ rating_5。
+ * rating_1 承载「我不喜欢」:除 1 星歌曲外并入 dislike 规则命中的歌曲,无真实评分时显示 1 星;
+ * 仅控制显示;自动联动分别受对应开关控制,仅补充缺失数据,
+ * 便于只能用评星操作的 Subsonic 客户端通过 1 星查找并处理不喜欢。
*/
- 'subsonic.linkDislikeToRating'?: boolean
+ 'subsonic.ratingPlaylists'?: boolean
/**
* Subsonic 列表中是否隐藏 dislike 命中的歌曲 (默认 true)
diff --git a/src/types/dislike_list.d.ts b/src/types/dislike_list.d.ts
index ca700104..06908a90 100644
--- a/src/types/dislike_list.d.ts
+++ b/src/types/dislike_list.d.ts
@@ -28,11 +28,17 @@ declare namespace LX {
* 规则串中以 !<专辑名>@<歌手> 形式存储,按歌手拆成多条。
*/
interface DislikeAlbumInfo {
+ source?: string
+ provenance?: { channel: string, at: number, ratingId?: string }
+ reasons?: { channel: string, at: number, ratingId?: string }[]
albumName: string
singer: string
}
interface DislikeSongInfo {
+ provenance?: { channel: string, at: number, ratingId?: string }
+ reasons?: { channel: string, at: number, ratingId?: string }[]
+ enrichedBy?: { channel: string, at: number }
id?: string
name: string
singer: string
diff --git a/src/utils/backupPolicy.ts b/src/utils/backupPolicy.ts
index 1c92f67c..8a877606 100644
--- a/src/utils/backupPolicy.ts
+++ b/src/utils/backupPolicy.ts
@@ -29,7 +29,7 @@ export interface BackupContext {
export const BACKUP_TYPE_INFO = [
{ id: 'music', label: '歌曲与歌词', description: '当前数据目录的 music/(默认 data/music)内的歌曲、已有歌词、封面和音乐索引;不含各级 cache、数据目录外独立 music 或外部曲库。' },
{ id: 'userFiles', label: '用户音源文件', description: 'users/source 内的音源脚本、顺序和状态文件,以及上传的用户文件。' },
- { id: 'snapshots', label: '歌单与不喜欢快照', description: '快照正文、snapshotInfo.json;包含没有扩展名的快照及已配置的外部歌单快照目录。' },
+ { id: 'snapshots', label: '歌单、不喜欢与评分快照', description: '快照正文、snapshotInfo.json;包含没有扩展名的快照及已配置的外部歌单快照目录。' },
{ id: 'userData', label: '用户数据', description: '用户设置、播放记录、收藏与其他 JSON 数据;不包含音源文件和快照。' },
{ id: 'system', label: '系统与账户', description: 'config.js、users.json、serverInfo.json 等系统 JSON;包含账号和连接凭据。' },
] as const
@@ -55,7 +55,7 @@ export function backupType(relative: string): BackupType | null {
if (/^users\/[^/]+\/music\//i.test(rel)) return null
if (/\.(tmp|oltmp|part|log|lock|db|sqlite)(-(wal|shm))?$/i.test(rel) || /(^|\/)temp-|(^|\/)lx-sync-backup-/.test(lower)) return null
if (lower.startsWith('music/')) return 'music'
- if (lower.startsWith('_snapshots/') || /^users\/[^/]+\/(list|dislike)\/snapshot(\/|info\.json$)/i.test(rel)) return 'snapshots'
+ if (lower.startsWith('_snapshots/') || /^users\/[^/]+\/(list|dislike|rating)\/snapshot(\/|info\.json$)/i.test(rel)) return 'snapshots'
if (/^(users\/source|source|uploads)\//i.test(rel)) return 'userFiles'
if (/^users\/[^/]+\//i.test(rel)) return 'userData'
if (lower === 'config.js' || (!rel.includes('/') && lower.endsWith('.json'))) return 'system'
diff --git a/test/admin-dislike-view.test.cjs b/test/admin-dislike-view.test.cjs
new file mode 100644
index 00000000..5fe0a2fe
--- /dev/null
+++ b/test/admin-dislike-view.test.cjs
@@ -0,0 +1,61 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const vm = require('node:vm');
+const source = fs.readFileSync('public/app.js','utf8');
+function harness(data) {
+ const start=source.indexOf(' dislikeOriginLabel('),end=source.indexOf(' getPlaylistsSectionHtml(',start);
+ assert.ok(start>=0&&end>start);
+ const context=vm.createContext({});
+ vm.runInContext(`class Harness { escapeHtml(s) { return String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''') } getSourceLabel(s) { return s } ${source.slice(start,end)} }; globalThis.instance=new Harness()`,context);
+ const app=context.instance;app.currentUserData={username:'test',data};return app;
+}
+const fixture={dislikeList:[{name:'',singer:'artist',source:'tx',id:'tx_1',songmid:'1',img:'javascript:alert(1)',dislikeRule:'track@artist',provenance:{channel:'web',at:1}},{name:'',singer:'',source:'wy',dislikeRule:'!album@artist',provenance:{channel:'subsonic-rating',at:2}}],dislikeArtists:[{name:'Artist',source:'tx',picUrl:'https://example.com/cover.jpg'}],userRatings:{tx_1:5},ratingOrigins:{tx_1:{channel:'subsonic',at:3}}};
+test('管理查看合并三类记录,准确区分添加来源与平台,保留原始字段',()=>{
+ const app=harness(fixture);const rows=app.getDislikeEntries(fixture);
+ assert.deepEqual(Array.from(rows,x=>x.kind),['song','album','artist']);assert.equal(rows[0].cover,'');assert.equal(rows[1].title,'album');assert.equal(rows[2].channel,'unknown');
+ const html=app.getDislikeSectionHtml(fixture);assert.match(html,/Web 添加/);assert.match(html,/Subsonic 低分联动/);assert.match(html,/来源未记录/);assert.match(html,/查看详情/);assert.ok([...app._adminDetails.values()].every(m=>m.raw&&m.sections.length));assert.ok(!html.includes(''));assert.match(html,/aria-label="5 星"/);assert.ok(!html.includes('',value:'tx',selected:true}],closest:()=>host};const wrapper=make();const trigger={getBoundingClientRect:()=>({left:120,top:150,bottom:190,width:140})};context.window.CustomSelectManager.open(select,wrapper,trigger);return{body,manager:context.window.CustomSelectManager,select};
+}
+test('统一下拉菜单挂载到所属dialog,坐标换算为弹窗内且文本不注入HTML',()=>{
+ const host={children:[],appendChild(x){this.children.push(x)},getBoundingClientRect:()=>({left:100,top:50,bottom:650}),scrollLeft:0,scrollTop:10,clientLeft:1,clientTop:1};const{body}=setup(host);assert.equal(body.children.length,0);assert.equal(host.children.length,1);const dropdown=host.children[0];assert.equal(dropdown.style.left,'19px');assert.equal(dropdown.style.top,'153px');assert.equal(dropdown.children[0].children[0].children[0].textContent,'');
+});
+test('非模态页面下拉菜单继续挂载body,不改变普通页面坐标',()=>{const{body}=setup(null);assert.equal(body.children.length,1);assert.equal(body.children[0].style.left,'120px');assert.equal(body.children[0].style.top,'194px')});
+test('我的评分显式初始化统一组件、映射手动补分,并在关闭时清理菜单',()=>{const text=fs.readFileSync('public/music/js/library/rating_manager.js','utf8');assert.match(text,/CustomSelectManager\?\.init\(select\)/);assert.match(text,/'manual-dislike-to-rating': '后台补分'/);assert.match(text,/collectionDialog.addEventListener\('close'.*CustomSelectManager\?\.closeAll/);assert.match(text,/评分来源是什么意思/);assert.match(text,/terms.every/)});
diff --git a/test/rating-feature-coverage.test.ts b/test/rating-feature-coverage.test.ts
new file mode 100644
index 00000000..6b0195ce
--- /dev/null
+++ b/test/rating-feature-coverage.test.ts
@@ -0,0 +1,181 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+import { DislikeDataManage } from '../src/modules/dislike/dislikeDataManage'
+import { parseDislikeRules, filterDisliked } from '../src/modules/dislike/match'
+
+const source = fs.readFileSync('src/server/integrations/subsonic.ts', 'utf8').replace(/\r/g, '')
+function between(from: string, to: string) {
+ const a = source.indexOf(from), b = source.indexOf(to, a + from.length)
+ assert.ok(a >= 0 && b > a, from)
+ return source.slice(a, b)
+}
+function dataManage() {
+ return new DislikeDataManage({ snapshotInfo: {}, getSnapshotInfo: async () => ({ latest: null }) } as any)
+}
+function harness() {
+ const dm = dataManage()
+ const state = { snapshots: 0, ratings: {} as Record, invalidations: 0, aliases: {} as Record }
+ const methods = between(' private async resolveDislikeTarget(', ' /** 按 dislike 规则过滤歌曲列表') +
+ between(' async syncRatingsToDislike(', ' /**\n * 手动补数据(不喜欢') +
+ between(' async syncDislikesToRating(', ' /**\n * 处理 Subsonic setRating')
+ const context = vm.createContext({
+ resolveStoredRatingId: async (_u: string, id: string) => state.aliases[id] || id,
+ localRatingId: (track: any) => track.platformId || track.id,
+ state, global: { lx: { config: { 'subsonic.dislikeRating': 1 } } },
+ getUserSpace: () => ({ dislikeManage: { dislikeDataManage: dm, createSnapshot: async () => { state.snapshots++ } } }),
+ withTimeout: (p: any) => p, LINK_SYNC_ITEM_TIMEOUT_MS: 8000, LINK_SYNC_BATCH_SIZE: 20,
+ countDislikeRules: (r: string) => r.split('\n').filter(Boolean).length,
+ invalidateDislikeCache: () => { state.invalidations++ },
+ writeRatingUnlocked:async (_u:string,id:string,rating:number)=>{const unchanged=state.ratings[id]>0;if(!unchanged)state.ratings[id]=rating;return {id,meta:{ratings:state.ratings},unchanged}},
+ recordRatingBackfillFailure:()=>{},
+ flushRatingSnapshot:async()=> (state as any).snapshotFailure ? '快照失败' : undefined,
+ readSubsonicMeta: () => ({ ratings: state.ratings }),
+ writeSubsonicMeta: (_u: string, meta: any) => { state.ratings = meta.ratings },
+ withRatingIdentityLock: async (_u: string, fn: () => any) => fn(),
+ withMetaLock: async (_u: string, f: () => any) => f(), createRatingSnapshot: async () => {},
+ getLocalTracks: async () => [], localMusic: (x: any) => x,
+ splitSingers: (s: string) => s.split('、'), normalizeText: (s: string) => s,
+ encodeAlbumRule: (a: string, s: string) => `!${a}@${s}`,
+ musicSdk: { wy: { extendDetail: { getAlbumSongs: async () => ({ name: 'album', list: [{ singer: 'artist' }] }) } } },
+ subsonicLog: { warn() {}, error() {} },
+ })
+ vm.runInContext(transformSync(`class Harness {
+ onlineSongCache = new Map(); userRatingsCache = new Map(); songs = new Map();
+ loadOnlineSongCache() {}
+ async getSubsonicListData() { return { loveList: [], defaultList: [...this.songs.values()], userList: [] } }
+ async filterDislikedSongs(_u, songs) { return songs.filter(s => s.name !== 'blocked') }
+ async resolveSongMeta(_u, id) { if (id === 'fail') throw Error('offline'); const music = this.songs.get(id); return music ? { music } : null }
+ async resolveSingerIdentity(_u, name, preset) { return { name, source: preset?.source || '', singerId: preset?.id || '' } }
+ async resolveArtistKey() { return { name: 'artist' } }
+ dislikeRuleKey(name, singer) { return name + '@' + singer }
+ ${methods}
+ }; globalThis.instance = new Harness()`, { loader: 'ts', format: 'cjs' }).code, context)
+ return { instance: (context as any).instance, dm, state }
+}
+const song = (id: string) => ({ id, name: 'track-' + id, singer: 'artist', source: 'wy', songmid: id, img: 'cover', interval: '03:00', types: [{ type: '128k' }], _types: { '128k': {} } })
+
+test('Subsonic 歌曲转换保留 Web 所需的已知资料字段', async () => {
+ const { instance, dm } = harness()
+ instance.songs.set('wy_1', song('wy_1'))
+ const target = await instance.resolveDislikeTarget('alice', 'wy_1')
+ await dm.addDislikeInfo(target.infos)
+ const item = dm.dislikeRules.dislikeList[0] as any
+ for (const key of ['id', 'source', 'songmid', 'name', 'singer', 'img', 'interval', 'types', '_types']) assert.deepEqual(item[key], (song('wy_1') as any)[key])
+ assert.equal(item.dislikeRule, 'track-wy_1@artist')
+})
+
+test('已知限制:残缺字段不会凭空补齐,同规则不同来源不会合并资料', async () => {
+ const { instance, dm } = harness()
+ instance.songs.set('wy_1', { id: 'wy_1', name: 'track', singer: 'artist' })
+ const target = await instance.resolveDislikeTarget('alice', 'wy_1')
+ assert.equal(target.infos[0].img, undefined)
+ await dm.addDislikeInfo([{ name: 'track', singer: 'artist', dislikeRule: '' }])
+ await dm.addDislikeInfo([{ ...song('wy_1'), name: 'track', dislikeRule: '' }] as any)
+ assert.equal(dm.dislikeRules.dislikeList.length, 2)
+ assert.equal((dm.dislikeRules.dislikeList[0] as any).img, undefined)
+})
+
+test('歌手转换为名称规则,专辑转换为专辑规则,不展开歌曲', async () => {
+ const { instance } = harness()
+ const artist = await instance.resolveDislikeTarget('alice', 'art_tx_123')
+ assert.equal(artist.infos[0].name, '')
+ assert.equal(artist.infos[0].singer, 'artist')
+ const album = await instance.resolveDislikeTarget('alice', 'alb_wy_123')
+ assert.equal(album.infos.length, 0)
+ assert.equal(album.album.albumName, 'album')
+ assert.deepEqual(Array.from(album.album.singers), ['artist'])
+})
+
+test('手动正向同步全量分批、幂等、跳过高分、失败继续并生成一次快照', async () => {
+ const { instance, state, dm } = harness()
+ for (let n = 0; n < 43; n++) { const id = 'wy_' + n; instance.songs.set(id, song(id)); state.ratings[id] = 1 }
+ state.ratings.fail = 1; state.ratings.high = 5
+ const first = await instance.syncRatingsToDislike('alice')
+ assert.equal(first.scanned, 44); assert.equal(first.batches, 3); assert.equal(first.failed, 1)
+ assert.equal(first.added, 43); assert.equal(state.snapshots, 1); assert.equal(state.invalidations, 1)
+ const second = await instance.syncRatingsToDislike('alice')
+ assert.equal(second.added, 0); assert.equal(dm.dislikeRules.dislikeList.length, 43)
+})
+
+test('手动反向同步只补命中且缺失的评分,保留已有高分', async () => {
+ const { instance, state } = harness()
+ instance.songs.set('a', { ...song('a'), name: 'blocked' })
+ instance.songs.set('b', { ...song('b'), name: 'blocked' })
+ instance.songs.set('c', song('c')); state.ratings.a = 5
+ const result = await instance.syncDislikesToRating('alice')
+ assert.equal(result.added, 1); assert.equal(result.skipped, 1)
+ assert.deepEqual(state.ratings, { a: 5, b: 1 })
+})
+
+for (const [name, rules, candidate, options, expected] of [
+ ['版本归一化开', 'track@artist', { name: 'track (Live)', singer: 'artist' }, { normalizeName: true }, 0],
+ ['版本归一化关', 'track@artist', { name: 'track (Live)', singer: 'artist' }, { normalizeName: false }, 1],
+ ['纯歌名要求歌手', 'track', { name: 'track', singer: 'artist' }, { requireSinger: true }, 1],
+ ['纯歌名不要求歌手', 'track', { name: 'track', singer: 'artist' }, { requireSinger: false }, 0],
+ ['合唱任一歌手', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'any' }, 0],
+ ['合唱全部歌手', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'all' }, 1],
+ ['合唱仅主唱', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'primary' }, 1],
+] as const) {
+ test(`规则选项:${name}`, () => {
+ const result = filterDisliked([candidate], parseDislikeRules(rules), (x: any) => x, options as any)
+ assert.equal(result.length, expected)
+ })
+}
+
+test('推荐过滤独立于普通列表隐藏开关', async () => {
+ const method = between(' private async filterRecommendationEntries(', ' private async handleGetSimilarSongs')
+ for (const hide of [false, true]) for (const exclude of [false, true]) {
+ const context = vm.createContext({ global: { lx: { config: { 'subsonic.hideDisliked': hide, 'subsonic.dislikeNoRecommend': exclude } } } })
+ vm.runInContext(transformSync(`class Harness {
+ async getUserSubsonicMeta(){return {ratings:{}}}
+ async filterDislikedEntries(_u, list) { return list.filter(x => x.music.id !== 'blocked') }
+ musicToSongFlat(m) { return m }
+ sendResponse(_res, data) { return data }
+ ${method}
+ }; globalThis.instance = new Harness()`, { loader: 'ts', format: 'cjs' }).code, context)
+ const result = await (context as any).instance.renderRandomSongs({}, [{ music: { id: 'blocked' }, listId: 'default' }], 'json', 'randomSongs', 'alice')
+ assert.equal(result.randomSongs.song.length, exclude ? 0 : 1)
+ }
+})
+
+test('已知限制:同名歌曲规则不带来源,跨来源开关不能隔离歌曲规则', () => {
+ const rules = parseDislikeRules('track@artist')
+ for (const crossSource of [false, true]) {
+ const result = filterDisliked([{ name: 'track', singer: 'artist', source: 'tx' }], rules, x => x, { crossSource })
+ assert.equal(result.length, 0)
+ }
+})
+
+test('手动正向同步把空解析计入失败,预览不写规则', async () => {
+ const { instance, state, dm } = harness()
+ state.ratings.missing = 1
+ const preview = await instance.syncRatingsToDislike('alice', true)
+ assert.equal(preview.candidates.length, 1)
+ assert.equal(dm.dislikeRules.dislikeList.length, 0)
+ assert.equal(state.snapshots, 0)
+ const actual = await instance.syncRatingsToDislike('alice')
+ assert.equal(actual.failed, 1)
+ assert.equal(actual.added, 0)
+})
+
+test('手动正向补齐已有记录也必须保存快照并报告updated', async () => {
+ const { instance, state, dm } = harness()
+ const music = song('wy_1')
+ await dm.addDislikeInfo([{ name: music.name, singer: music.singer, source: music.source, id: music.id }])
+ instance.songs.set('wy_1', music); state.ratings.wy_1 = 1
+ const actual = await instance.syncRatingsToDislike('alice')
+ assert.equal(actual.added, 0)
+ assert.equal(actual.updated, 1)
+ assert.equal(state.snapshots, 1)
+ assert.equal((dm.dislikeRules.dislikeList[0] as any).img, 'cover')
+})
+
+
+test('后台补分预览区分已有分、重复关联和真正待新增,快照失败返回警告',async()=>{
+ const {instance,state}=harness();instance.songs.set('a',{...song('a'),name:'blocked'});instance.songs.set('b',{...song('b'),name:'blocked'});instance.songs.set('c',{...song('c'),name:'blocked'});state.aliases.b='a';state.ratings.a=5;
+ const preview=await instance.syncDislikesToRating('alice',true);assert.equal(preview.candidates.length,1);assert.equal(preview.existing.length,1);assert.equal(preview.duplicates.length,1);assert.equal(state.ratings.c,undefined)
+ ;(state as any).snapshotFailure=true;const result=await instance.syncDislikesToRating('alice');assert.equal(result.added,1);assert.equal(result.warning,'快照失败');assert.equal(state.ratings.a,5)
+})
diff --git a/test/rating-link-policy.test.ts b/test/rating-link-policy.test.ts
new file mode 100644
index 00000000..145b5b80
--- /dev/null
+++ b/test/rating-link-policy.test.ts
@@ -0,0 +1,121 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+
+const source = fs.readFileSync('src/server/integrations/subsonic.ts', 'utf8')
+const start = source.indexOf(' private async handleSetRating(')
+const end = source.indexOf(' private async handleGetStarred(', start)
+const reverseStart = source.indexOf('export async function syncDislikeToRating(')
+const reverseEnd = source.indexOf('/**', reverseStart)
+function build(config: Record = {}) {
+ const state = { ratings: {} as Record, adds: 0, removes: 0, snapshots: 0, fail: false }
+ const context = vm.createContext({
+ global: { lx: { config } }, state,
+ withTimeout: (promise:any) => promise, LINK_SYNC_ITEM_TIMEOUT_MS:8000,
+ resolveStoredRatingId: async (_u: string, id: string) => id,
+ withRatingIdentityLock: async (_u: string, fn: () => any) => fn(),
+ withMetaLock: async (_u: string, fn: () => any) => fn(),
+ createRatingSnapshot: async () => { state.snapshots++ },
+ writeRating: async (_u: string, id: string, rating: number, channel:string, _song:any, options:any={}) => { if (state.fail && channel==='web-dislike-link') throw Error('disk failure');if(options.onlyMissing&&state.ratings[id]>0)return {id,meta:{ratings:state.ratings},unchanged:true};if (rating) state.ratings[id] = rating; else delete state.ratings[id]; state.snapshots++; return { id, meta: { ratings: state.ratings } } },
+ readSubsonicMeta: () => ({ ratings: state.ratings }),
+ writeSubsonicMeta: (_u: string, meta: any) => { if (state.fail) throw Error('disk failure'); state.ratings = meta.ratings },
+ subsonicLog: { debug() {}, warn() {}, error() {} },
+ })
+ vm.runInContext(transformSync(`
+class Harness {
+ userRatingsCache = new Map()
+ async getUserSubsonicMeta() { return { ratings: state.ratings } }
+ async saveUserSubsonicMeta(_u, meta) { state.ratings = meta.ratings }
+ async resolveSongMeta() {return null}
+ async getRatingMetadata() {return null}
+ async resolveDislikeTarget() { if (state.fail) throw Error('offline'); return { infos: [{ name: 'song' }], keys: new Set(['song']) } }
+ async addDislikeRules() { state.adds++; return 1 }
+ async removeDislikeRules() { state.removes++; return 1 }
+ sendResponse() { return { ok: true } }
+ sendError(_r, _code, message) { return { ok: false, message } }
+${source.slice(source.indexOf(' async applyRatingLink('), start)}
+${source.slice(start, end)}
+}
+${source.slice(reverseStart, reverseEnd).replace('export async', 'async')}
+globalThis.instance = new Harness()
+globalThis.reverse = syncDislikeToRating
+`, { loader: 'ts', format: 'cjs' }).code, context)
+ return { state, instance: (context as any).instance, reverse: (context as any).reverse }
+}
+
+test('评分歌单开启也不能绕过关闭的正向联动', async () => {
+ const { state, instance } = build({ 'subsonic.ratingPlaylists': true, 'subsonic.linkRatingToDislike': false })
+ await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '1' }), 'json')
+ assert.equal(state.ratings.wy_1, 1)
+ assert.equal(state.snapshots, 1)
+ assert.equal(state.adds, 0)
+})
+
+test('联动仅补充低分规则,提高或清除评分不删除手工规则', async () => {
+ const { state, instance } = build({ 'subsonic.linkRatingToDislike': true })
+ for (const rating of ['1', '5', '0']) await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating }), 'json')
+ assert.equal(state.adds, 1)
+ assert.equal(state.removes, 0)
+ assert.equal(state.ratings.wy_1, undefined)
+})
+
+test('联动解析失败返回部分成功提示,评分保留供重试', async () => {
+ const { state, instance } = build({ 'subsonic.linkRatingToDislike': true })
+ state.fail = true
+ const result = await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '1' }), 'json')
+ assert.equal(result.ok, false)
+ assert.match(result.message, /评分已保存/)
+ assert.equal(state.ratings.wy_1, 1)
+})
+
+test('反向联动保留高分,取消不喜欢保留评分,仅缺失评分补 1 星', async () => {
+ const { state, reverse } = build()
+ state.ratings.wy_1 = 5
+ await reverse('alice', 'wy_1', 1)
+ await reverse('alice', 'wy_1', 0)
+ assert.equal(state.ratings.wy_1, 5)
+ await reverse('alice', 'wy_2', 1)
+ assert.equal(state.ratings.wy_2, 1)
+})
+
+for (const threshold of [0, 1, 2]) {
+ for (const enabled of [false, true]) {
+ for (const visible of [false, true]) {
+ test(`开关矩阵:阈值 ${threshold} 联动 ${enabled} 评分歌单 ${visible}`, async () => {
+ const { state, instance } = build({ 'subsonic.dislikeRating': threshold, 'subsonic.linkRatingToDislike': enabled, 'subsonic.ratingPlaylists': visible })
+ await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '2' }), 'json')
+ assert.equal(state.ratings.wy_1, 2)
+ assert.equal(state.adds, enabled && threshold >= 2 ? 1 : 0)
+ assert.equal(state.snapshots, 1)
+ })
+ }
+ }
+}
+
+test('Web歌手/专辑不喜欢不能被误写成歌曲评分', async () => {
+ const server = fs.readFileSync('src/server/server.ts', 'utf8')
+ const start = server.indexOf(' const dislikeAutoRating =')
+ const end = server.indexOf(' if (!isAdd && removeKeys.size', start)
+ assert.ok(start >= 0 && end > start)
+ for (const type of ['song', 'singer', 'album']) {
+ const calls: any[] = []
+ const context = vm.createContext({
+ readRatingPreferences: () => ({ dislikeAutoRating: true }),
+ readRatings: () => ({ ratings: {} }),
+ type, id: 'real-id', source: 'tx', verified: 'test', isAdd: true,
+ global: { lx: { config: { 'subsonic.linkDislikeToRating': true, 'subsonic.dislikeRating': 1 } } },
+ payload: { id: 'real-id', source: 'tx' }, resolveRatingId: async () => 'tx_real-id',
+ require: () => ({ syncDislikeToRating: async (...args: any[]) => calls.push(args) }), console,
+ })
+ await vm.runInContext(transformSync(`(async () => { ${server.slice(start, end)} })()`, { loader: 'ts', format: 'cjs' }).code, context)
+ assert.equal(calls.length, type === 'song' ? 1 : 0)
+ if (type === 'song') assert.deepEqual(calls[0].slice(0,3), ['test', 'tx_real-id', 1])
+ }
+})
+
+
+test('自动补分写盘失败向调用方抛出,不伪报成功',async()=>{
+ const {state,reverse}=build();state.fail=true;await assert.rejects(()=>reverse('alice','wy_1',1),/disk failure/)
+})
diff --git a/test/rating-migration.test.ts b/test/rating-migration.test.ts
new file mode 100644
index 00000000..c59203ac
--- /dev/null
+++ b/test/rating-migration.test.ts
@@ -0,0 +1,54 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import { commitRatingFiles, recoverRatingTransaction, planRatingMigration, registerDislikeMigration } from '../src/server/rating/ratingMigration'
+const fixture = (fn: (dir: string) => void) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-rating-transaction-')); try { fn(dir) } finally { fs.rmSync(dir, { recursive: true, force: true }) } }
+test('跨文件提交第二次写入失败完整回滚,删除事务日志',()=>fixture(dir=>{
+ const a=path.join(dir,'rating/songs.json'),b=path.join(dir,'subsonic-meta.json');fs.mkdirSync(path.dirname(a),{recursive:true});fs.writeFileSync(a,'old catalog');fs.writeFileSync(b,'old ratings')
+ const rename=fs.renameSync;let failed=false
+ try { fs.renameSync=((from:any,to:any)=>{if(to===b&&!failed){failed=true;throw Error('disk failure')}return rename(from,to)}) as any
+ assert.throws(()=>commitRatingFiles(dir,new Map([[a,'new catalog'],[b,'new ratings']])),/disk failure/)
+ }finally{fs.renameSync=rename}
+ assert.equal(fs.readFileSync(a,'utf8'),'old catalog');assert.equal(fs.readFileSync(b,'utf8'),'old ratings');assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
+}))
+test('中断后由日志完成所有文件提交,重复恢复幂等,拒绝越界日志',()=>fixture(dir=>{
+ fs.mkdirSync(path.join(dir,'rating'));fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'rating/songs.json',after:'catalog'},{file:'subsonic-meta.json',after:'ratings'}]))
+ recoverRatingTransaction(dir);recoverRatingTransaction(dir);assert.equal(fs.readFileSync(path.join(dir,'subsonic-meta.json'),'utf8'),'ratings')
+ fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'../escape',after:'bad'}]));assert.throws(()=>recoverRatingTransaction(dir),/Invalid/)
+}))
+test('迁移全部原因ID,保留手动原因和旧快照,并同步运行时规则及快照索引',()=>fixture(dir=>{
+ const dislike=path.join(dir,'dislike');fs.mkdirSync(path.join(dislike,'snapshot'),{recursive:true})
+ const rules={dislikeList:[{name:'歌',provenance:{channel:'web-rating-link',ratingId:'local_a'},reasons:[{channel:'web-rating-link',ratingId:'local_a'},{channel:'web'}]}]}
+ const info={latest:'old',list:[],imported:[],clients:{}}
+ fs.writeFileSync(path.join(dislike,'snapshot/snapshot_old'),JSON.stringify(rules));fs.writeFileSync(path.join(dislike,'snapshotInfo.json'),JSON.stringify(info))
+ const manager={dislikeRules:rules,snapshotDataManage:{snapshotInfo:info}};registerDislikeMigration(dir,manager)
+ const data={ratings:{local_a:1},ratingOrigins:{local_a:{channel:'web',at:1}}},catalog={local_a:{name:'歌'}}
+ const changes=planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}]);changes.set(path.join(dir,'subsonic-meta.json'),JSON.stringify(data));commitRatingFiles(dir,changes)
+ assert.equal(manager.dislikeRules.dislikeList[0].provenance.ratingId,'wy_a');assert.equal(manager.dislikeRules.dislikeList[0].reasons[0].ratingId,'wy_a');assert.equal(manager.dislikeRules.dislikeList[0].reasons[1].channel,'web');assert.notEqual(manager.snapshotDataManage.snapshotInfo.latest,'old');assert.ok(fs.existsSync(path.join(dislike,'snapshot/snapshot_old')))
+ assert.equal(planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}]).size,0)
+}))
+
+test('只有收藏没有ratings也能整理关联,不生成虚假分数',()=>fixture(dir=>{
+ const data:any={starredArtists:['keep']},catalog={}
+ const changes=planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}])
+ assert.deepEqual(data.ratings,{});assert.deepEqual(data.starredArtists,['keep']);assert.equal(changes.size,0)
+}))
+test('仅提交评分文件不能覆盖尚未保存的运行时不喜欢',()=>fixture(dir=>{
+ const dislike=path.join(dir,'dislike');fs.mkdirSync(path.join(dislike,'snapshot'),{recursive:true})
+ const info={latest:'old',list:[],imported:[],clients:{}}
+ fs.writeFileSync(path.join(dislike,'snapshotInfo.json'),JSON.stringify(info));fs.writeFileSync(path.join(dislike,'snapshot/snapshot_old'),JSON.stringify({dislikeList:[]}))
+ const manager={snapshotDataManage:{snapshotInfo:info},dislikeRules:{dislikeList:[{name:'尚未保存'}]}}
+ registerDislikeMigration(dir,manager)
+ commitRatingFiles(dir,new Map([[path.join(dir,'subsonic-meta.json'),JSON.stringify({ratings:{wy_a:3}})]]))
+ assert.equal(manager.dislikeRules.dislikeList[0].name,'尚未保存')
+}))
+test('初始化不喜欢管理器时先完成遗留事务,不必访问评分接口',()=>fixture(dir=>{
+ fs.mkdirSync(path.join(dir,'rating'));fs.mkdirSync(path.join(dir,'dislike/snapshot'),{recursive:true})
+ const id='a'.repeat(32),info={latest:id,list:[],clients:{},imported:[]}
+ fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'dislike/snapshot/snapshot_'+id,after:JSON.stringify({dislikeList:[{name:'已恢复'}]})},{file:'dislike/snapshotInfo.json',after:JSON.stringify(info)}]))
+ const manager={snapshotDataManage:{snapshotInfo:{}},dislikeRules:{dislikeList:[] as any[]}}
+ registerDislikeMigration(dir,manager)
+ assert.equal(manager.dislikeRules.dislikeList[0].name,'已恢复');assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
+}))
diff --git a/test/rating-preferences.test.ts b/test/rating-preferences.test.ts
new file mode 100644
index 00000000..eae38db4
--- /dev/null
+++ b/test/rating-preferences.test.ts
@@ -0,0 +1,25 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import vm from 'node:vm'
+import { createRequire } from 'node:module'
+import { transformSync } from 'esbuild'
+const actualRequire=createRequire(import.meta.url)
+test('自动补分偏好默认关闭、按用户落盘隔离、布尔校验且关闭保留其他用户',()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-preferences-'))
+ try{
+ const mod={exports:{} as any}
+ const require=(id:string)=>id==='@/user'?{getUserDirname:(user:string)=>user}:actualRequire(id)
+ vm.runInNewContext(transformSync(fs.readFileSync('src/server/rating/ratingPreferences.ts','utf8'),{loader:'ts',format:'cjs'}).code,{module:mod,exports:mod.exports,require,global:{lx:{userPath:root}},console:{warn(){}}})
+ const api=mod.exports
+ assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false)
+ api.saveRatingPreferences('a',true);assert.equal(api.readRatingPreferences('a').dislikeAutoRating,true);assert.equal(api.readRatingPreferences('b').dislikeAutoRating,false)
+ for(const value of [1,'true',null,{},undefined])assert.throws(()=>api.saveRatingPreferences('a',value))
+ assert.equal(api.readRatingPreferences('a').dislikeAutoRating,true)
+ api.saveRatingPreferences('b',true);api.saveRatingPreferences('a',false);assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false);assert.equal(api.readRatingPreferences('b').dislikeAutoRating,true)
+ const file=path.join(root,'a/rating/preferences.json');assert.equal(JSON.parse(fs.readFileSync(file,'utf8')).dislikeAutoRating,false);assert.equal(fs.existsSync(file+'.tmp'),false)
+ fs.writeFileSync(file,'broken');assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false)
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
diff --git a/test/rating-recommendation-switch.test.ts b/test/rating-recommendation-switch.test.ts
new file mode 100644
index 00000000..02091555
--- /dev/null
+++ b/test/rating-recommendation-switch.test.ts
@@ -0,0 +1,16 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+const source=fs.readFileSync('src/server/integrations/subsonic.ts','utf8')
+const start=source.indexOf(' private async handleGetRecommendedSongs(')
+const end=source.indexOf(' private async ',start+20)
+const filterStart=source.indexOf(' private async filterRecommendationEntries(')
+const filterEnd=source.indexOf(' private async renderRandomSongs(',filterStart)
+for(const enabled of [false,true]) test(`每日推荐低分排除遵守开关 ${enabled}`,async()=>{
+ const context:any={global:{lx:{config:{'subsonic.dislikeNoRecommend':enabled,'subsonic.dislikeRating':1}}},localRatingId:(t:any)=>t.platformId||t.id,fetchRecommendedSongs:async()=>[{id:'wy_1'},{id:'wy_2'}],subsonicLog:{error(){}}}
+ vm.runInNewContext(transformSync(`class Harness { async filterDislikedEntries(_u,entries){return entries} async getUserSubsonicMeta(){ return {ratings:{wy_1:1,wy_2:5}} } renderRandomSongs(_res,rows){return rows} ${source.slice(filterStart,filterEnd)} ${source.slice(start,end)} }; globalThis.instance=new Harness()`,{loader:'ts',format:'cjs'}).code,context)
+ const rows=await context.instance.handleGetRecommendedSongs({},'a',new URLSearchParams(),'json')
+ assert.equal(rows.length,enabled?1:2)
+})
diff --git a/test/rating-snapshot.test.ts b/test/rating-snapshot.test.ts
new file mode 100644
index 00000000..e99ab34e
--- /dev/null
+++ b/test/rating-snapshot.test.ts
@@ -0,0 +1,213 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import vm from 'node:vm'
+import { createRequire } from 'node:module'
+import { transformSync } from 'esbuild'
+
+/** 用 vm 注入受控的 global.lx 与 @/ 别名依赖,隔离文件系统的真实用户目录 */
+const nodeRequire = createRequire(import.meta.url)
+type Api = Record
+
+const load = (userPath: string, maxSnapshotNum = 3): Api => {
+ const source = fs.readFileSync(path.resolve('src/server/rating/ratingSnapshot.ts'), 'utf8')
+ const code = transformSync(source, { loader: 'ts', format: 'cjs', target: 'node20' }).code
+ const mod = { exports: {} as Api }
+ const require = (id: string): any => {
+ if (id === './ratingMigration' || id === '../../server/rating/ratingMigration') return nodeRequire(path.resolve('src/server/rating/ratingMigration.ts'))
+ if (id === './ratingIdentityLock') return nodeRequire(path.resolve('src/server/rating/ratingIdentityLock.ts'))
+ if (id === './ratingValidation') return nodeRequire(path.resolve('src/server/rating/ratingValidation.ts'))
+ if (id === '@/user') return { getUserDirname: (u: string) => u }
+ if (id === '@/user/data') return { getUserConfig: () => ({ maxSnapshotNum }) }
+ return nodeRequire(id)
+ }
+ const context = vm.createContext({
+ module: mod,
+ exports: mod.exports,
+ require,
+ global: { lx: { userPath, config: { maxSnapshotNum } } },
+ console,
+ process,
+ Buffer,
+ URL,
+ __dirname: path.resolve('src/server'),
+ __filename: path.resolve('src/server/rating/ratingSnapshot.ts'),
+ })
+ vm.runInContext(code, context)
+ return mod.exports
+}
+
+const withUser = async (maxSnapshotNum: number, fn: (api: Api, userDir: string) => Promise | void) => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-rating-snap-'))
+ try {
+ const userDir = path.join(root, 'alice')
+ fs.mkdirSync(userDir, { recursive: true })
+ await fn(load(root, maxSnapshotNum), userDir)
+ } finally {
+ fs.rmSync(root, { recursive: true, force: true })
+ }
+}
+
+const metaFile = (userDir: string) => path.join(userDir, 'subsonic-meta.json')
+const snapshotDir = (userDir: string) => path.join(userDir, 'rating', 'snapshot')
+const infoFile = (userDir: string) => path.join(userDir, 'rating', 'snapshotInfo.json')
+const writeMeta = (userDir: string, ratings: Record) =>
+ fs.writeFileSync(metaFile(userDir), JSON.stringify({ ratings, starredAlbums: [], starredArtists: [], starredArtistNames: [] }), 'utf8')
+const plain = (value: T): T => JSON.parse(JSON.stringify(value))
+
+test('恢复评分快照保留当前收藏,并与并发写入串行', async () => {
+ await withUser(3, async (api, userDir) => {
+ writeMeta(userDir, { old: 1 })
+ const id = await api.createRatingSnapshot('alice')
+ const pending = api.withMetaLock('alice', async () => {
+ await new Promise(resolve => setTimeout(resolve, 10))
+ fs.writeFileSync(metaFile(userDir), JSON.stringify({ ratings: { newer: 5 }, starredAlbums: ['album'], starredArtists: ['artist'], starredArtistNames: ['name'] }))
+ })
+ await Promise.all([pending, api.restoreRatingSnapshot('alice', id)])
+ const restored = JSON.parse(fs.readFileSync(metaFile(userDir), 'utf8'))
+ assert.equal(restored.ratingHistory.old.at(-1).channel, 'snapshot-restore')
+ delete restored.ratingHistory
+ assert.deepEqual(restored, {
+ ratings: { old: 1 }, ratingOrigins: {}, starredAlbums: ['album'], starredArtists: ['artist'], starredArtistNames: ['name'],
+ })
+ })
+})
+
+test('S4 快照 id:路径穿越与非法字符全部拒绝', async () => {
+ await withUser(3, (api) => {
+ for (const id of ['abc', '2026-11-08', 'a'.repeat(32), '用户备份']) {
+ assert.equal(api.isValidSnapshotId(id), true, `应接受 ${id}`)
+ }
+ for (const id of ['', '.', '..', '../evil', 'a..b', 'a/b', 'a\\b', 'a:b', 'a*b', 'a?b', 'a"b', 'ab', 'a|b', 'x'.repeat(201), null, 1, {}, []]) {
+ assert.equal(api.isValidSnapshotId(id), false, `应拒绝 ${String(id)}`)
+ }
+ })
+})
+
+test('S9 内容去重:同一评分只生成一份快照,复用时刷新 mtime', async () => {
+ await withUser(3, async (api, userDir) => {
+ writeMeta(userDir, { a: 3 })
+ const id1 = await api.createRatingSnapshot('alice')
+ const file = path.join(snapshotDir(userDir), `snapshot_${id1}`)
+ assert.ok(fs.existsSync(file))
+ const old = new Date(Date.now() - 10 * 86_400_000)
+ fs.utimesSync(file, old, old)
+ const id2 = await api.createRatingSnapshot('alice')
+ assert.equal(id2, id1)
+ assert.ok(fs.statSync(file).mtimeMs > old.getTime() + 5 * 86_400_000)
+ writeMeta(userDir, { a: 5 })
+ const id3 = await api.createRatingSnapshot('alice')
+ assert.notEqual(id3, id1)
+ })
+})
+
+test('S9 配额清理:只清超量的 auto 快照,imported 永久保留', async () => {
+ await withUser(2, async (api, userDir) => {
+ writeMeta(userDir, { a: 1 })
+ const first = await api.createRatingSnapshot('alice')
+ // 错开 mtime,避免同毫秒导致清理顺序不稳定
+ fs.utimesSync(path.join(snapshotDir(userDir), `snapshot_${first}`), new Date(1), new Date(1))
+ for (const star of [2, 3, 4]) {
+ writeMeta(userDir, { a: star })
+ const id = await api.createRatingSnapshot('alice')
+ fs.utimesSync(path.join(snapshotDir(userDir), `snapshot_${id}`), new Date(star * 1000), new Date(star * 1000))
+ }
+ const imported = 'my-import.json'
+ await api.saveRatingImport('alice', imported, { ratings: { z: 5 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, Date.now() - 86_400_000)
+ const list = plain(await api.listRatingSnapshots('alice'))
+ assert.ok(fs.existsSync(path.join(snapshotDir(userDir), `snapshot_${imported}`)))
+ assert.equal(list.filter((x: any) => x.origin === 'auto').length, 2, 'auto 快照应只保留最新 2 份')
+ assert.equal(list.find((x: any) => x.id === imported)?.origin, 'imported')
+ assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.includes(imported), true)
+ })
+})
+
+test('S9 索引兼容:旧版 snapshotInfo 无 imported 字段时降级为空数组', async () => {
+ await withUser(3, async (api, userDir) => {
+ writeMeta(userDir, { a: 1 })
+ fs.mkdirSync(snapshotDir(userDir), { recursive: true })
+ fs.writeFileSync(infoFile(userDir), '{ not json', 'utf8')
+ await api.createRatingSnapshot('alice')
+ const list = plain(await api.listRatingSnapshots('alice'))
+ assert.equal(list[0].origin, 'auto')
+ fs.writeFileSync(infoFile(userDir), JSON.stringify({ unrelated: 1 }), 'utf8')
+ assert.equal(plain(await api.listRatingSnapshots('alice'))[0].origin, 'auto')
+ })
+})
+
+test('S9 meta 损坏阻止导出并保留原文件;读写与删除、回滚幂等', async () => {
+ await withUser(3, async (api, userDir) => {
+ fs.writeFileSync(metaFile(userDir), '{ broken', 'utf8')
+ await assert.rejects(()=>api.createRatingSnapshot('alice'),/损坏/)
+ assert.equal(fs.readFileSync(metaFile(userDir),'utf8'),'{ broken')
+ assert.equal(await api.readRatingSnapshot('alice', 'missing'), null)
+ assert.equal(await api.readRatingSnapshot('alice', '../evil'), null)
+ assert.equal(await api.deleteRatingSnapshot('alice', 'missing'), false)
+ await assert.rejects(() => api.restoreRatingSnapshot('alice', 'missing'), /not found/)
+
+ writeMeta(userDir, { song: 4 })
+ const target = 'user-backup'
+ await api.saveRatingImport('alice', target, { ratings: { song: 1 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, 0)
+ await api.saveRatingImport('alice', target, { ratings: { song: 1 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, 0)
+ assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.length, 1, '重复导入不应重复登记')
+
+ await api.restoreRatingSnapshot('alice', target)
+ assert.equal(JSON.parse(fs.readFileSync(metaFile(userDir), 'utf8')).ratings.song, 1)
+ assert.equal(await api.deleteRatingSnapshot('alice', target), true)
+ assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.length, 0)
+ await assert.rejects(() => api.saveRatingImport('alice', '../evil', {}, 0), /Invalid snapshot id/)
+ })
+})
+
+
+test('完整评分导出含描述及关联参照,回滚恢复描述而不覆盖收藏或文件关联',async()=>{
+ await withUser(3,async(api,dir)=>{
+ writeMeta(dir,{wy_1:4});fs.mkdirSync(path.join(dir,'rating'),{recursive:true})
+ fs.writeFileSync(path.join(dir,'rating/songs.json'),JSON.stringify({wy_1:{name:'晴天',img:'https://example.com/a.jpg'}}))
+ const registry=JSON.stringify({tracks:{a:{id:'local_a',filename:'a.wav',platformId:'wy_1'}}})
+ fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),registry)
+ const exported=api.exportRatingBackup('alice');assert.equal(exported.ratingCatalog.wy_1.name,'晴天');assert.equal(exported.localLinks[0].platformId,'wy_1')
+ const id=await api.createRatingSnapshot('alice')
+ fs.writeFileSync(path.join(dir,'rating/songs.json'),'{}');writeMeta(dir,{wy_1:1})
+ await api.restoreRatingSnapshot('alice',id)
+ assert.equal(JSON.parse(fs.readFileSync(path.join(dir,'rating/songs.json'),'utf8')).wy_1.name,'晴天')
+ assert.equal(fs.readFileSync(path.join(dir,'subsonic-local-registry.json'),'utf8'),registry)
+ assert.deepEqual(plain(api.normalizeRatingMeta({ratings:{a:6,b:'3',c:0,d:2}}).ratings),{d:2})
+ })
+})
+
+
+test('恢复第二个文件失败时回滚歌曲描述与评分,不留下半恢复状态',async()=>{
+ await withUser(3,async(api,dir)=>{
+ writeMeta(dir,{wy_1:1});fs.mkdirSync(path.join(dir,'rating'),{recursive:true});const catalog=path.join(dir,'rating/songs.json');fs.writeFileSync(catalog,JSON.stringify({wy_1:{name:'旧描述'}}));const id=await api.createRatingSnapshot('alice')
+ writeMeta(dir,{wy_1:5});fs.writeFileSync(catalog,JSON.stringify({wy_1:{name:'当前描述'}}));const before=fs.readFileSync(metaFile(dir),'utf8')
+ const rename=fs.renameSync;let failed=false
+ try {
+ fs.renameSync=((from:any,to:any)=>{if(!failed&&String(to)===metaFile(dir)&&String(from).endsWith('.transaction.tmp')){failed=true;throw Error('模拟写入失败')}return rename(from,to)}) as any
+ await assert.rejects(()=>api.restoreRatingSnapshot('alice',id),/模拟写入失败/)
+ }finally{fs.renameSync=rename}
+ assert.equal(fs.readFileSync(metaFile(dir),'utf8'),before);assert.equal(JSON.parse(fs.readFileSync(catalog,'utf8')).wy_1.name,'当前描述')
+ })
+})
+
+
+test('评分历史导入只保留有效事件并限制30条,异常结构不会进入展示数据',async()=>{
+ await withUser(3,async(api)=>{
+ const bad=api.normalizeRatingMeta({ratings:{wy_1:4},ratingHistory:{wy_1:{broken:true},wy_2:[null,{},'bad',{from:0,to:9,at:1,channel:'web'}],wy_3:Array.from({length:50},(_,n)=>({from:0,to:4,at:n+1,channel:'web',url:'secret'}))}})
+ assert.equal(bad.ratingHistory.wy_1,undefined);assert.equal(bad.ratingHistory.wy_2.length,0);assert.equal(bad.ratingHistory.wy_3.length,30);assert.equal(bad.ratingHistory.wy_3[0].url,undefined)
+ })
+})
+
+test('恢复旧本地快照返回前完成身份迁移',async()=>{
+ await withUser(3,async(api,dir)=>{
+ writeMeta(dir,{local_old:2});const id=await api.createRatingSnapshot('alice')
+ fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),JSON.stringify({tracks:{file:{id:'local_old',platformId:'wy_online'}}}))
+ writeMeta(dir,{wy_online:5})
+ await api.restoreRatingSnapshot('alice',id)
+ const data=JSON.parse(fs.readFileSync(metaFile(dir),'utf8'))
+ assert.equal(data.ratings.local_old,undefined);assert.equal(data.ratings.wy_online,2)
+ assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
+ })
+})
diff --git a/test/snapshot-imported.test.ts b/test/snapshot-imported.test.ts
new file mode 100644
index 00000000..403bd0c3
--- /dev/null
+++ b/test/snapshot-imported.test.ts
@@ -0,0 +1,134 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+
+/**
+ * 提取 SnapshotDataManage 中与 imported 相关的真实成员,放进最小 Harness 里执行。
+ * 歌单与不喜欢两份实现分别加载,用同一组断言验证行为对称。
+ */
+const MEMBERS = [
+ 'private getImported =',
+ 'private persistSnapshotInfo =',
+ 'markImported =',
+ 'unmarkImported =',
+ 'isImported =',
+ 'getSnapshotListWithMeta =',
+]
+
+const extractMember = (lines: string[], marker: string): string => {
+ const start = lines.findIndex(line => line.includes(marker))
+ assert.ok(start >= 0, `找不到成员 ${marker}`)
+ let end = start + 1
+ for (; end < lines.length; end++) {
+ if (/^ [A-Za-z_]\w*\s*[=(]/.test(lines[end])) break
+ }
+ return lines.slice(start, end).join('\n')
+}
+
+const build = (rel: string, dir: string) => {
+ const src = fs.readFileSync(path.resolve(rel), 'utf8')
+ const lines = src.split('\n')
+ const members = MEMBERS.map(m => extractMember(lines, m)).join('\n')
+ const infoPath = path.join(dir, 'snapshotInfo.json')
+ const snapshotDir = path.join(dir, 'snapshot')
+ fs.mkdirSync(snapshotDir, { recursive: true })
+ const harness = `
+class Harness {
+ snapshotInfo = { latest: null, time: 0, list: [], clients: {} }
+ snapshotInfoFilePath = ${JSON.stringify(infoPath)}
+ snapshotDir = ${JSON.stringify(snapshotDir)}
+${members}
+}
+globalThis.Harness = Harness
+`
+ const context = vm.createContext({
+ fs,
+ path,
+ syncLog: { warn() {}, error() {} },
+ console,
+ global: { lx: {} },
+ })
+ vm.runInContext(transformSync(harness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
+ return { instance: new (context as any).Harness(), infoPath, snapshotDir }
+}
+
+const plain = (value: T): T => JSON.parse(JSON.stringify(value))
+const md5 = 'a'.repeat(32)
+
+for (const rel of ['src/modules/list/snapshotDataManage.ts', 'src/modules/dislike/snapshotDataManage.ts']) {
+ const label = rel.includes('/list/') ? '歌单' : '不喜欢'
+
+ test(`S6 ${label}:旧索引无 imported 字段时兼容降级`, () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
+ try {
+ const { instance } = build(rel, dir)
+ assert.equal(instance.isImported(md5), false, '32 位 md5 应视为系统快照')
+ assert.equal(instance.isImported('user-backup'), true, '非 md5 文件名应视为用户导入')
+ assert.deepEqual(plain(instance.snapshotInfo.imported), [])
+ } finally { fs.rmSync(dir, { recursive: true, force: true }) }
+ })
+
+ test(`S6 ${label}:来源登记幂等且落盘持久化`, () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
+ try {
+ const { instance, infoPath } = build(rel, dir)
+ instance.markImported('backup-1')
+ instance.markImported('backup-1')
+ instance.markImported('backup-2')
+ assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-1', 'backup-2'])
+ assert.deepEqual(JSON.parse(fs.readFileSync(infoPath, 'utf8')).imported, ['backup-1', 'backup-2'])
+ instance.unmarkImported('not-there')
+ assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-1', 'backup-2'])
+ instance.unmarkImported('backup-1')
+ assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-2'])
+ instance.markImported(md5)
+ assert.equal(instance.isImported(md5), true, '显式登记应优先于 md5 推断')
+ } finally { fs.rmSync(dir, { recursive: true, force: true }) }
+ })
+
+ test(`S6 ${label}:快照列表 origin 判定与按时间倒序`, async () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
+ try {
+ const { instance, snapshotDir } = build(rel, dir)
+ const autoFile = path.join(snapshotDir, `snapshot_${md5}`)
+ const importFile = path.join(snapshotDir, 'snapshot_user-backup')
+ fs.writeFileSync(autoFile, '{}')
+ fs.writeFileSync(importFile, '{}')
+ fs.utimesSync(importFile, new Date(1000), new Date(1000))
+ fs.utimesSync(autoFile, new Date(2000), new Date(2000))
+ const list = plain(await instance.getSnapshotListWithMeta())
+ assert.equal(list.length, 2)
+ assert.equal(list[0].id, md5)
+ assert.equal(list[0].origin, 'auto')
+ assert.equal(list[1].id, 'user-backup')
+ assert.equal(list[1].origin, 'imported')
+ } finally { fs.rmSync(dir, { recursive: true, force: true }) }
+ })
+}
+
+test('S7 双实现对称性:歌单与不喜欢的 imported 语义一致', () => {
+ const a = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-a-'))
+ const b = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-b-'))
+ try {
+ const x = build('src/modules/list/snapshotDataManage.ts', a).instance
+ const y = build('src/modules/dislike/snapshotDataManage.ts', b).instance
+ for (const inst of [x, y]) {
+ inst.markImported('same')
+ inst.markImported('same')
+ inst.markImported(md5)
+ }
+ assert.deepEqual(plain(x.snapshotInfo.imported), plain(y.snapshotInfo.imported))
+ assert.equal(x.isImported('same'), y.isImported('same'))
+ assert.equal(x.isImported(md5), y.isImported(md5))
+ assert.equal(x.isImported('other'), y.isImported('other'))
+ for (const inst of [x, y]) inst.unmarkImported('same')
+ assert.deepEqual(plain(x.snapshotInfo.imported), plain(y.snapshotInfo.imported))
+ } finally {
+ fs.rmSync(a, { recursive: true, force: true })
+ fs.rmSync(b, { recursive: true, force: true })
+ }
+})
diff --git a/test/snapshot-rating-wiring.test.ts b/test/snapshot-rating-wiring.test.ts
new file mode 100644
index 00000000..1a4c068d
--- /dev/null
+++ b/test/snapshot-rating-wiring.test.ts
@@ -0,0 +1,185 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+
+/**
+ * 从真实源码抽出两段可独立执行的逻辑:
+ * 1. server.ts 顶层的备份类型判定 / playList_v2 转换(上传快照分流的核心)
+ * 2. /api/subsonic/sync-link 路由的业务校验与分流
+ * 另外用 public/app.js 的真实判定表达式做前后端口径一致性校验,防止两类目录写串。
+ */
+const serverSource = fs.readFileSync(path.resolve('src/server/server.ts'), 'utf8')
+const appSource = fs.readFileSync(path.resolve('public/app.js'), 'utf8')
+
+const slice = (from: string, to: string): string => {
+ const start = serverSource.indexOf(from)
+ const end = serverSource.indexOf(to, start + from.length)
+ assert.ok(start >= 0 && end > start, `无法从 server.ts 定位 ${from} ~ ${to}`)
+ return serverSource.slice(start, end)
+}
+
+const runInVm = (code: string, context: Record, exportExpr: string): T => {
+ const sandbox = vm.createContext({ console, ...context })
+ vm.runInContext(transformSync(`${code}\nglobalThis.__export = ${exportExpr}`, {
+ loader: 'ts', format: 'cjs', target: 'node20',
+ }).code, sandbox)
+ return (sandbox as any).__export as T
+}
+
+const kindBlock = slice('type SnapshotKind = ', 'async function validateOpenListConfig')
+const kindApi = runInVm<{
+ detectSnapshotKind: (parsed: unknown) => 'list' | 'dislike' | 'rating'
+ toInternalListSnapshot: (parsed: unknown) => string | null
+}>(kindBlock, {}, '{ detectSnapshotKind, toInternalListSnapshot }')
+
+/** public/app.js 中真实的 isRatingBackup / kindLabel 判定,注入同一批样本比对 */
+const frontStart = appSource.indexOf(' const isRatingBackup = ')
+const frontEnd = appSource.indexOf(' // 目标用户由超管', frontStart)
+assert.ok(frontStart >= 0 && frontEnd > frontStart, '无法从 public/app.js 定位导入类型判定')
+const frontKind = new Function('jsonData', `${appSource.slice(frontStart, frontEnd)}\nreturn kindLabel`) as (data: unknown) => string
+
+const samples: Array<{ name: string, value: unknown, kind: 'list' | 'dislike' | 'rating' }> = [
+ { name: '非 JSON / 空值', value: null, kind: 'list' },
+ { name: '原生歌单快照', value: { defaultList: [{ id: 'a' }], loveList: [], userList: [] }, kind: 'list' },
+ { name: 'playList_v2 离线备份', value: { type: 'playList_v2', data: [{ id: 'default', list: [] }, { id: 'love', list: [] }] }, kind: 'list' },
+ { name: '原生不喜欢快照', value: { dislikeList: ['a@歌手@歌曲'] }, kind: 'dislike' },
+ { name: 'lxRating_v1 评分快照', value: { type: 'lxRating_v1', ratings: { a: 5 }, starredAlbums: [] }, kind: 'rating' },
+ { name: '旧版评分快照(无 type)', value: { ratings: { a: 3 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, kind: 'rating' },
+ { name: 'type 优先于 dislikeList', value: { type: 'lxRating_v1', ratings: {}, dislikeList: [] }, kind: 'rating' },
+ { name: '歌单字段优先于 ratings', value: { ratings: { a: 1 }, defaultList: [] }, kind: 'list' },
+ { name: '空对象', value: {}, kind: 'list' },
+ { name: '数组', value: [], kind: 'list' },
+ { name: '字符串', value: 'not an object', kind: 'list' },
+]
+
+test('S19 备份类型判定:歌单 / 不喜欢 / 评分三类分流', () => {
+ for (const s of samples) {
+ assert.equal(kindApi.detectSnapshotKind(s.value), s.kind, `样本「${s.name}」应判为 ${s.kind}`)
+ }
+})
+
+test('S19 前后端导入类型判定口径一致(避免上传到错误目录)', () => {
+ const label: Record = { list: '我的歌单', dislike: '我不喜欢', rating: '我的评分' }
+ for (const s of samples) {
+ assert.equal(frontKind(s.value), label[kindApi.detectSnapshotKind(s.value)], `样本「${s.name}」前后端判定不一致`)
+ }
+})
+
+test('S19 playList_v2 转回内部结构:default / love / 其余列表', () => {
+ const converted = kindApi.toInternalListSnapshot({
+ type: 'playList_v2',
+ data: [
+ { id: 'default', list: [{ id: 'd' }] },
+ { id: 'love', list: [{ id: 'l' }] },
+ { id: 'custom', list: [{ id: 'c' }] },
+ ],
+ })
+ assert.ok(converted)
+ assert.deepEqual(JSON.parse(converted), {
+ defaultList: [{ id: 'd' }],
+ loveList: [{ id: 'l' }],
+ userList: [{ id: 'custom', list: [{ id: 'c' }] }],
+ })
+})
+
+test('S19 playList_v2 转换:非该格式返回 null,残缺 data 不抛异常', () => {
+ assert.equal(kindApi.toInternalListSnapshot(null), null)
+ assert.equal(kindApi.toInternalListSnapshot({ type: 'lxRating_v1', ratings: {} }), null)
+ assert.equal(kindApi.toInternalListSnapshot({ type: 'playList_v2', data: 'bad' }), null)
+ const partial = kindApi.toInternalListSnapshot({ type: 'playList_v2', data: [null, { id: 'default', list: [] }] })
+ assert.ok(partial)
+ assert.deepEqual(JSON.parse(partial).defaultList, [])
+})
+
+const syncLinkBody = slice(
+ ' const { user, direction, preview, reconcile } = JSON.parse(await readBody(req))',
+ ' } catch (err: any) {',
+)
+
+const runSyncLink = async (options: {
+ body: unknown
+ config?: Record
+ users?: string[]
+}): Promise<{ ok: boolean, payload?: any, error?: string }> => {
+ const written: string[] = []
+ const res = { writeHead: () => {}, end: (text: string) => written.push(text) }
+ const config = {
+ users: (options.users ?? ['alice']).map(name => ({ name })),
+ ...options.config,
+ }
+ const context = {
+ req: {},
+ global: { lx: { config } },
+ readBody: async () => JSON.stringify(options.body),
+ syncRatingsToDislike: async (user: string) => ({ direction: 'ratingToDislike', user, scanned: 1, added: 1, skipped: 0, failed: 0, threshold: 1, batches: 1 }),
+ syncDislikesToRating: async (user: string) => ({ direction: 'dislikeToRating', user, scanned: 2, added: 0, skipped: 2, failed: 0, threshold: 1, batches: 1 }),
+ res,
+ }
+ const run = runInVm<(user?: string) => Promise>(
+ `async function run() {\n${syncLinkBody}\n}`,
+ context,
+ 'run',
+ )
+ try {
+ await run()
+ return { ok: true, payload: written.length ? JSON.parse(written[0]) : null }
+ } catch (err: any) {
+ return { ok: false, error: err.message }
+ }
+}
+
+test('S20 /api/subsonic/sync-link:非法 direction / 缺用户被拒绝', async () => {
+ const badDirection = await runSyncLink({ body: { user: 'alice', direction: 'unknown' } })
+ assert.equal(badDirection.ok, false)
+ assert.match(badDirection.error || '', /Invalid direction/)
+
+ const noUser = await runSyncLink({ body: { direction: 'ratingToDislike' } })
+ assert.equal(noUser.ok, false)
+ assert.match(noUser.error || '', /Missing user/)
+})
+
+test('S20 /api/subsonic/sync-link:开关未开启时不得执行同步', async () => {
+ const result = await runSyncLink({ body: { user: 'alice', direction: 'ratingToDislike' } })
+ assert.equal(result.ok, false, '服务端必须独立校验开关,不能只依赖前端')
+ assert.match(result.error || '', /联动开关未开启/)
+})
+
+test('S20 /api/subsonic/sync-link:未知用户不得创建用户空间', async () => {
+ const result = await runSyncLink({
+ body: { user: 'mallory', direction: 'ratingToDislike' },
+ config: { 'subsonic.linkRatingToDislike': true },
+ })
+ assert.equal(result.ok, false)
+ assert.match(result.error || '', /用户不存在/)
+})
+
+test('S20 /api/subsonic/sync-link:两个方向分别调用对应同步实现', async () => {
+ const forward = await runSyncLink({
+ body: { user: 'alice', direction: 'ratingToDislike' },
+ config: { 'subsonic.linkRatingToDislike': true },
+ })
+ assert.equal(forward.ok, true, forward.error)
+ assert.equal(forward.payload.direction, 'ratingToDislike')
+ assert.equal(forward.payload.added, 1)
+
+ const backward = await runSyncLink({
+ body: { user: 'bob', direction: 'dislikeToRating' },
+ config: { 'subsonic.linkDislikeToRating': true },
+ users: ['alice', 'bob'],
+ })
+ assert.equal(backward.ok, true, backward.error)
+ assert.equal(backward.payload.direction, 'dislikeToRating')
+ assert.equal(backward.payload.user, 'bob')
+})
+
+test('S20 /api/subsonic/sync-link:_open 公开用户同样允许同步', async () => {
+ const result = await runSyncLink({
+ body: { user: '_open', direction: 'ratingToDislike' },
+ config: { 'subsonic.linkRatingToDislike': true },
+ })
+ assert.equal(result.ok, true, result.error)
+ assert.equal(result.payload.user, '_open')
+})
diff --git a/test/subsonic-rating-playlists.test.ts b/test/subsonic-rating-playlists.test.ts
new file mode 100644
index 00000000..ef34968d
--- /dev/null
+++ b/test/subsonic-rating-playlists.test.ts
@@ -0,0 +1,174 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import path from 'node:path'
+import vm from 'node:vm'
+import { transformSync } from 'esbuild'
+
+/** 从 subsonic.ts 抽出评分歌单的两个私有方法,隔离其余依赖后放进 vm 执行 */
+const source = fs.readFileSync(path.resolve('src/server/integrations/subsonic.ts'), 'utf8')
+const start = source.indexOf(' private async getRatingPlaylistSongs')
+const end = source.indexOf(' private async handleGetPlaylists', start)
+assert.ok(start > 0 && end > start, '无法从 subsonic.ts 中定位评分歌单方法')
+const methods = source.slice(start, end)
+
+const harness = `
+class Harness {
+ onlineSongCache = new Map()
+ meta = { ratings: {} }
+ listData = { loveList: [], defaultList: [], userList: [] }
+ disliked = new Set()
+ loadOnlineSongCache() {}
+ async getUserSubsonicMeta() { return this.meta }
+ async getSubsonicListData() { return this.listData }
+ async filterDislikedSongs(_u, arr) { return arr.filter(m => !this.disliked.has(m.id)) }
+${methods}
+}
+globalThis.Harness = Harness
+`
+
+const build = (ratingPlaylists: boolean | undefined = undefined) => {
+ const config: Record = {}
+ if (ratingPlaylists !== undefined) config['subsonic.ratingPlaylists'] = ratingPlaylists
+ const context = vm.createContext({
+ global: { lx: { config } },
+ getLocalTracks: async () => [],
+ localMusic: (x: any) => x,
+ localRatingId: (track: any) => track.platformId || track.id,
+ withTimeout: (p: any) => p,
+ LINK_SYNC_ITEM_TIMEOUT_MS: 8000,
+ subsonicLog: { warn() {} },
+ console,
+ })
+ vm.runInContext(transformSync(harness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
+ return { instance: new (context as any).Harness(), config }
+}
+
+const song = (id: string) => ({ id, name: id })
+
+test('S5 ratings 值域:非整数 / 越界 / 字符串评分不得让 getPlaylists 抛错', async () => {
+ const { instance } = build()
+ instance.meta = { ratings: { a: 1.5, b: 2.1, c: 0, d: 6, e: -1, f: '3', g: null, h: NaN, good: 3 } }
+ instance.listData = {
+ loveList: [],
+ defaultList: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'good'].map(song),
+ userList: [],
+ }
+ const groups = await instance.getRatingPlaylistSongs('alice')
+ assert.equal(groups.get(3).length, 1, '只有整数 3 应进入 3 星歌单')
+ for (const star of [1, 2, 4, 5]) assert.equal(groups.get(star).length, 0)
+})
+
+test('高评分屏蔽歌曲同时进入原星级与不喜欢合集', async () => {
+ const { instance } = build(true)
+ instance.meta = { ratings: { a: 5 } }
+ instance.listData = { loveList: [], defaultList: [song('a')], userList: [] }
+ instance.disliked = new Set(['a'])
+ const groups = await instance.getRatingPlaylistSongs('alice')
+ assert.equal(groups.get(1)[0].id, 'a')
+ assert.equal(groups.get(5)[0].id, 'a')
+})
+
+test('缓存淘汰后解析已评分歌曲,排除专辑和歌手', async () => {
+ const { instance } = build(true)
+ instance.meta = { ratings: { lost: 5, alb_wy_1: 5, art_tx_2: 4 } }
+ const calls: string[] = []
+ instance.resolveSongMeta = async (_user: string, id: string) => { calls.push(id); return { music: song(id) } }
+ const groups = await instance.getRatingPlaylistSongs('alice')
+ assert.equal(groups.get(5)[0].id, 'lost')
+ assert.deepEqual(calls, ['lost'])
+})
+
+test('S8 开关:默认开启,显式 false 关闭', () => {
+ assert.equal(build().instance.isRatingPlaylistEnabled(), true)
+ assert.equal(build(true).instance.isRatingPlaylistEnabled(), true)
+ assert.equal(build(false).instance.isRatingPlaylistEnabled(), false)
+})
+
+test('S8 1 星歌单 = 1 星歌曲 ∪ dislike 命中;关闭开关后不再并入', async () => {
+ const enabled = build(true)
+ enabled.instance.meta = { ratings: { a: 1 } }
+ enabled.instance.listData = { loveList: [], defaultList: [song('a'), song('b')], userList: [] }
+ enabled.instance.disliked = new Set(['b'])
+ const on = await enabled.instance.getRatingPlaylistSongs('alice')
+ assert.deepEqual(Array.from(on.get(1)).map((m: any) => m.id).sort(), ['a', 'b'])
+
+ const disabled = build(false)
+ disabled.instance.meta = { ratings: { a: 1 } }
+ disabled.instance.listData = { loveList: [], defaultList: [song('a'), song('b')], userList: [] }
+ disabled.instance.disliked = new Set(['b'])
+ const off = await disabled.instance.getRatingPlaylistSongs('alice')
+ assert.deepEqual(Array.from(off.get(1)).map((m: any) => m.id), ['a'])
+})
+
+test('S8 只读语义与配置契约:虚拟歌单不得被写入或删除', () => {
+ assert.match(source, /RATING_PLAYLIST_PREFIX = 'rating_'/)
+ assert.match(source, /if \(this\.isRatingPlaylistEnabled\(\)\)/)
+ assert.match(source, /评星歌单为只读播放列表,请通过给歌曲评星来修改/)
+ assert.match(source, /评星歌单为只读播放列表,不支持删除/)
+ assert.match(source, /Rating playlists are disabled/)
+ assert.match(source, /if \(!global\.lx\.config\['subsonic\.linkRatingToDislike'\]/)
+ const defaults = fs.readFileSync(path.resolve('src/defaultConfig.ts'), 'utf8')
+ assert.match(defaults, /'subsonic\.ratingPlaylists': true/)
+})
+
+/** 从 handleGetPlaylists 抽出「生成评星歌单」的真实代码块,验证空星级是否会被跳过 */
+const blockStart = source.indexOf(' // [新增] 评星歌单:1~5 星')
+const blockEnd = source.indexOf(" if (format === 'json')", blockStart)
+assert.ok(blockStart > 0 && blockEnd > blockStart, '无法从 subsonic.ts 中定位评星歌单生成块')
+const ratingPlaylistBlock = source.slice(blockStart, blockEnd)
+
+const playlistHarness = `
+class PlaylistHarness {
+ constructor(groups, enabled) {
+ this.groups = groups
+ this.enabled = enabled
+ this.playlists = []
+ }
+ isRatingPlaylistEnabled() { return this.enabled }
+ async getRatingPlaylistSongs() { return this.groups }
+ async run() {
+ const playlists = this.playlists
+ const buildPlaylist = (id, name, musics) => ({ id, name, songCount: musics.length })
+ const username = 'alice'
+ const subsonicLog = { error() {} }
+ const SubsonicHandler = { RATING_PLAYLIST_PREFIX: 'rating_' }
+${ratingPlaylistBlock}
+ return playlists
+ }
+}
+globalThis.PlaylistHarness = PlaylistHarness
+`
+
+const buildPlaylists = async (counts: Record, enabled = true): Promise> => {
+ const groups = new Map()
+ for (let star = 1; star <= 5; star++) {
+ groups.set(star, Array.from({ length: counts[star] || 0 }, (_, i) => song(`${star}-${i}`)))
+ }
+ const context = vm.createContext({ console })
+ vm.runInContext(transformSync(playlistHarness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
+ const instance = new (context as any).PlaylistHarness(groups, enabled)
+ // vm 里的数组来自另一个 realm,deepStrictEqual 会因原型不同而误判;先转成主 realm 的普通对象。
+ return JSON.parse(JSON.stringify(await instance.run()))
+}
+
+test('S18 空星级不生成歌单:只暴露有歌曲的星级', async () => {
+ const list = await buildPlaylists({ 1: 1, 3: 2 })
+ assert.deepEqual(list.map(p => p.id), ['rating_1', 'rating_3'])
+ assert.deepEqual(list.map(p => p.songCount), [1, 2])
+ assert.match(list[0].name, /我不喜欢/)
+})
+
+test('S18 五个星级都为空时不生成任何评星歌单', async () => {
+ assert.deepEqual(await buildPlaylists({}), [])
+})
+
+test('S18 关闭开关时不生成评星歌单', async () => {
+ assert.deepEqual(await buildPlaylists({ 5: 1 }, false), [])
+})
+
+test('S18 五个星级都有歌曲时全部保留且顺序固定', async () => {
+ const list = await buildPlaylists({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 })
+ assert.deepEqual(list.map(p => p.id), ['rating_1', 'rating_2', 'rating_3', 'rating_4', 'rating_5'])
+ assert.ok(list.every(p => p.songCount === 1))
+})
diff --git a/test/web-rating-controls.test.cjs b/test/web-rating-controls.test.cjs
new file mode 100644
index 00000000..49a36f29
--- /dev/null
+++ b/test/web-rating-controls.test.cjs
@@ -0,0 +1,34 @@
+const test=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
+function setup(fetch){const storage=new Map([['lx_sync_user','a'],['lx_user_token','token-a']]);const window={addEventListener(){}};const context={window,localStorage:{getItem:k=>storage.get(k)||null},document:{addEventListener(){},hidden:false},setInterval(){},queueMicrotask(){},fetch,ensureUserAuthToken:async()=>true,getUserAuthHeaders:()=>({'x-user-token':storage.get('lx_user_token')})};vm.runInNewContext(fs.readFileSync('public/music/js/library/rating_manager.js','utf8'),context);return{manager:window.RatingManager,storage,context}}
+const button=()=>({isConnected:true,textContent:'',setAttribute(){}});
+test('评分按100首分批查询,不上传播放URL或鉴权封面',async()=>{const bodies=[];const{manager}=setup(async(_u,options)=>{const body=JSON.parse(options.body);bodies.push(body);return{ok:true,json:async()=>({entries:body.songs.map(s=>({id:s.id,rating:4}))})}});const buttons=[];for(let n=0;n<101;n++){const b=button();buttons.push(b);manager.attach(b,{id:'wy_'+n,source:'wy',url:'secret',pic:'token-cover'})}await manager.refresh();assert.equal(bodies.length,2);assert.equal(bodies[0].songs.length,100);assert.ok(!JSON.stringify(bodies).includes('secret'));assert.ok(!JSON.stringify(bodies).includes('token-cover'));assert.ok(buttons.every(x=>x.textContent==='★'))});
+test('账号切换与播放器换歌后,旧请求不能刷新新控件',async()=>{let release;const{manager,storage}=setup(()=>new Promise(r=>{release=r}));const b=button();manager.attach(b,{id:'wy_old',source:'wy'});const pending=manager.refresh();await new Promise(r=>setImmediate(r));manager.attach(b,{id:'wy_new',source:'wy'});release({ok:true,json:async()=>({entries:[{id:'wy_old',rating:5}]})});await pending;assert.equal(b.textContent,'☆');const pending2=manager.refresh();await new Promise(r=>setImmediate(r));storage.set('lx_sync_user','b');storage.set('lx_user_token','token-b');release({ok:true,json:async()=>({entries:[{id:'wy_new',rating:5}]})});await pending2;assert.equal(b.textContent,'☆')});
+
+
+test('同账号重叠刷新只采用最后请求,旧响应不能覆盖新评分',async()=>{
+ const waiting=[];const {manager}=setup(()=>new Promise(resolve=>waiting.push(resolve)));const b=button();manager.attach(b,{id:'wy_1',source:'wy'});
+ const old=manager.refresh();await new Promise(r=>setImmediate(r));const latest=manager.refresh();await new Promise(r=>setImmediate(r));
+ waiting[1]({ok:true,json:async()=>({entries:[{id:'wy_1',rating:5}]})});await latest;
+ waiting[0]({ok:true,json:async()=>({entries:[{id:'wy_1',rating:1}]})});await old;assert.equal(b.textContent,'★');
+});
+
+
+test('分数tag仅在已评分时显示,清分和退出账号立即隐藏',async()=>{
+ let rating=4;const {manager,storage}=setup(async()=>({ok:true,json:async()=>({entries:[{id:'wy_1',rating}]})}));const tag=button();manager.attach(tag,{id:'wy_1',source:'wy'},true);assert.equal(tag.hidden,true);await manager.refresh();assert.equal(tag.hidden,false);assert.equal(tag.textContent,'★ 4');rating=0;await manager.refresh();assert.equal(tag.hidden,true);rating=5;await manager.refresh();assert.equal(tag.hidden,false);storage.delete('lx_sync_user');await manager.refresh();assert.equal(tag.hidden,true);
+});
+
+
+test('歌单操作区接入评分,合集刷新重新读服务端并保留滚动',()=>{
+ const list=fs.readFileSync('public/music/js/library/songlist_manager.js','utf8');assert.match(list,/data-songlist-rating/);assert.match(list,/RatingManager\?\.attach\(button, entry.item\)/);
+ const rating=fs.readFileSync('public/music/js/library/rating_manager.js','utf8');assert.match(rating,/data = latest; render\(\); list.scrollTop = scroll/);assert.match(rating,/collectionDialog\?\.open.*collectionReload/);
+});
+
+
+test('同账号认证更新Token后用户偏好仍能回显并解除禁用',async()=>{
+ const {manager,storage,context}=setup(async()=>({ok:true,json:async()=>({dislikeAutoRating:true})}));const toggle={disabled:false,checked:false};const status={};context.document.getElementById=id=>id==='setting-dislike-auto-rating'?toggle:status;context.ensureUserAuthToken=async()=>{storage.set('lx_user_token','renewed-token')};await manager.loadPreference();assert.equal(toggle.checked,true);assert.equal(toggle.disabled,false);
+});
+
+
+test('版本不变仅查询版本,版本变化再批量刷新分数',async()=>{
+ let version='v1',resolved=0,polled=0;const {manager}=setup(async(_u,opt)=>{const body=JSON.parse(opt.body);if(body.operation==='version'){polled++;return{ok:true,json:async()=>({version})}}resolved++;return{ok:true,json:async()=>({version,entries:[{id:'wy_1',rating:4}]})}});manager.attach(button(),{id:'wy_1',source:'wy'});await manager.refresh();await manager.refresh();assert.equal(resolved,1);assert.equal(polled,1);version='v2';await manager.refresh();assert.equal(resolved,2)
+});
diff --git a/test/web-rating-service.test.ts b/test/web-rating-service.test.ts
new file mode 100644
index 00000000..e87341ff
--- /dev/null
+++ b/test/web-rating-service.test.ts
@@ -0,0 +1,172 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+import fs from 'node:fs'
+import os from 'node:os'
+import path from 'node:path'
+import vm from 'node:vm'
+import { createRequire } from 'node:module'
+import { transformSync } from 'esbuild'
+const actualRequire = createRequire(import.meta.url)
+function load(root: string, snapshot:()=>Promise = async()=>{}) {
+ const tracks:any[]=[]
+ const locks=new Map>()
+ const mod={exports:{} as any}
+ const require=(id:string):any=>{
+ if (id === './ratingMigration' || id === '../../server/rating/ratingMigration') return actualRequire(path.resolve('src/server/rating/ratingMigration.ts'))
+ if(id==='./ratingIdentityLock')return actualRequire(path.resolve('src/server/rating/ratingIdentityLock.ts'))
+ if (id === './ratingValidation') return actualRequire(path.resolve('src/server/rating/ratingValidation.ts'))
+ if(id==='@/user')return {getUserDirname:(user:string)=>user}
+ if(id==='./ratingSnapshot')return {withMetaLock:(user:string,fn:any)=>{const p=(locks.get(user)||Promise.resolve()).then(fn);locks.set(user,p.catch(()=>{}));return p},createRatingSnapshot:snapshot}
+ if(id==='../music/subsonicLocal')return {localRatingId:(track:any)=>track.platformId||track.id,getLocalTracks:async()=>tracks,localFile:(track:any)=>{const resolved=path.resolve(track.root,track.filename);return resolved.startsWith(path.resolve(track.root)+path.sep)&&fs.existsSync(resolved)?resolved:null}}
+ if(id==='../music/fileCache')return {getCacheDir:(user:string)=>path.join(root,user,'audio')}
+ if(id==='../music/customMusicManager')return {getCustomMusicDir:(user:string)=>path.join(root,user,'audio')}
+ return actualRequire(id)
+ }
+ vm.runInNewContext(transformSync(fs.readFileSync('src/server/rating/ratingService.ts','utf8'),{loader:'ts',format:'cjs'}).code,{module:mod,exports:mod.exports,require,URL,global:{lx:{userPath:root,dataPath:root}}})
+ return {api:mod.exports,tracks}
+}
+test('Web评分服务并发写入不丢数据、隔离用户、保留收藏且不改歌单',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-web-rating-unit-'))
+ try{
+ const {api}=load(root);fs.mkdirSync(path.join(root,'a'),{recursive:true});const file=path.join(root,'a','subsonic-meta.json');fs.writeFileSync(file,JSON.stringify({starredArtists:['artist'],ratings:{}}));const list=path.join(root,'a','list.json');fs.writeFileSync(list,'playlist-marker')
+ await Promise.all(Array.from({length:20},(_,n)=>api.writeRating('a','wy_'+n,5,'web',{name:'歌'+n,singer:'人',source:'wy',url:'secret-url'})))
+ const data=api.readRatings('a');assert.equal(Object.keys(data.ratings).length,20);assert.equal(data.starredArtists[0],'artist');assert.equal(data.ratingOrigins.wy_0.channel,'web');assert.equal(fs.readFileSync(list,'utf8'),'playlist-marker');assert.equal(Object.keys(api.readRatings('b').ratings).length,0)
+ assert.ok(!fs.readFileSync(path.join(root,'a','rating','songs.json'),'utf8').includes('secret-url'))
+ for(const r of [-1,6,1.5,'5'])await assert.rejects(()=>api.writeRating('a','wy_bad',r,'web'))
+ await api.writeRating('a','wy_0',0,'web');assert.equal(api.readRatings('a').ratings.wy_0,undefined);assert.equal(api.readRatings('a').ratingOrigins.wy_0,undefined)
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+test('本地未关联按本地ID,关联及重新关联后按对应在线ID,拒绝越权',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-web-rating-local-'))
+ try{
+ const {api,tracks}=load(root);const audio=path.join(root,'a','audio');fs.mkdirSync(audio,{recursive:true});fs.writeFileSync(path.join(audio,'song.wav'),'audio');tracks.push({id:'local_stable',root:audio,filename:'song.wav',platformId:undefined})
+ const song={isLocal:true,_localFilename:'song.wav',_localFolder:'custom',_localUsername:'a',source:'tx',songmid:'old'}
+ assert.equal(await api.resolveRatingId('a',song),'local_stable');tracks[0].platformId='wy_new';assert.equal(await api.resolveRatingId('a',{...song,source:'wy',songmid:'new'}),'wy_new')
+ await api.writeRating('a','local_stable',2,'web')
+ assert.equal(api.readRatings('a').ratings.wy_new,2)
+ tracks[0].platformId='tx_other'
+ assert.equal(await api.resolveStoredRatingId('a','local_stable'),'tx_other')
+ assert.equal(api.readRatings('a').ratings.tx_other,undefined)
+ await api.writeRating('a','local_stable',4,'subsonic')
+ assert.equal(api.readRatings('a').ratings.tx_other,4)
+ assert.equal(api.readRatings('a').ratings.wy_new,2)
+ await assert.rejects(()=>api.resolveRatingId('b',song));await assert.rejects(()=>api.resolveRatingId('a',{...song,_localFilename:'../subsonic-meta.json'}))
+ assert.equal(await api.resolveRatingId('a',{source:'tx',songmid:'abc',songId:123}),'tx_abc')
+ assert.equal(await api.resolveRatingId('a',{source:'wy',id:'wy_123'}),'wy_123')
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+
+test('资料合并保留封面和专辑ID,拒绝凭据及查询参数封面', async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-merge-'))
+ try {
+ const {api}=load(root)
+ await api.writeRating('a','wy_1',4,'web',{name:'原歌名',source:'wy',albumId:'album',img:'https://example.com/cover.jpg'})
+ await api.writeRating('a','wy_1',3,'web',{name:'',source:'wy'})
+ const row=JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1
+ assert.equal(row.name,'原歌名');assert.equal(row.albumId,'album');assert.equal(row.img,'https://example.com/cover.jpg')
+ assert.equal(api.safeCover('https://user:secret@example.com/a'),'');assert.equal(api.safeCover('https://example.com/a?token=secret'),'')
+ } finally { fs.rmSync(root,{recursive:true,force:true}) }
+})
+
+test('排队评分在取得身份锁后重新解析关联,并清除旧本地评分',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-identity-'))
+ try {
+ const {api,tracks}=load(root);const audio=path.join(root,'a/audio');fs.mkdirSync(audio,{recursive:true});fs.writeFileSync(path.join(audio,'a.wav'),'audio')
+ const track={id:'local_a',root:audio,filename:'a.wav',platformId:undefined as string|undefined};tracks.push(track)
+ await api.writeRating('a','local_a',5,'web')
+ const lock=actualRequire(path.resolve('src/server/rating/ratingIdentityLock.ts')).withRatingIdentityLock
+ let release!:()=>void
+ const association=lock('a',async()=>{await new Promise(r=>{release=r});track.platformId='wy_linked'})
+ await Promise.resolve()
+ const write=api.writeRating('a','local_a',2,'web')
+ release();await association;await write
+ assert.equal(api.readRatings('a').ratings.wy_linked,2)
+ fs.writeFileSync(path.join(root,'a/subsonic-local-registry.json'),JSON.stringify({tracks:{a:track}}))
+ assert.ok(!api.listRatingItems('a').some((x:any)=>x.id==='local_a'));assert.equal(api.readRatings('a').ratings.wy_linked,2)
+ } finally {fs.rmSync(root,{recursive:true,force:true})}
+})
+
+
+test('重复同分不改来源或时间,清分记录历史;旧本地ID资料不覆盖关联在线描述',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-idempotence-'))
+ try {
+ const {api,tracks}=load(root)
+ await api.writeRating('a','wy_1',4,'web',{name:'在线曲名',source:'wy'})
+ const first=api.readRatings('a');const duplicate=await api.writeRating('a','wy_1',4,'subsonic')
+ assert.equal(duplicate.unchanged,true);assert.deepEqual(JSON.parse(JSON.stringify(api.readRatings('a').ratingOrigins)),JSON.parse(JSON.stringify(first.ratingOrigins)))
+ assert.equal(api.readRatings('a').ratingHistory.wy_1.length,1)
+ tracks.push({id:'local_old',platformId:'wy_1',title:'在线曲名',artist:'歌手',album:'专辑'})
+ await api.writeRating('a','local_old',2,'web',{id:'local_old',source:'local',name:'旧本地曲名'})
+ const catalog=JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8'));assert.equal(catalog.wy_1.name,'在线曲名')
+ await api.writeRating('a','wy_1',0,'web');const events=api.readRatings('a').ratingHistory.wy_1
+ assert.equal(events.length,3);assert.equal(events[2].from,2);assert.equal(events[2].to,0)
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+
+test('同分补齐描述不改评分来源,资料失败可单独重试,版本能感知变化',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-repair-'))
+ try {
+ const {api}=load(root);await api.writeRating('a','wy_1',4,'subsonic')
+ const before=api.readRatings('a');const version=api.ratingVersion('a')
+ await api.writeRating('a','wy_1',4,'web',{name:'补齐曲名',source:'wy'})
+ assert.equal(api.readRatings('a').ratingOrigins.wy_1.channel,'subsonic');assert.equal(api.readRatings('a').ratingHistory.wy_1.length,1)
+ assert.equal(JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1.name,'补齐曲名');assert.notEqual(api.ratingVersion('a'),version)
+ const rename=fs.renameSync;let failed=false
+ try{fs.renameSync=((from:any,to:any)=>{if(!failed&&String(to).endsWith('songs.json')){failed=true;throw Error('disk')}return rename(from,to)}) as any
+ const saved=await api.writeRating('a','wy_1',4,'web',{name:'可重试曲名',source:'wy'});assert.ok(saved.snapshotWarning)
+ }finally{fs.renameSync=rename}
+ assert.ok(api.readRatingTasks('a').some((t:any)=>t.kind==='catalog'))
+ await api.retryRatingTasks('a');assert.equal(api.readRatingTasks('a').length,0)
+ assert.equal(JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1.name,'可重试曲名');assert.equal(api.readRatings('a').ratingOrigins.wy_1.at,before.ratingOrigins.wy_1.at)
+ const stable=api.ratingVersion('a');await api.writeRating('a','wy_1',4,'web',{name:'可重试曲名',source:'wy'});assert.equal(api.ratingVersion('a'),stable)
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+test('快照失败记录状态并可重试,修复资料不能覆写较新的评分',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-snapshot-retry-'))
+ try{
+ let fail=true;const {api}=load(root,async()=>{if(fail)throw Error('snapshot disk')})
+ const result=await api.writeRating('a','wy_1',2,'web',{name:'歌曲',source:'wy'});assert.ok(result.snapshotWarning);assert.ok(api.readRatingTasks('a').some((x:any)=>x.kind==='snapshot'))
+ fail=false;await api.retryRatingTasks('a');assert.equal(api.readRatingTasks('a').length,0)
+ await api.writeRating('a','wy_1',5,'subsonic');await api.writeRating('a','wy_1',1,'repair',{name:'歌曲',source:'wy'},{repairOnly:true});assert.equal(api.readRatings('a').ratings.wy_1,5);assert.equal(api.readRatings('a').ratingOrigins.wy_1.channel,'subsonic')
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+
+test('同步状态损坏不清空文件,主评分损坏拒绝写入与覆盖',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-storage-errors-'))
+ try{
+ const {api}=load(root);await api.writeRating('a','wy_1',4,'web',{name:'歌曲',source:'wy'});const status=path.join(root,'a/rating/sync-status.json');fs.writeFileSync(status,'broken')
+ assert.equal(api.readRatingTasks('a')[0].kind,'status');await assert.rejects(()=>api.retryRatingTasks('a'),/损坏/);assert.equal(fs.readFileSync(status,'utf8'),'broken')
+ const meta=path.join(root,'a/subsonic-meta.json');fs.writeFileSync(meta,'broken');await assert.rejects(()=>api.writeRating('a','wy_1',5,'web'));assert.equal(fs.readFileSync(meta,'utf8'),'broken')
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+
+test('补分主文件写入失败登记重试任务,只重试未评分项且保留较新评分',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-backfill-retry-'))
+ try {
+ const {api}=load(root);const rename=fs.renameSync;let fail=true
+ try{fs.renameSync=((from:any,to:any)=>{if(fail&&String(to).endsWith('subsonic-meta.json'))throw Error('meta disk');return rename(from,to)}) as any
+ await assert.rejects(()=>api.writeRating('a','wy_1',1,'web-dislike-link',{name:'曲',source:'wy'},{onlyMissing:true}),/meta disk/)
+ }finally{fs.renameSync=rename;fail=false}
+ assert.ok(api.readRatingTasks('a').some((x:any)=>x.kind==='backfill'))
+ await api.writeRating('a','wy_1',5,'web',{name:'曲',source:'wy'});await api.retryRatingTasks('a');assert.equal(api.readRatings('a').ratings.wy_1,5);assert.equal(api.readRatingTasks('a').length,0)
+ }finally{fs.rmSync(root,{recursive:true,force:true})}
+})
+
+test('关联迁移保留来源与历史,在线评分优先且删除本地所有当前记录',async()=>{
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-migrate-'))
+ try {
+ const {api}=load(root);const dir=path.join(root,'a');fs.mkdirSync(path.join(dir,'rating'),{recursive:true})
+ const file=path.join(dir,'subsonic-meta.json')
+ fs.writeFileSync(file,JSON.stringify({ratings:{local_a:5,local_b:2,wy_b:4},ratingOrigins:{local_a:{channel:'web',at:1},local_b:{channel:'web',at:2},wy_b:{channel:'subsonic',at:3}},ratingHistory:{local_a:[{from:0,to:5,channel:'web',at:1}],local_b:[]},starredArtists:['keep']}))
+ fs.writeFileSync(path.join(dir,'rating/songs.json'),JSON.stringify({local_a:{name:'原歌名'},local_b:{name:'删除'}}))
+ fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),JSON.stringify({tracks:{a:{id:'local_a',platformId:'wy_a'},b:{id:'local_b',platformId:'wy_b'}}}))
+ const data=api.readRatings('a');assert.deepEqual(JSON.parse(JSON.stringify(data.ratings)),{wy_a:5,wy_b:4});assert.equal(data.ratingOrigins.wy_a.at,1);assert.equal(data.ratingOrigins.wy_b.channel,'subsonic');assert.equal(data.ratingHistory.wy_a.length,1);assert.equal(data.ratingHistory.local_a,undefined);assert.equal(data.starredArtists[0],'keep')
+ const catalog=JSON.parse(fs.readFileSync(path.join(dir,'rating/songs.json'),'utf8'));assert.equal(catalog.local_a,undefined);assert.equal(catalog.local_b,undefined);assert.equal(catalog.wy_a.name,'原歌名')
+ const before=fs.readFileSync(file,'utf8');api.readRatings('a');assert.equal(fs.readFileSync(file,'utf8'),before)
+ } finally {fs.rmSync(root,{recursive:true,force:true})}
+})