feat: export playlists to files and import them in the web client

This commit is contained in:
DiheChen committed 2026-11-10 10:15:11 +08:00
1 parent 8966954489
commit 93a91aad81
4 files changed
+352 -3

No files matched your search

@@ -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, unknown> = {}): 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"g<h>i|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)
})
})
@@ -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<string> = new Set<Source>(['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<string, unknown> {
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 }
}
@@ -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 个字。', '导入太频繁了,稍后再试'])
})
})
+52 -3
View File
@@ -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<PlaylistsState> {
#account: string | null = null
@@ -94,8 +100,7 @@ class PlaylistsStore extends Store<PlaylistsState> {
async create(name: string): Promise<MyPlaylist | null> {
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<PlaylistsState> {
}
}
async importFile(name: string, tracks: ImportedTrack[]): Promise<ImportMyPlaylistResponse | null> {
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<ResolveMyPlaylistLinkResponse | null> {
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<ImportMyPlatformPlaylistResponse | null> {
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<boolean> {
try {
await api.renameMyPlaylist(playlist.id, { name })
@@ -167,6 +210,12 @@ class PlaylistsStore extends Store<PlaylistsState> {
}
}
// 先放进列表再重新读取:打开新歌单时,列表里要已经有它,否则详情页会以为它被删掉了。
#added(playlist: MyPlaylist): void {
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
void this.load()
}
// 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。
async #sync(playlistId: string, track: Track, saved: boolean): Promise<void> {
const account = this.#account