diff --git a/web/src/features/playlists/playlist-file.test.ts b/web/src/features/playlists/playlist-file.test.ts new file mode 100644 index 0000000..db496d7 --- /dev/null +++ b/web/src/features/playlists/playlist-file.test.ts @@ -0,0 +1,108 @@ +import type { MyPlaylistItem } from '@/core/realtime' +import { describe, expect, it } from 'vitest' +import { exportFileName, exportPlaylist, importLimit, parsePlaylistFile } from './playlist-file' + +const sunny: MyPlaylistItem = { + track: { source: 'netease', id: '186016' }, + meta: { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016', durationMs: 269000 }, +} +const clip: MyPlaylistItem = { + track: { source: 'link', id: 'https://example.com/a.mp3' }, + meta: { title: 'a.mp3', artist: '', album: '', coverUrl: '', pageUrl: 'https://example.com/a.mp3', durationMs: 0 }, +} + +function file(tracks: unknown, extra: Record = {}): string { + return JSON.stringify({ format: 'alisten.playlist', name: '通勤', exportedAt: '2026-11-09T12:00:00.000Z', tracks, ...extra }) +} + +describe('exportPlaylist', () => { + it('按设计的格式导出,曲目保持歌单里的先后', () => { + const exported = JSON.parse(exportPlaylist('通勤', [sunny, clip], Date.UTC(2026, 10, 9, 12))) + expect(exported).toEqual({ + format: 'alisten.playlist', + name: '通勤', + exportedAt: '2026-11-09T12:00:00.000Z', + tracks: [ + { source: 'netease', id: '186016', title: '晴天', artist: '周杰伦', album: '叶惠美', durationMs: 269000, coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016' }, + { source: 'link', id: 'https://example.com/a.mp3', title: 'a.mp3', artist: '', album: '', durationMs: 0, coverUrl: '', pageUrl: 'https://example.com/a.mp3' }, + ], + }) + }) + + it('导出的文件能原样读回', () => { + expect(parsePlaylistFile(exportPlaylist('通勤', [sunny, clip], 0))).toEqual({ + name: '通勤', + truncated: false, + tracks: [ + { track: sunny.track, title: '晴天', artist: '周杰伦', album: '叶惠美', durationMs: 269000, coverUrl: 'https://p1.music.126.net/a.jpg', pageUrl: 'https://music.163.com/#/song?id=186016' }, + { track: clip.track, title: 'a.mp3', artist: '', album: '', durationMs: 0, coverUrl: '', pageUrl: 'https://example.com/a.mp3' }, + ], + }) + }) +}) + +describe('exportFileName', () => { + it('用歌单名作文件名', () => { + expect(exportFileName('我喜欢')).toBe('我喜欢.json') + }) + + it('替换文件名里不能用的字符', () => { + expect(exportFileName('a/b\\c:d*e?f"gi|j\tk\u007Fl')).toBe('a_b_c_d_e_f_g_h_i_j_k_l.json') + }) +}) + +describe('parsePlaylistFile', () => { + const notPlaylistFile = '这个文件不是导出的歌单。' + const cases: { name: string, content: string, want: string }[] = [ + { name: '不是 JSON', content: '晴天', want: notPlaylistFile }, + { name: '不是对象', content: '[]', want: notPlaylistFile }, + { name: '没有 format', content: JSON.stringify({ name: '通勤', tracks: [] }), want: notPlaylistFile }, + { name: 'format 不对', content: file([], { format: 'alisten.history' }), want: notPlaylistFile }, + { name: 'tracks 不是数组', content: file({ 0: sunny.track }), want: notPlaylistFile }, + { name: '空数组', content: file([]), want: '这个歌单文件里没有歌。' }, + { name: '缺少音源', content: file([{ id: '186016', title: '晴天' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' }, + { name: '音源不认识', content: file([{ source: 'netease', id: '1' }, { source: 'spotify', id: '2' }]), want: '文件里的第 2 首歌缺少音源或 ID,没有导入。' }, + { name: '音源是原型上的属性名', content: file([{ source: 'toString', id: '1' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' }, + { name: 'ID 为空', content: file([{ source: 'qq', id: '' }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' }, + { name: 'ID 不是字符串', content: file([{ source: 'netease', id: 186016 }]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' }, + { name: '曲目不是对象', content: file([null]), want: '文件里的第 1 首歌缺少音源或 ID,没有导入。' }, + ] + for (const { name, content, want } of cases) { + it(name, () => { + expect(parsePlaylistFile(content)).toBe(want) + }) + } + + it('元数据类型不对时当作没有', () => { + const parsed = parsePlaylistFile(file([{ source: 'qq', id: '0039MnYb0qxYhV', title: 7, artist: null, album: ['叶惠美'], durationMs: '269000' }])) + expect(parsed).toEqual({ name: '通勤', truncated: false, tracks: [{ track: { source: 'qq', id: '0039MnYb0qxYhV' }, title: '', durationMs: 0 }] }) + }) + + it('时长不是 0 到 24 小时之间的整数毫秒时当作未知', () => { + const durations = [-1, 1.5, 86_400_001, 86_400_000] + const parsed = parsePlaylistFile(file(durations.map((durationMs, index) => ({ source: 'netease', id: String(index), title: '晴天', durationMs })))) + expect(typeof parsed === 'string' ? parsed : parsed.tracks.map(track => track.durationMs)).toEqual([0, 0, 0, 86_400_000]) + }) + + it('歌单名缺少或不是字符串时为空,由服务端换成默认的名字', () => { + const parsed = parsePlaylistFile(file([sunny.track], { name: 42 })) + expect(typeof parsed === 'string' ? parsed : parsed.name).toBe('') + }) + + it('超过 1000 首只取前 1000 首,后面的不检查', () => { + const tracks: unknown[] = Array.from({ length: importLimit }, (_, index) => ({ source: 'netease', id: String(index), title: `第 ${index + 1} 首` })) + tracks.push({ source: 'spotify', id: 'x' }) + const parsed = parsePlaylistFile(file(tracks)) + if (typeof parsed === 'string') + throw new Error(parsed) + expect(parsed.truncated).toBe(true) + expect(parsed.tracks).toHaveLength(importLimit) + expect(parsed.tracks.at(-1)?.track).toEqual({ source: 'netease', id: String(importLimit - 1) }) + }) + + it('正好 1000 首不算截断', () => { + const tracks = Array.from({ length: importLimit }, (_, index) => ({ source: 'netease', id: String(index), title: '晴天' })) + const parsed = parsePlaylistFile(file(tracks)) + expect(typeof parsed === 'string' ? parsed : parsed.truncated).toBe(false) + }) +}) diff --git a/web/src/features/playlists/playlist-file.ts b/web/src/features/playlists/playlist-file.ts new file mode 100644 index 0000000..641aa40 --- /dev/null +++ b/web/src/features/playlists/playlist-file.ts @@ -0,0 +1,82 @@ +import type { ImportedTrack, MyPlaylistItem, Source } from '@/core/realtime' + +const format = 'alisten.playlist' +export const importLimit = 1000 +const maxDurationMs = 86_400_000 +const sources: ReadonlySet = new Set(['netease', 'qq', 'bilibili', 'link']) +const notPlaylistFile = '这个文件不是导出的歌单。' + +export interface PlaylistFile { + name: string + tracks: ImportedTrack[] + // 文件里超过 importLimit 首,只取了前面的。 + truncated: boolean +} + +export function exportPlaylist(name: string, items: readonly MyPlaylistItem[], now: number): string { + const tracks = items.map(({ track, meta }) => ({ + source: track.source, + id: track.id, + title: meta.title, + artist: meta.artist, + album: meta.album, + durationMs: meta.durationMs, + coverUrl: meta.coverUrl, + pageUrl: meta.pageUrl, + })) + return JSON.stringify({ format, name, exportedAt: new Date(now).toISOString(), tracks }, null, 2) +} + +// Windows 的文件名不能含 \ / : * ? " < > | 和控制字符,其他系统也不能含 /。 +export function exportFileName(name: string): string { + return `${name.replace(/[\\/:*?"<>|\p{Cc}]/gu, '_')}.json` +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +// 文件可以随意改写:元数据的类型不对时当作没有,由服务端决定用不用;音源和 ID 不对就认不出是哪首歌,整个文件不导入。 +function importedTrack(entry: unknown): ImportedTrack | null { + if (!isRecord(entry) || typeof entry.source !== 'string' || !sources.has(entry.source) || typeof entry.id !== 'string' || entry.id === '') + return null + const { durationMs } = entry + const imported: ImportedTrack = { + track: { source: entry.source as Source, id: entry.id }, + title: text(entry.title) ?? '', + durationMs: typeof durationMs === 'number' && Number.isInteger(durationMs) && durationMs >= 0 && durationMs <= maxDurationMs ? durationMs : 0, + } + for (const key of ['artist', 'album', 'coverUrl', 'pageUrl'] as const) { + const value = text(entry[key]) + if (value !== undefined) + imported[key] = value + } + return imported +} + +// 返回读到的歌单,或者给用户看的错误信息。 +export function parsePlaylistFile(content: string): PlaylistFile | string { + let data: unknown + try { + data = JSON.parse(content) + } + catch { + return notPlaylistFile + } + if (!isRecord(data) || data.format !== format || !Array.isArray(data.tracks)) + return notPlaylistFile + if (data.tracks.length === 0) + return '这个歌单文件里没有歌。' + const tracks: ImportedTrack[] = [] + for (const [index, entry] of data.tracks.slice(0, importLimit).entries()) { + const track = importedTrack(entry) + if (!track) + return `文件里的第 ${index + 1} 首歌缺少音源或 ID,没有导入。` + tracks.push(track) + } + return { name: text(data.name) ?? '', tracks, truncated: data.tracks.length > importLimit } +} diff --git a/web/src/features/playlists/playlists-store.test.ts b/web/src/features/playlists/playlists-store.test.ts index 6858f0c..b475353 100644 --- a/web/src/features/playlists/playlists-store.test.ts +++ b/web/src/features/playlists/playlists-store.test.ts @@ -33,6 +33,8 @@ const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUr const notReady = '这首歌还在准备,准备好之后再保存' const full = '一个歌单最多 1000 首歌' const changed = '歌单已经变了,刷新之后再试' +const imported = { id: 'p3', name: '导入的歌单', favorites: false, trackCount: 2, createdAtMs: 2, updatedAtMs: 2, coverUrl: null } +const fileTrack = { track: sunny, title: '晴天', durationMs: 269000 } let requests: string[] let release: (() => void)[] @@ -40,6 +42,7 @@ let failRemove: boolean let failAdd: boolean let failCopy: boolean let failMove: boolean +let importFailure: { status: number, code: string, message: string } | null let bodies: unknown[] let holdReads: boolean let failReads: boolean @@ -80,6 +83,7 @@ beforeEach(() => { failAdd = false failCopy = false failMove = false + importFailure = null bodies = [] holdReads = false failReads = false @@ -110,6 +114,14 @@ beforeEach(() => { } if (init.body) bodies.push(JSON.parse(String(init.body))) + if (importFailure && /^POST \/api\/me\/(?:playlists\/(?:imports|platform-imports)|playlist-links)$/.test(request)) + return json(importFailure.status, { error: { code: importFailure.code, message: importFailure.message } }) + if (request === 'POST /api/me/playlists/imports' || request === 'POST /api/me/playlists/platform-imports') { + serverPlaylists.push(imported) + return json(201, { playlist: imported, imported: 2 }) + } + if (request === 'POST /api/me/playlist-links') + return json(200, { source: 'netease', playlistId: '3778678' }) const write = /^POST \/api\/me\/playlists\/(\w+)\/(items|removals|copies|moves)$/.exec(request) if (write?.[2] === 'items') { if (failAdd) @@ -519,4 +531,102 @@ describe('playlistsStore', () => { await settle() expect(playlistsStore.state.playlists).toEqual([favorites, commute]) }) + + it('importFile 导入成功后新歌单立刻出现在列表里,再重新读取', async () => { + const { playlistsStore } = await import('./playlists-store') + await playlistsStore.load() + requests = [] + holdReads = true + const done = playlistsStore.importFile('通勤', [fileTrack]) + await flush() + release.shift()!() + expect(await done).toEqual({ playlist: imported, imported: 2 }) + expect(playlistsStore.state.playlists).toEqual([favorites, imported]) + expect(bodies).toEqual([{ name: '通勤', tracks: [fileTrack] }]) + holdReads = false + await settle() + expect(requests).toEqual(['POST /api/me/playlists/imports', 'GET /api/me/playlists']) + expect(playlistsStore.state.playlists).toEqual([favorites, imported]) + }) + + it('importFile 遇到 INVALID_ARGUMENT 时说是文件的问题,不重新读取', async () => { + const { playlistsStore } = await import('./playlists-store') + const { notificationStore } = await import('@/shared/ui') + importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: 'tracks[0].track.id:不是合法的链接' } + const done = playlistsStore.importFile('通勤', [fileTrack]) + await settle() + expect(await done).toBeNull() + expect(requests).toEqual(['POST /api/me/playlists/imports']) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['文件里有不符合要求的内容,没有导入。']) + }) + + it('importFile 超过歌单数上限时提示服务端的文案', async () => { + const { playlistsStore } = await import('./playlists-store') + const { notificationStore } = await import('@/shared/ui') + importFailure = { status: 409, code: 'CONFLICT', message: '最多只能建 50 个歌单' } + const done = playlistsStore.importFile('通勤', [fileTrack]) + await settle() + expect(await done).toBeNull() + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['最多只能建 50 个歌单']) + }) + + it('resolveLink 返回音源和歌单 ID,不重新读取歌单', async () => { + const { playlistsStore } = await import('./playlists-store') + const link = '分享歌单:通勤 https://163cn.tv/abc' + const done = playlistsStore.resolveLink(link) + await settle() + expect(await done).toEqual({ source: 'netease', playlistId: '3778678' }) + expect(requests).toEqual(['POST /api/me/playlist-links']) + expect(bodies).toEqual([{ link }]) + }) + + it('resolveLink 认不出链接时提示服务端的文案', async () => { + const { playlistsStore } = await import('./playlists-store') + const { notificationStore } = await import('@/shared/ui') + importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: '不是网易云或 QQ 音乐的歌单链接' } + const done = playlistsStore.resolveLink('https://example.com/') + await settle() + expect(await done).toBeNull() + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['不是网易云或 QQ 音乐的歌单链接']) + }) + + it('resolveLink 超过 2048 字节的文本不发出', async () => { + const { playlistsStore } = await import('./playlists-store') + const { notificationStore } = await import('@/shared/ui') + expect(await playlistsStore.resolveLink('歌'.repeat(683))).toBeNull() + expect(requests).toEqual([]) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['文字太长了,只粘贴歌单的链接或分享文字就好。']) + const done = playlistsStore.resolveLink('歌'.repeat(682)) + await settle() + await done + expect(requests).toEqual(['POST /api/me/playlist-links']) + }) + + it('importPlatform 导入成功后新歌单立刻出现在列表里,再重新读取', async () => { + const { playlistsStore } = await import('./playlists-store') + holdReads = true + const done = playlistsStore.importPlatform('通勤', 'netease', '3778678') + await flush() + release.shift()!() + expect(await done).toEqual({ playlist: imported, imported: 2 }) + expect(playlistsStore.state.playlists).toEqual([imported]) + expect(bodies).toEqual([{ name: '通勤', source: 'netease', playlistId: '3778678' }]) + holdReads = false + await settle() + expect(requests).toEqual(['POST /api/me/playlists/platform-imports', 'GET /api/me/playlists']) + expect(playlistsStore.state.playlists).toEqual([favorites, imported]) + }) + + it('importPlatform 歌单名不合法时说明规则,其他错误提示服务端的文案', async () => { + const { playlistsStore } = await import('./playlists-store') + const { notificationStore } = await import('@/shared/ui') + importFailure = { status: 400, code: 'INVALID_ARGUMENT', message: '歌单名不合法' } + const first = playlistsStore.importPlatform(' ', 'netease', '3778678') + await settle() + importFailure = { status: 429, code: 'RATE_LIMITED', message: '导入太频繁了,稍后再试' } + const second = playlistsStore.importPlatform('通勤', 'netease', '3778678') + await settle() + expect([await first, await second]).toEqual([null, null]) + expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['歌单名要 1 到 50 个字。', '导入太频繁了,稍后再试']) + }) }) diff --git a/web/src/features/playlists/playlists-store.ts b/web/src/features/playlists/playlists-store.ts index 6c32275..7dacc57 100644 --- a/web/src/features/playlists/playlists-store.ts +++ b/web/src/features/playlists/playlists-store.ts @@ -1,4 +1,6 @@ -import type { MyPlaylist, Track } from '@/core/realtime' +import type { ImportMyPlatformPlaylistResponse, ImportMyPlaylistResponse, ResolveMyPlaylistLinkResponse } from '@/core/api' +import type { ErrorTexts } from '@/core/errors' +import type { ImportedTrack, MyPlaylist, Source, Track } from '@/core/realtime' import { api } from '@/core/api' import { errorMessage } from '@/core/errors' import { profileStore } from '@/features/settings' @@ -27,6 +29,10 @@ interface PlaylistsState { const empty: PlaylistsState = { playlists: null, error: null, loadedAt: 0, memberships: new Map() } const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' } +const fileTexts = { INVALID_ARGUMENT: '文件里有不符合要求的内容,没有导入。' } +// 接口只收 1 到 2048 字节的文本:太长的这里不发出,对话框不提交空的,INVALID_ARGUMENT 就只剩认不出链接,这时的 message 是写给用户的。 +const maxLinkBytes = 2048 +const linkTexts: ErrorTexts = { INVALID_ARGUMENT: message => message } class PlaylistsStore extends Store { #account: string | null = null @@ -94,8 +100,7 @@ class PlaylistsStore extends Store { async create(name: string): Promise { try { const playlist = await api.createMyPlaylist({ name }) - this.setState({ playlists: [...(this.state.playlists ?? []), playlist] }) - void this.load() + this.#added(playlist) return playlist } catch (error) { @@ -104,6 +109,44 @@ class PlaylistsStore extends Store { } } + async importFile(name: string, tracks: ImportedTrack[]): Promise { + try { + const imported = await api.importMyPlaylist({ name, tracks }) + this.#added(imported.playlist) + return imported + } + catch (error) { + notificationStore.error(errorMessage(error, fileTexts)) + return null + } + } + + async resolveLink(link: string): Promise { + if (new TextEncoder().encode(link).length > maxLinkBytes) { + notificationStore.error('文字太长了,只粘贴歌单的链接或分享文字就好。') + return null + } + try { + return await api.resolveMyPlaylistLink({ link }) + } + catch (error) { + notificationStore.error(errorMessage(error, linkTexts)) + return null + } + } + + async importPlatform(name: string, source: Source, playlistId: string): Promise { + try { + const imported = await api.importMyPlatformPlaylist({ name, source, playlistId }) + this.#added(imported.playlist) + return imported + } + catch (error) { + notificationStore.error(errorMessage(error, nameTexts)) + return null + } + } + async rename(playlist: MyPlaylist, name: string): Promise { try { await api.renameMyPlaylist(playlist.id, { name }) @@ -167,6 +210,12 @@ class PlaylistsStore extends Store { } } + // 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。 + #added(playlist: MyPlaylist): void { + this.setState({ playlists: [...(this.state.playlists ?? []), playlist] }) + void this.load() + } + // 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。 async #sync(playlistId: string, track: Track, saved: boolean): Promise { const account = this.#account