feat: track member playlists in the web client
This commit is contained in:
1 parent
c87b48f014
commit
ef4558bd65
9 files changed
+735
-4
No files matched your search
@@ -22,7 +22,7 @@ server {
|
||||
|
||||
include /tmp/alisten.d/*.conf;
|
||||
|
||||
location ~ ^/(?:admin|settings|history)?$ {
|
||||
location ~ ^/(?:admin|settings|history|playlists(?:/[^/]+)?)?$ {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files /index.html =404;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { chatStore } from '@/features/chat'
|
||||
import { historyStore } from '@/features/history'
|
||||
import { lyricsStore } from '@/features/lyrics'
|
||||
import { playerStore, reminderStore, volumeStore } from '@/features/playback'
|
||||
import { playlistsStore } from '@/features/playlists'
|
||||
import { roomEntryStore } from '@/features/rooms'
|
||||
import { searchStore } from '@/features/search'
|
||||
import { appearanceStore, performanceStore, profileStore } from '@/features/settings'
|
||||
@@ -26,6 +27,7 @@ performanceStore.start()
|
||||
volumeStore.start()
|
||||
brandStore.start()
|
||||
profileStore.start()
|
||||
playlistsStore.start()
|
||||
playerStore.start()
|
||||
reminderStore.start()
|
||||
historyStore.start()
|
||||
|
||||
@@ -65,11 +65,11 @@ class NavigationStore extends Store<NavigationState> {
|
||||
}
|
||||
|
||||
// 不在房间里时,房间的页面没有地址,去发现房间。换页面时侧栏保持原样。
|
||||
open(page: 'lobby' | 'admin' | 'settings' | 'history' | RoomPage): void {
|
||||
open(page: 'lobby' | 'admin' | 'settings' | 'history' | 'playlists' | RoomPage): void {
|
||||
const { panel } = this.state.route
|
||||
if (page === 'lobby' || page === 'history')
|
||||
this.navigate({ name: page, filter: '', panel })
|
||||
else if (page === 'admin' || page === 'settings')
|
||||
else if (page === 'admin' || page === 'settings' || page === 'playlists')
|
||||
this.navigate({ name: page, panel })
|
||||
else
|
||||
this.#openRoomPage(ref => ({ name: page, ref, panel }))
|
||||
|
||||
@@ -15,6 +15,9 @@ describe('parseRoute', () => {
|
||||
['/settings?panel=queue', { name: 'settings', panel: 'queue' }],
|
||||
['/history', { name: 'history', filter: '', panel: null }],
|
||||
['/history?q=%E6%99%B4&panel=history', { name: 'history', filter: '晴', panel: 'history' }],
|
||||
['/playlists', { name: 'playlists', panel: null }],
|
||||
['/playlists/abc', { name: 'my-playlist', id: 'abc', panel: null }],
|
||||
['/playlists/abc?panel=queue', { name: 'my-playlist', id: 'abc', panel: 'queue' }],
|
||||
['/r/my-room', { name: 'listen', ref: 'my-room', panel: null }],
|
||||
['/r/my-room?panel=history', { name: 'listen', ref: 'my-room', panel: 'history' }],
|
||||
['/r/my-room/queue?panel=queue', { name: 'queue', ref: 'my-room', panel: 'queue' }],
|
||||
@@ -64,6 +67,11 @@ describe('isNewEntry', () => {
|
||||
expect(isNewEntry({ name: 'history', filter: '晴', panel: null }, { name: 'history', filter: '晴天', panel: null })).toBe(false)
|
||||
})
|
||||
|
||||
it('打开另一个自己的歌单写入新记录,同一个歌单只换侧栏时替换当前地址', () => {
|
||||
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'b', panel: null })).toBe(true)
|
||||
expect(isNewEntry({ name: 'my-playlist', id: 'a', panel: null }, { name: 'my-playlist', id: 'a', panel: 'queue' })).toBe(false)
|
||||
})
|
||||
|
||||
it('提交新的关键词写入新记录,换来源、类别、页码和推荐只替换当前地址', () => {
|
||||
const search = (query: SearchQuery): Route => ({ name: 'search', ref: 'a', panel: null, query })
|
||||
const sunny = search({ view: 'all', keyword: '晴天', source: 'qq', page: 1 })
|
||||
|
||||
@@ -12,6 +12,8 @@ export type Route
|
||||
| { name: 'admin', panel: Panel | null }
|
||||
| { name: 'settings', panel: Panel | null }
|
||||
| { name: 'history', filter: string, panel: Panel | null }
|
||||
| { name: 'playlists', panel: Panel | null }
|
||||
| { name: 'my-playlist', id: string, panel: Panel | null }
|
||||
| { name: RoomPage, ref: string, panel: Panel | null }
|
||||
| { name: 'search', ref: string, panel: Panel | null, query: SearchQuery }
|
||||
| { name: 'playlist', ref: string, panel: Panel | null, source: Source, id: string, page: number }
|
||||
@@ -68,6 +70,11 @@ export function parseRoute({ pathname, search }: Pick<Location, 'pathname' | 'se
|
||||
return { name: 'settings', panel }
|
||||
if (pathname === '/history')
|
||||
return { name: 'history', filter: params.get('q') ?? '', panel }
|
||||
if (pathname === '/playlists')
|
||||
return { name: 'playlists', panel }
|
||||
const own = /^\/playlists\/([^/]+)$/.exec(pathname)
|
||||
if (own)
|
||||
return { name: 'my-playlist', id: own[1], panel }
|
||||
// 不做 decodeURIComponent:UUID、别名和歌单 ID 不含需要转义的字符,而它遇到不合法的转义序列会抛出异常。
|
||||
const room = /^\/r\/([^/]+)(\/.*)?$/.exec(pathname)
|
||||
if (!room)
|
||||
@@ -97,7 +104,10 @@ function pathOf(route: Route): string {
|
||||
case 'admin':
|
||||
case 'settings':
|
||||
case 'history':
|
||||
case 'playlists':
|
||||
return `/${route.name}`
|
||||
case 'my-playlist':
|
||||
return `/playlists/${route.id}`
|
||||
case 'listen':
|
||||
return `/r/${route.ref}`
|
||||
case 'playlist':
|
||||
@@ -126,6 +136,8 @@ export function formatRoute(route: Route): string {
|
||||
export function isNewEntry(from: Route, to: Route): boolean {
|
||||
if (from.name !== to.name || ('ref' in from && 'ref' in to && from.ref !== to.ref))
|
||||
return true
|
||||
if (from.name === 'my-playlist' && to.name === 'my-playlist')
|
||||
return from.id !== to.id
|
||||
if (from.name === 'search' && to.name === 'search')
|
||||
return to.query.view !== 'recommend' && to.query.keyword !== (from.query.view === 'recommend' ? '' : from.query.keyword)
|
||||
if (from.name === 'playlist' && to.name === 'playlist')
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { playlistsStore } from './playlists-store'
|
||||
@@ -0,0 +1,465 @@
|
||||
import type { Track } from '@/core/realtime'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
class FakeStorage {
|
||||
readonly #items = new Map<string, string>()
|
||||
|
||||
get length(): number {
|
||||
return this.#items.size
|
||||
}
|
||||
|
||||
key(index: number): string | null {
|
||||
return [...this.#items.keys()][index] ?? null
|
||||
}
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.#items.get(key) ?? null
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.#items.set(key, value)
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.#items.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const favorites = { id: 'p1', name: '我喜欢', favorites: true, trackCount: 1, createdAtMs: 1, updatedAtMs: 1, coverUrl: null }
|
||||
const sunny = { source: 'netease', id: '186016' } as const
|
||||
const rain = { source: 'netease', id: '185811' } as const
|
||||
const commute = { id: 'p2', name: '通勤', favorites: false, trackCount: 0, createdAtMs: 1, updatedAtMs: 1, coverUrl: null }
|
||||
const meta = { title: '晴天', artist: '周杰伦', album: '叶惠美', coverUrl: '', pageUrl: '', durationMs: 0 }
|
||||
const notReady = '这首歌还在准备,准备好之后再保存'
|
||||
const full = '一个歌单最多 1000 首歌'
|
||||
const changed = '歌单已经变了,刷新之后再试'
|
||||
|
||||
let requests: string[]
|
||||
let release: (() => void)[]
|
||||
let failRemove: boolean
|
||||
let failAdd: boolean
|
||||
let failCopy: boolean
|
||||
let failMove: boolean
|
||||
let bodies: unknown[]
|
||||
let holdReads: boolean
|
||||
let failReads: boolean
|
||||
let serverPlaylists: typeof favorites[]
|
||||
let server: Map<string, Map<string, Track>>
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status })
|
||||
}
|
||||
|
||||
function key(track: Track): string {
|
||||
return `${track.source}:${track.id}`
|
||||
}
|
||||
|
||||
function items(playlist: string): Map<string, Track> {
|
||||
let tracks = server.get(playlist)
|
||||
if (!tracks) {
|
||||
tracks = new Map()
|
||||
server.set(playlist, tracks)
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。
|
||||
async function read(body: unknown): Promise<Response> {
|
||||
if (holdReads)
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
if (failReads)
|
||||
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
|
||||
return json(200, body)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
requests = []
|
||||
release = []
|
||||
failRemove = false
|
||||
failAdd = false
|
||||
failCopy = false
|
||||
failMove = false
|
||||
bodies = []
|
||||
holdReads = false
|
||||
failReads = false
|
||||
serverPlaylists = [favorites]
|
||||
server = new Map([['p1', new Map([[key(sunny), sunny]])]])
|
||||
vi.stubGlobal('window', new EventTarget())
|
||||
const local = new FakeStorage()
|
||||
local.setItem('alisten.member-token', 'token')
|
||||
vi.stubGlobal('localStorage', local)
|
||||
vi.stubGlobal('fetch', async (path: string, init: RequestInit) => {
|
||||
const request = `${init.method} ${path}`
|
||||
requests.push(request)
|
||||
if (request === 'GET /api/me/playlists')
|
||||
return read({ items: [...serverPlaylists] })
|
||||
if (request === 'POST /api/me/playlist-memberships') {
|
||||
const { track } = JSON.parse(String(init.body)) as { track: Track }
|
||||
return read({ playlistIds: [...server].filter(([, tracks]) => tracks.has(key(track))).map(([id]) => id) })
|
||||
}
|
||||
// 让测试控制请求完成的先后,模拟连续点击。
|
||||
await new Promise<void>(resolve => release.push(resolve))
|
||||
if (request === 'DELETE /api/me/playlists/p2') {
|
||||
serverPlaylists = serverPlaylists.filter(p => p.id !== 'p2')
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (request === 'POST /api/me/playlists') {
|
||||
serverPlaylists.push(commute)
|
||||
return json(201, commute)
|
||||
}
|
||||
if (init.body)
|
||||
bodies.push(JSON.parse(String(init.body)))
|
||||
const write = /^POST \/api\/me\/playlists\/(\w+)\/(items|removals|copies|moves)$/.exec(request)
|
||||
if (write?.[2] === 'items') {
|
||||
if (failAdd)
|
||||
return json(409, { error: { code: 'CONFLICT', message: notReady } })
|
||||
const { track } = JSON.parse(String(init.body)) as { track: Track }
|
||||
items(write[1]).set(key(track), track)
|
||||
return json(201, { item: { track, meta } })
|
||||
}
|
||||
if (write?.[2] === 'removals') {
|
||||
if (failRemove)
|
||||
return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
|
||||
const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] }
|
||||
for (const track of tracks)
|
||||
items(write[1]).delete(key(track))
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (write?.[2] === 'copies') {
|
||||
if (failCopy)
|
||||
return json(409, { error: { code: 'CONFLICT', message: full } })
|
||||
const { tracks } = JSON.parse(String(init.body)) as { tracks: Track[] }
|
||||
const target = items(write[1])
|
||||
const added = tracks.filter(track => !target.has(key(track)))
|
||||
for (const track of added)
|
||||
target.set(key(track), track)
|
||||
return json(200, { added: added.length })
|
||||
}
|
||||
if (write?.[2] === 'moves') {
|
||||
if (failMove)
|
||||
return json(409, { error: { code: 'CONFLICT', message: changed } })
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
throw new Error(`没有预期的请求 ${request}`)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function loaded() {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
await playlistsStore.containing(sunny)
|
||||
await playlistsStore.containing(rain)
|
||||
requests = []
|
||||
const included = (track: Track) => playlistsStore.state.memberships.get(key(track))?.has('p1') ?? false
|
||||
return { playlistsStore, included }
|
||||
}
|
||||
|
||||
function flush() {
|
||||
return new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
// 每次先等一个宏任务,让已经开始的请求走到 fetch,再放行最早的一个。
|
||||
async function settle() {
|
||||
for (;;) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const next = release.shift()
|
||||
if (!next)
|
||||
return
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
const writes = () => requests.filter(r => !r.startsWith('GET ') && r !== 'POST /api/me/playlist-memberships')
|
||||
|
||||
describe('playlistsStore', () => {
|
||||
it('load 读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
await playlistsStore.load()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
expect(requests).toEqual(['GET /api/me/playlists'])
|
||||
})
|
||||
|
||||
it('containing 读取包含这首歌的歌单', async () => {
|
||||
const { included } = await loaded()
|
||||
expect(included(sunny)).toBe(true)
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('加入请求进行中又取消勾选,完成后再发移出', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('连点三次只发一次加入', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
// 同步结束后会重新读取,读到的服务端状态也能把勾选改对。下面几个用例挡住这次读取,只看回滚本身。
|
||||
it('第一次请求失败时以服务端为准,提示服务端的文案', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failAdd = true
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items'])
|
||||
expect(included(rain)).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([notReady])
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(false)
|
||||
})
|
||||
|
||||
it('移出失败时恢复勾选', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, sunny, false)
|
||||
expect(included(sunny)).toBe(false)
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(included(sunny)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(sunny)).toBe(true)
|
||||
})
|
||||
|
||||
it('加入成功后移出失败,勾选恢复为已加入', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
holdReads = true
|
||||
const done = playlistsStore.setMembership(favorites, rain, true)
|
||||
void playlistsStore.setMembership(favorites, rain, false)
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await done
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlists/p1/removals'])
|
||||
expect(included(rain)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('移出失败后重新读取也失败,勾选保持回滚的结果', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
failRemove = true
|
||||
failReads = true
|
||||
const done = playlistsStore.setMembership(favorites, sunny, false)
|
||||
await settle()
|
||||
await done
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
|
||||
expect(included(sunny)).toBe(true)
|
||||
expect(playlistsStore.state.failed).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在加入的歌以本地为准', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
await playlistsStore.containing(rain)
|
||||
expect(included(rain)).toBe(true)
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('读取期间正在移出的歌以本地为准', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, sunny, false)
|
||||
await flush()
|
||||
await playlistsStore.containing(sunny)
|
||||
expect(included(sunny)).toBe(false)
|
||||
await settle()
|
||||
expect(included(sunny)).toBe(false)
|
||||
})
|
||||
|
||||
it('同步完成之后才返回的旧读取不会取消勾选', async () => {
|
||||
const { playlistsStore, included } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await flush()
|
||||
void playlistsStore.containing(rain)
|
||||
await flush()
|
||||
// 先放行加入,同步结束;再放行加入之前发出的读取。
|
||||
release.shift()!()
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(included(rain)).toBe(true)
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(included(rain)).toBe(true)
|
||||
})
|
||||
|
||||
it('同步结束后重新读取歌单和这首歌所在的歌单', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
void playlistsStore.setMembership(favorites, rain, true)
|
||||
await settle()
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/items', 'POST /api/me/playlist-memberships', 'GET /api/me/playlists'])
|
||||
})
|
||||
|
||||
it('removeItems 一次移出多首,成功后重新读取歌单', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
items('p1').set(key(rain), rain)
|
||||
const done = playlistsStore.removeItems(favorites, [sunny, rain])
|
||||
await settle()
|
||||
expect(await done).toBe(true)
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p1/removals', 'GET /api/me/playlists'])
|
||||
expect(bodies).toEqual([{ tracks: [sunny, rain] }])
|
||||
expect(items('p1').size).toBe(0)
|
||||
})
|
||||
|
||||
it('removeItems 失败时提示并返回 false', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failRemove = true
|
||||
const done = playlistsStore.removeItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual(['暂时不可用'])
|
||||
})
|
||||
|
||||
it('copyItems 返回新加入的首数,目标歌单里已有的歌不算', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
items('p2').set(key(sunny), sunny)
|
||||
const done = playlistsStore.copyItems(favorites, commute, [sunny, rain])
|
||||
await settle()
|
||||
expect(await done).toBe(1)
|
||||
expect(requests).toEqual(['POST /api/me/playlists/p2/copies', 'GET /api/me/playlists'])
|
||||
expect(bodies).toEqual([{ from: 'p1', tracks: [sunny, rain] }])
|
||||
})
|
||||
|
||||
it('copyItems 装不下时提示是哪个歌单并返回 null', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failCopy = true
|
||||
const done = playlistsStore.copyItems(favorites, commute, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBeNull()
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([`没能保存到“通勤”:${full}`])
|
||||
})
|
||||
|
||||
it('moveItems 有 before 时放到它之前,没有时移到最后', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const first = playlistsStore.moveItems(favorites, [rain], sunny)
|
||||
await settle()
|
||||
const last = playlistsStore.moveItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect([await first, await last]).toEqual([true, true])
|
||||
expect(writes()).toEqual(['POST /api/me/playlists/p1/moves', 'POST /api/me/playlists/p1/moves'])
|
||||
expect(bodies).toEqual([{ tracks: [rain], before: sunny }, { tracks: [sunny] }])
|
||||
expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('moveItems 失败时提示并返回 false', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
const { notificationStore } = await import('@/shared/ui')
|
||||
failMove = true
|
||||
const done = playlistsStore.moveItems(favorites, [sunny])
|
||||
await settle()
|
||||
expect(await done).toBe(false)
|
||||
expect(notificationStore.state.notifications.map(n => n.message)).toEqual([changed])
|
||||
})
|
||||
|
||||
it('游客不读取歌单', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
playlistsStore.start()
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await flush()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
|
||||
it('注册之后读取,窗口获得焦点时刷新', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
await flush()
|
||||
expect(requests.filter(r => r === 'GET /api/me/playlists')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('换账号之后,上一个账号的读取结果作废', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
holdReads = true
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
await flush()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toBeNull()
|
||||
release.shift()!()
|
||||
await flush()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
})
|
||||
|
||||
it('换账号之后,上一个账号读到的所在歌单作废', async () => {
|
||||
const { playlistsStore } = await import('./playlists-store')
|
||||
const { profileStore } = await import('@/features/settings')
|
||||
playlistsStore.start()
|
||||
await profileStore.registered({ username: 'alice' })
|
||||
await flush()
|
||||
holdReads = true
|
||||
const reading = playlistsStore.containing(sunny)
|
||||
await flush()
|
||||
await profileStore.registered({ username: 'bob' })
|
||||
release.shift()!()
|
||||
await reading
|
||||
expect(playlistsStore.state.memberships.size).toBe(0)
|
||||
})
|
||||
|
||||
// 读取在删除或新建之前到达服务端,却在之后才返回时,结果不能写回界面。
|
||||
it('读取进行中删除歌单,旧读取返回后歌单不会回来', async () => {
|
||||
serverPlaylists.push(commute)
|
||||
const { playlistsStore } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.load()
|
||||
await flush()
|
||||
const removed = playlistsStore.remove(commute)
|
||||
await flush()
|
||||
release.splice(1, 1)[0]()
|
||||
await removed
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites])
|
||||
})
|
||||
|
||||
it('读取进行中新建歌单,旧读取返回后歌单不会消失', async () => {
|
||||
const { playlistsStore } = await loaded()
|
||||
holdReads = true
|
||||
void playlistsStore.load()
|
||||
await flush()
|
||||
const created = playlistsStore.create('通勤')
|
||||
await flush()
|
||||
release.splice(1, 1)[0]()
|
||||
await created
|
||||
holdReads = false
|
||||
await settle()
|
||||
expect(playlistsStore.state.playlists).toEqual([favorites, commute])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { MyPlaylist, Track } from '@/core/realtime'
|
||||
import { api } from '@/core/api'
|
||||
import { errorMessage } from '@/core/errors'
|
||||
import { profileStore } from '@/features/settings'
|
||||
import { Store } from '@/shared/lib/store'
|
||||
import { notificationStore } from '@/shared/ui'
|
||||
|
||||
export function trackKey(track: Track): string {
|
||||
return `${track.source}:${track.id}`
|
||||
}
|
||||
|
||||
function membershipKey(playlistId: string, track: Track): string {
|
||||
return `${playlistId} ${trackKey(track)}`
|
||||
}
|
||||
|
||||
interface PlaylistsState {
|
||||
// 游客,或者还没有读到时为 null。
|
||||
playlists: MyPlaylist[] | null
|
||||
failed: boolean
|
||||
// 曲目 → 包含它的歌单 id,只有读过的曲目才有。正在同步的歌单是最后一次勾选的结果,同步负责把服务端改成一致。
|
||||
memberships: ReadonlyMap<string, ReadonlySet<string>>
|
||||
}
|
||||
|
||||
const empty: PlaylistsState = { playlists: null, failed: false, memberships: new Map() }
|
||||
|
||||
const nameTexts = { INVALID_ARGUMENT: '歌单名要 1 到 50 个字。' }
|
||||
|
||||
class PlaylistsStore extends Store<PlaylistsState> {
|
||||
#account: string | null = null
|
||||
// 只有最后发起的读取能写入:换了账号,或者之后又读过一次时,旧的结果作废。
|
||||
#generation = 0
|
||||
#membershipReads = new Map<string, object>()
|
||||
#syncing = new Map<string, Promise<void>>()
|
||||
|
||||
constructor() {
|
||||
super(empty)
|
||||
}
|
||||
|
||||
start(): void {
|
||||
profileStore.addEventListener('change', this.#onProfile)
|
||||
window.addEventListener('focus', this.#onFocus)
|
||||
this.#onProfile()
|
||||
}
|
||||
|
||||
// 失败时只设置 failed,不抛出。
|
||||
load(): Promise<void> {
|
||||
return this.#load(++this.#generation)
|
||||
}
|
||||
|
||||
// 读取包含这首歌的歌单,写入 memberships;没读到时返回 false。读到了却已被之后的读取取代时也返回 true,由后来的读取写入。
|
||||
async containing(track: Track): Promise<boolean> {
|
||||
const account = this.#account
|
||||
const key = trackKey(track)
|
||||
const read = {}
|
||||
this.#membershipReads.set(key, read)
|
||||
try {
|
||||
const { playlistIds } = await api.listMyTrackPlaylists({ track })
|
||||
if (this.#account === account && this.#membershipReads.get(key) === read)
|
||||
this.#applyMemberships(track, playlistIds)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
finally {
|
||||
if (this.#membershipReads.get(key) === read)
|
||||
this.#membershipReads.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
setMembership(playlist: MyPlaylist, track: Track, included: boolean): Promise<void> {
|
||||
const key = membershipKey(playlist.id, track)
|
||||
const saved = this.#includes(playlist.id, track)
|
||||
this.#setIncluded(playlist.id, track, included)
|
||||
let sync = this.#syncing.get(key)
|
||||
if (!sync) {
|
||||
sync = this.#sync(playlist.id, track, saved)
|
||||
this.#syncing.set(key, sync)
|
||||
void sync.finally(() => {
|
||||
if (this.#syncing.get(key) !== sync)
|
||||
return
|
||||
this.#syncing.delete(key)
|
||||
// 同步之前发出的读取可能带着旧的勾选返回,重新读一次让它们作废;请求超时但服务端已生效时,也以服务端为准。
|
||||
void this.containing(track)
|
||||
void this.load()
|
||||
})
|
||||
}
|
||||
return sync
|
||||
}
|
||||
|
||||
async create(name: string): Promise<MyPlaylist | null> {
|
||||
try {
|
||||
const playlist = await api.createMyPlaylist({ name })
|
||||
this.setState({ playlists: [...(this.state.playlists ?? []), playlist] })
|
||||
void this.load()
|
||||
return playlist
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error, nameTexts))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async rename(playlist: MyPlaylist, name: string): Promise<boolean> {
|
||||
try {
|
||||
await api.renameMyPlaylist(playlist.id, { name })
|
||||
await this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error, nameTexts))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async remove(playlist: MyPlaylist): Promise<boolean> {
|
||||
try {
|
||||
await api.deleteMyPlaylist(playlist.id)
|
||||
this.setState({ playlists: (this.state.playlists ?? []).filter(p => p.id !== playlist.id) })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async removeItems(playlist: MyPlaylist, tracks: Track[]): Promise<boolean> {
|
||||
try {
|
||||
await api.removeMyPlaylistItems(playlist.id, { tracks })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回新加入的首数,to 里已经有的歌不算;失败时返回 null。
|
||||
async copyItems(from: MyPlaylist, to: MyPlaylist, tracks: Track[]): Promise<number | null> {
|
||||
try {
|
||||
const { added } = await api.copyMyPlaylistItems(to.id, { from: from.id, tracks })
|
||||
void this.load()
|
||||
return added
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(`没能保存到“${to.name}”:${errorMessage(error)}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 before 时移到最后。
|
||||
async moveItems(playlist: MyPlaylist, tracks: Track[], before?: Track): Promise<boolean> {
|
||||
try {
|
||||
await api.moveMyPlaylistItems(playlist.id, before ? { tracks, before } : { tracks })
|
||||
void this.load()
|
||||
return true
|
||||
}
|
||||
catch (error) {
|
||||
notificationStore.error(errorMessage(error))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 请求进行中又改了勾选时,等它完成之后按最新的勾选再发;失败时恢复成服务端的状态,不再发出。
|
||||
async #sync(playlistId: string, track: Track, saved: boolean): Promise<void> {
|
||||
const account = this.#account
|
||||
while (this.#account === account && this.#includes(playlistId, track) !== saved) {
|
||||
const included = this.#includes(playlistId, track)
|
||||
try {
|
||||
if (included)
|
||||
await api.addMyPlaylistItem(playlistId, { track })
|
||||
else
|
||||
await api.removeMyPlaylistItems(playlistId, { tracks: [track] })
|
||||
saved = included
|
||||
}
|
||||
catch (error) {
|
||||
if (this.#account === account)
|
||||
this.#setIncluded(playlistId, track, saved)
|
||||
notificationStore.error(errorMessage(error))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #load(generation: number): Promise<void> {
|
||||
try {
|
||||
const { items } = await api.listMyPlaylists()
|
||||
if (generation === this.#generation)
|
||||
this.setState({ playlists: items, failed: false })
|
||||
}
|
||||
catch {
|
||||
if (generation === this.#generation)
|
||||
this.setState({ failed: true })
|
||||
}
|
||||
}
|
||||
|
||||
// 正在同步的歌单以本地为准:读到的可能是同步完成之前的状态。
|
||||
#applyMemberships(track: Track, playlistIds: readonly string[]): void {
|
||||
const syncing = (id: string) => this.#syncing.has(membershipKey(id, track))
|
||||
const next = new Set(playlistIds.filter(id => !syncing(id)))
|
||||
for (const id of this.state.memberships.get(trackKey(track)) ?? []) {
|
||||
if (syncing(id))
|
||||
next.add(id)
|
||||
}
|
||||
this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), next) })
|
||||
}
|
||||
|
||||
#includes(playlistId: string, track: Track): boolean {
|
||||
return this.state.memberships.get(trackKey(track))?.has(playlistId) ?? false
|
||||
}
|
||||
|
||||
#setIncluded(playlistId: string, track: Track, included: boolean): void {
|
||||
const ids = new Set(this.state.memberships.get(trackKey(track)))
|
||||
if (included)
|
||||
ids.add(playlistId)
|
||||
else
|
||||
ids.delete(playlistId)
|
||||
this.setState({ memberships: new Map(this.state.memberships).set(trackKey(track), ids) })
|
||||
}
|
||||
|
||||
#onProfile = (): void => {
|
||||
const username = profileStore.state.account?.username ?? null
|
||||
if (username === this.#account)
|
||||
return
|
||||
this.#account = username
|
||||
this.#generation++
|
||||
this.#membershipReads.clear()
|
||||
this.#syncing.clear()
|
||||
this.setState(empty)
|
||||
if (username)
|
||||
void this.load()
|
||||
}
|
||||
|
||||
#onFocus = (): void => {
|
||||
if (this.#account)
|
||||
void this.load()
|
||||
}
|
||||
}
|
||||
|
||||
export const playlistsStore = new PlaylistsStore()
|
||||
+1
-1
@@ -62,7 +62,7 @@ export default defineConfig({
|
||||
workbox: {
|
||||
// 与 nginx 一致:只有页面路径返回 index.html,/api、/ws 和 /media 照常走网络。
|
||||
// Workbox 拿路径加查询参数来匹配,nginx 只看路径,所以这里的规则末尾允许查询参数。
|
||||
navigateFallbackAllowlist: [/^\/(?:admin|settings|history)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
|
||||
navigateFallbackAllowlist: [/^\/(?:admin|settings|history|playlists(?:\/[^/?]+)?)?(?:\?.*)?$/, /^\/r\/[^/?]+(?:\/(?:queue|chat|search|playlists\/[^/?]+\/[^/?]+))?(?:\?.*)?$/],
|
||||
importScripts: ['notification-click.js'],
|
||||
},
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user