feat: export playlists to files and import them in the web client
This commit is contained in:
1 parent
649916ab9d
commit
df8e0c5b8b
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 个字。', '导入太频繁了,稍后再试'])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user