diff --git a/web/src/features/history/history-page.tsx b/web/src/features/history/history-page.tsx
index c0dcf39..98fecde 100644
--- a/web/src/features/history/history-page.tsx
+++ b/web/src/features/history/history-page.tsx
@@ -1,21 +1,21 @@
-import type { PlayRecord } from './records'
+import type { MyListen } from '@/core/realtime'
import { CirclePlus, Download, History, Search, Trash2 } from 'lucide-react'
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { roomStore, sameTrack } from '@/core/room'
import { SaveToPlaylistButton } from '@/features/playlists'
import { enqueue, sourceName } from '@/features/queue'
import { bookTitle } from '@/shared/lib/book-title'
import { formatClock } from '@/shared/lib/time'
import { useStore } from '@/shared/lib/use-store'
-import { AsyncIconButton, EmptyState, IconButton, Modal, notificationStore, PlayingBars, SongArtwork } from '@/shared/ui'
+import { AsyncIconButton, ConfirmDialog, EmptyState, IconButton, notificationStore, PlayingBars, Skeleton, SongArtwork } from '@/shared/ui'
import { historyStore } from './history-store'
import './history.css'
const batchSize = 80
-function matches(record: PlayRecord, query: string): boolean {
+function matches(listen: MyListen, query: string): boolean {
const keyword = query.trim().toLowerCase()
- const { title, artist, album } = record.meta
+ const { title, artist, album } = listen.meta
return !keyword || `${title} ${artist} ${album}`.toLowerCase().includes(keyword)
}
@@ -23,10 +23,10 @@ function playedOn(ms: number): string {
return new Date(ms).toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' })
}
-function HistoryRow({ record, compact, connected, playing }: { record: PlayRecord, compact: boolean, connected: boolean, playing: boolean }) {
- const { title, artist, album, coverUrl, pageUrl } = record.meta
+function HistoryRow({ listen, compact, connected, playing }: { listen: MyListen, compact: boolean, connected: boolean, playing: boolean }) {
+ const { title, artist, album, coverUrl, pageUrl } = listen.meta
const performer = artist || '未知艺术家'
- const source = sourceName(record.track)
+ const source = sourceName(listen.track)
return (
@@ -37,24 +37,24 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
{[performer, source, album].filter(Boolean).join(' · ')}
- {!compact && }
+ {!compact && }
{
- await enqueue(record.track)
+ await enqueue(listen.track)
}}
/>
-
-
+ {
- historyStore.removePlay(record)
- notificationStore.success('已删除播放记录')
+ onAction={async () => {
+ if (await historyStore.remove(listen))
+ notificationStore.success('已删除播放记录')
}}
/>
@@ -62,13 +62,48 @@ function HistoryRow({ record, compact, connected, playing }: { record: PlayRecor
}
export function HistoryPage({ compact = false, filter = '', onFilter }: { compact?: boolean, filter?: string, onFilter?: (filter: string) => void }) {
- const plays = useStore(historyStore, s => s.plays)
+ const listens = useStore(historyStore, s => s.listens)
+ const error = useStore(historyStore, s => s.error)
const connected = useStore(roomStore, s => s.status === 'open')
const playing = useStore(roomStore, s => s.room?.nowPlaying?.entry.track)
const [limit, setLimit] = useState(batchSize)
const [confirming, setConfirming] = useState(false)
- const visible = plays.filter(record => matches(record, filter))
+ useEffect(() => historyStore.open(), [])
+ const all = listens ?? []
+ const visible = all.filter(listen => matches(listen, filter))
const shown = visible.slice(0, limit)
+ let body
+ if (listens === null) {
+ body = error === null
+ ?
+ : (
+ void historyStore.load()}>重试}>
+ {error}
+
+ )
+ }
+ else if (shown.length) {
+ body = (
+
+ {shown.map((listen, index) => {
+ const day = playedOn(listen.heardAtMs)
+ return (
+ -
+ {(index === 0 || playedOn(shown[index - 1].heardAtMs) !== day) &&
{day}
}
+
+
+ )
+ })}
+
+ )
+ }
+ else {
+ body = (
+
+ {filter ? '换个关键词,再找找看。' : '你在房间里听过的歌会记在这里。'}
+
+ )
+ }
return (
{!compact && (
@@ -90,53 +125,33 @@ export function HistoryPage({ compact = false, filter = '', onFilter }: { compac
aria-label="搜索播放历史"
/>
- historyStore.exportPlays()} />
- setConfirming(true)} />
-
-
- {`${plays.length} 首歌曲 · 保存在这台设备上`}
+ historyStore.exportListens()} />
+ setConfirming(true)} />
+ {listens && (
+
+ {`${listens.length} 首歌曲`}
+
+ )}
>
)}
- {shown.length
- ? (
-
- {shown.map((record, index) => {
- const day = playedOn(record.playedAtMs)
- return (
- -
- {(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) &&
{day}
}
-
-
- )
- })}
-
- )
- : (
-
- {filter ? '换个关键词,再找找看。' : '房间播放过的歌曲会保存在这台设备上,方便再次点歌。'}
-
- )}
+ {body}
{visible.length > limit && (
)}
{confirming && (
- setConfirming(false)}>
- 将清空这台设备上的全部播放记录。
-
-
-
-
-
+ {
+ if (await historyStore.clear())
+ setConfirming(false)
+ }}
+ onClose={() => setConfirming(false)}
+ >
+ 将清空你的全部播放记录,登录同一个账号的其他设备上也会一起清空。
+
)}
)
diff --git a/web/src/features/history/history-store.test.ts b/web/src/features/history/history-store.test.ts
new file mode 100644
index 0000000..299ace4
--- /dev/null
+++ b/web/src/features/history/history-store.test.ts
@@ -0,0 +1,265 @@
+import type { MyListen, NowPlaying, Track } from '@/core/realtime'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+class FakeStorage {
+ readonly #items = new Map()
+
+ 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)
+ }
+}
+
+function listen(id: string, heardAtMs: number): MyListen {
+ return {
+ track: { source: 'netease', id },
+ meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
+ heardAtMs,
+ }
+}
+
+function nowPlaying(entryId: string, startedAtMs: number): NowPlaying {
+ return { entry: { id: entryId }, startedAtMs } as NowPlaying
+}
+
+let requests: string[]
+let server: MyListen[]
+// 读取的结果在请求到达服务端时确定,holdReads 时推迟返回,模拟带着旧状态返回的读取。
+let holdReads: boolean
+let release: (() => void)[]
+let failReads: boolean
+// 服务端拒绝这个成员 token,客户端随即换成新的游客 renewed。
+let rejectedToken: string | null
+let room: EventTarget & { state: { room: { nowPlaying: NowPlaying | null } | null } }
+const renewed = { id: 'm2', name: '游客-9f8e', avatarHash: '' }
+
+function json(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), { status })
+}
+
+beforeEach(() => {
+ vi.resetModules()
+ requests = []
+ server = [listen('2', 2), listen('1', 1)]
+ holdReads = false
+ release = []
+ failReads = false
+ rejectedToken = null
+ room = Object.assign(new EventTarget(), { state: { room: null } })
+ vi.doMock('@/core/room', () => ({ roomStore: room }))
+ 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 (new Headers(init.headers).get('Authorization') === `Bearer ${rejectedToken}`)
+ return json(401, { error: { code: 'UNAUTHORIZED', message: '身份已经失效' } })
+ if (request === 'POST /api/members')
+ return json(201, { member: renewed, memberToken: 'token-2' })
+ if (request === 'GET /api/me')
+ return json(200, { member: renewed, recordListens: true })
+ if (request === 'GET /api/me/listens') {
+ const items = [...server]
+ if (holdReads)
+ await new Promise(resolve => release.push(resolve))
+ if (failReads)
+ return json(503, { error: { code: 'UNAVAILABLE', message: '暂时不可用' } })
+ return json(200, { items })
+ }
+ if (request === 'POST /api/me/listens/removals') {
+ const { track } = JSON.parse(String(init.body)) as { track: Track }
+ server = server.filter(l => l.track.id !== track.id)
+ return new Response(null, { status: 204 })
+ }
+ if (request === 'DELETE /api/me/listens') {
+ server = []
+ return new Response(null, { status: 204 })
+ }
+ throw new Error(`没有预期的请求 ${request}`)
+ })
+})
+
+afterEach(() => {
+ vi.doUnmock('@/core/room')
+ vi.unstubAllGlobals()
+})
+
+function flush() {
+ return new Promise(resolve => setTimeout(resolve, 0))
+}
+
+function play(next: NowPlaying | null) {
+ room.state = { room: next && { nowPlaying: next } }
+ room.dispatchEvent(new Event('change'))
+}
+
+async function started() {
+ const { historyStore } = await import('./history-store')
+ historyStore.start()
+ return historyStore
+}
+
+const reads = () => requests.filter(r => r === 'GET /api/me/listens').length
+
+describe('historyStore 的播放历史', () => {
+ it('每次打开页面都从服务端读取', async () => {
+ const historyStore = await started()
+ expect(historyStore.state.listens).toBeNull()
+ const close = historyStore.open()
+ await flush()
+ expect(historyStore.state.listens).toEqual(server)
+ close()
+ historyStore.open()
+ await flush()
+ expect(reads()).toBe(2)
+ })
+
+ it('页面开着时,正在播放的歌一变就重新读取;同一首歌的其他房间变化不读', async () => {
+ const historyStore = await started()
+ historyStore.open()
+ await flush()
+ play(nowPlaying('1', 1000))
+ await flush()
+ expect(reads()).toBe(2)
+ play(nowPlaying('1', 1000))
+ play(null)
+ await flush()
+ expect(reads()).toBe(2)
+ server = [listen('3', 3), ...server]
+ play(nowPlaying('2', 2000))
+ await flush()
+ expect(reads()).toBe(3)
+ expect(historyStore.state.listens?.[0]).toEqual(listen('3', 3))
+ })
+
+ it('页面都关了之后换歌不读取', async () => {
+ const historyStore = await started()
+ const closeFull = historyStore.open()
+ const closePanel = historyStore.open()
+ await flush()
+ closeFull()
+ play(nowPlaying('1', 1000))
+ await flush()
+ expect(reads()).toBe(3)
+ closePanel()
+ closePanel()
+ play(nowPlaying('2', 2000))
+ await flush()
+ expect(reads()).toBe(3)
+ })
+
+ it('只有最后发起的读取能写入', async () => {
+ const historyStore = await started()
+ holdReads = true
+ historyStore.open()
+ await flush()
+ server = [listen('3', 3), ...server]
+ play(nowPlaying('1', 1000))
+ await flush()
+ release.pop()?.()
+ await flush()
+ release.pop()?.()
+ await flush()
+ expect(historyStore.state.listens?.map(l => l.track.id)).toEqual(['3', '2', '1'])
+ })
+
+ it('删除之后,删除之前发起的读取作废,再读一次', async () => {
+ const historyStore = await started()
+ historyStore.open()
+ await flush()
+ holdReads = true
+ play(nowPlaying('1', 1000))
+ await flush()
+ holdReads = false
+ expect(await historyStore.remove(listen('2', 2))).toBe(true)
+ expect(historyStore.state.listens).toEqual([listen('1', 1)])
+ await flush()
+ release.shift()?.()
+ await flush()
+ expect(historyStore.state.listens).toEqual([listen('1', 1)])
+ expect(requests).toEqual(['GET /api/me/listens', 'GET /api/me/listens', 'POST /api/me/listens/removals', 'GET /api/me/listens'])
+ })
+
+ it('清空', async () => {
+ const historyStore = await started()
+ historyStore.open()
+ await flush()
+ expect(await historyStore.clear()).toBe(true)
+ expect(historyStore.state.listens).toEqual([])
+ expect(requests).toEqual(['GET /api/me/listens', 'DELETE /api/me/listens'])
+ })
+
+ it('读取失败时记下原因,已经读到的历史保留', async () => {
+ const historyStore = await started()
+ historyStore.open()
+ await flush()
+ failReads = true
+ play(nowPlaying('1', 1000))
+ await flush()
+ expect(historyStore.state.error).toBe('暂时不可用')
+ expect(historyStore.state.listens).toEqual(server)
+ failReads = false
+ play(nowPlaying('2', 2000))
+ await flush()
+ expect(historyStore.state.error).toBeNull()
+ })
+
+ it('身份换成新的游客时清掉旧身份的历史,进行中的读取作废;页面开着时换好之后重新读取', async () => {
+ const historyStore = await started()
+ const { identityEvents } = await import('@/core/api')
+ historyStore.open()
+ await flush()
+ holdReads = true
+ play(nowPlaying('1', 1000))
+ await flush()
+ identityEvents.emit({ kind: 'rejected' })
+ expect(historyStore.state.listens).toEqual([])
+ holdReads = false
+ server = [listen('9', 9)]
+ identityEvents.emit({ kind: 'renewed', member: renewed })
+ await flush()
+ release.shift()?.()
+ await flush()
+ expect(historyStore.state.listens).toEqual([listen('9', 9)])
+ expect(reads()).toBe(3)
+ })
+
+ it('页面都关了时换身份只清掉旧身份的历史,不读取', async () => {
+ const historyStore = await started()
+ const { identityEvents } = await import('@/core/api')
+ historyStore.open()()
+ await flush()
+ identityEvents.emit({ kind: 'rejected' })
+ identityEvents.emit({ kind: 'renewed', member: renewed })
+ await flush()
+ expect(historyStore.state.listens).toEqual([])
+ expect(reads()).toBe(1)
+ })
+
+ it('读取本身被拒绝时,换成新的游客之后读取它的历史', async () => {
+ const historyStore = await started()
+ rejectedToken = 'token'
+ server = [listen('9', 9)]
+ historyStore.open()
+ await flush()
+ expect(historyStore.state).toMatchObject({ listens: [listen('9', 9)], error: null })
+ expect(requests).toEqual(['GET /api/me/listens', 'POST /api/members', 'GET /api/me', 'GET /api/me/listens'])
+ })
+})
diff --git a/web/src/features/history/history-store.ts b/web/src/features/history/history-store.ts
index ab39f92..d10faa8 100644
--- a/web/src/features/history/history-store.ts
+++ b/web/src/features/history/history-store.ts
@@ -1,25 +1,30 @@
-import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
+import type { SearchCategory, SearchRecord, SearchSource } from './records'
+import type { IdentityEvent } from '@/core/api'
+import type { MyListen, Track } from '@/core/realtime'
+import { api, identityEvents } from '@/core/api'
+import { errorMessage } from '@/core/errors'
import { roomStore } from '@/core/room'
import { Store } from '@/shared/lib/store'
-import { parsePlays, parseSearches, recordPlay, recordSearch } from './records'
+import { notificationStore } from '@/shared/ui'
+import { parseSearches, recordSearch } from './records'
export const searchLimits = [5, 10, 20, 50]
const defaultSearchLimit = 10
-const playsKey = 'alisten.play-history'
-const playsEnabledKey = 'alisten.play-history.enabled'
const searchesKey = 'alisten.search-history'
const searchesEnabledKey = 'alisten.search-history.enabled'
const searchLimitKey = 'alisten.search-history.limit'
-export interface HistorySettings {
- playsEnabled: boolean
+export interface SearchSettings {
searchesEnabled: boolean
searchLimit: number
}
-interface HistoryState extends HistorySettings {
- plays: PlayRecord[]
+interface HistoryState extends SearchSettings {
+ // 播放历史在服务端;还没有读到时为 null。
+ listens: MyListen[] | null
+ // 最近一次读取失败的原因,读取成功后为 null。
+ error: string | null
searches: SearchRecord[]
}
@@ -32,11 +37,24 @@ function localDate(date: Date): string {
return [date.getFullYear(), date.getMonth() + 1, date.getDate()].map(n => String(n).padStart(2, '0')).join('-')
}
+function playingKey(): string {
+ const nowPlaying = roomStore.state.room?.nowPlaying
+ return nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
+}
+
+function sameTrack(a: Track, b: Track): boolean {
+ return a.source === b.source && a.id === b.id
+}
+
class HistoryStore extends Store {
#playing = ''
+ #pages = 0
+ // 只有最后发起的读取能写入:之后又读过一次,或者删除、清空、换了身份之后,此前的读取作废。
+ #generation = 0
+ #reading = 0
constructor() {
- super({ plays: [], playsEnabled: true, searches: [], searchesEnabled: true, searchLimit: defaultSearchLimit })
+ super({ listens: null, error: null, searches: [], searchesEnabled: true, searchLimit: defaultSearchLimit })
}
start(): void {
@@ -44,29 +62,67 @@ class HistoryStore extends Store {
const storedSearches = localStorage.getItem(searchesKey)
const searches = parseSearches(storedSearches, searchLimit)
this.setState({
- plays: parsePlays(localStorage.getItem(playsKey)),
- playsEnabled: localStorage.getItem(playsEnabledKey) !== 'false',
searches,
searchesEnabled: localStorage.getItem(searchesEnabledKey) !== 'false',
searchLimit,
})
if (storedSearches !== null && JSON.stringify(searches) !== storedSearches)
this.#saveSearches(searches)
+ this.#playing = playingKey()
roomStore.addEventListener('change', this.#onRoom)
window.addEventListener('storage', this.#onStorage)
+ identityEvents.on(this.#onIdentity)
}
- removePlay(record: PlayRecord): void {
- this.#savePlays(this.state.plays.filter(play => play !== record))
+ // 每打开一个历史页读取一次;页面开着时,服务端在开播时写入历史,所以正在播放的歌一变就重新读取。返回的函数在页面关闭时调用。
+ open(): () => void {
+ this.#pages++
+ void this.load()
+ let open = true
+ return () => {
+ if (open)
+ this.#pages--
+ open = false
+ }
}
- clearPlays(): void {
- this.#savePlays([])
+ // 失败时只设置 error,不抛出。
+ async load(): Promise {
+ const generation = ++this.#generation
+ this.#reading++
+ try {
+ const { items } = await api.listMyListens()
+ if (generation === this.#generation)
+ this.setState({ listens: items, error: null })
+ }
+ catch (error) {
+ if (generation === this.#generation)
+ this.setState({ error: errorMessage(error) })
+ }
+ finally {
+ this.#reading--
+ }
}
- exportPlays(): void {
+ // 返回 false 时,失败原因已经通过通知显示。
+ async remove(listen: MyListen): Promise {
+ if (!await this.#write(() => api.removeMyListen({ track: listen.track })))
+ return false
+ this.#changed((this.state.listens ?? []).filter(l => !sameTrack(l.track, listen.track)))
+ return true
+ }
+
+ // 返回 false 时,失败原因已经通过通知显示。
+ async clear(): Promise {
+ if (!await this.#write(() => api.clearMyListens()))
+ return false
+ this.#changed([])
+ return true
+ }
+
+ exportListens(): void {
const now = new Date()
- const file = new Blob([JSON.stringify({ exportedAt: now.toISOString(), history: this.state.plays }, null, 2)], { type: 'application/json' })
+ const file = new Blob([JSON.stringify({ exportedAt: now.toISOString(), history: this.state.listens ?? [] }, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(file)
const link = document.createElement('a')
link.href = url
@@ -86,38 +142,60 @@ class HistoryStore extends Store {
this.#saveSearches([])
}
- updateSettings({ playsEnabled, searchesEnabled, searchLimit }: HistorySettings): void {
- localStorage.setItem(playsEnabledKey, String(playsEnabled))
+ updateSettings({ searchesEnabled, searchLimit }: SearchSettings): void {
localStorage.setItem(searchesEnabledKey, String(searchesEnabled))
localStorage.setItem(searchLimitKey, String(searchLimit))
- this.setState({ playsEnabled, searchesEnabled, searchLimit })
+ this.setState({ searchesEnabled, searchLimit })
if (this.state.searches.length > searchLimit)
this.#saveSearches(this.state.searches.slice(0, searchLimit))
}
+ async #write(request: () => Promise): Promise {
+ try {
+ await request()
+ return true
+ }
+ catch (error) {
+ notificationStore.error(errorMessage(error))
+ return false
+ }
+ }
+
+ // 改动之前发起的读取可能带着改动前的历史返回,作废它们;有读取被作废时再读一次,免得错过其间开播的歌。
+ #changed(listens: MyListen[]): void {
+ this.#generation++
+ this.setState({ listens })
+ if (this.#reading > 0)
+ void this.load()
+ }
+
#onRoom = (): void => {
- const nowPlaying = roomStore.state.room?.nowPlaying ?? null
- const playing = nowPlaying ? `${nowPlaying.entry.id}@${nowPlaying.startedAtMs}` : ''
+ const playing = playingKey()
if (playing === this.#playing)
return
this.#playing = playing
- if (nowPlaying && this.state.playsEnabled)
- this.#savePlays(recordPlay(this.state.plays, { track: nowPlaying.entry.track, meta: nowPlaying.entry.meta, playedAtMs: nowPlaying.startedAtMs }))
+ if (playing && this.#pages > 0)
+ void this.load()
}
- // 其他标签页写入历史时同步更新,本页之后的写入才不会覆盖它写入的记录。
+ // 成员 token 被拒绝时页面不重新加载:旧身份的历史不能留在页面上,新的游客还没有播放历史。新身份也可能是其他标签页
+ // 换来的、已经有历史的游客,所以换好之后页面开着就重新读取。
+ #onIdentity = (event: IdentityEvent): void => {
+ if (event.kind === 'rejected') {
+ this.#generation++
+ this.setState({ listens: [], error: null })
+ }
+ else if (event.kind === 'renewed' && this.#pages > 0) {
+ void this.load()
+ }
+ }
+
+ // 其他标签页写入搜索记录时同步更新,本页之后的写入才不会覆盖它写入的记录。
#onStorage = (event: StorageEvent): void => {
- if (event.key === playsKey)
- this.setState({ plays: parsePlays(event.newValue) })
- else if (event.key === searchesKey)
+ if (event.key === searchesKey)
this.setState({ searches: parseSearches(event.newValue, this.state.searchLimit) })
}
- #savePlays(plays: PlayRecord[]): void {
- localStorage.setItem(playsKey, JSON.stringify(plays))
- this.setState({ plays })
- }
-
#saveSearches(searches: SearchRecord[]): void {
localStorage.setItem(searchesKey, JSON.stringify(searches))
this.setState({ searches })
diff --git a/web/src/features/history/records.test.ts b/web/src/features/history/records.test.ts
index da4d2cf..ba6ab59 100644
--- a/web/src/features/history/records.test.ts
+++ b/web/src/features/history/records.test.ts
@@ -1,61 +1,11 @@
-import type { PlayRecord, SearchCategory, SearchRecord, SearchSource } from './records'
+import type { SearchCategory, SearchRecord, SearchSource } from './records'
import { describe, expect, it } from 'vitest'
-import { parsePlays, parseSearches, playHistoryLimit, recordPlay, recordSearch } from './records'
-
-function play(id: string, playedAtMs: number, source: PlayRecord['track']['source'] = 'netease'): PlayRecord {
- return {
- track: { source, id },
- meta: { title: id, artist: '', album: '', coverUrl: '', pageUrl: '', durationMs: 1000 },
- playedAtMs,
- }
-}
+import { parseSearches, recordSearch } from './records'
function search(query: string, searchedAtMs: number, source: SearchSource = 'netease', category: SearchCategory = 'all'): SearchRecord {
return { query, source, category, searchedAtMs }
}
-describe('recordPlay', () => {
- it('新的记录排在最前,同一首歌只留最近一次', () => {
- const plays = recordPlay([play('2', 200), play('1', 100)], play('1', 300))
- expect(plays).toEqual([play('1', 300), play('2', 200)])
- })
-
- it('来源不同的同一个 ID 是两首歌', () => {
- expect(recordPlay([play('1', 100, 'qq')], play('1', 200))).toEqual([play('1', 200), play('1', 100, 'qq')])
- })
-
- it('最多保留 500 条', () => {
- const plays = Array.from({ length: playHistoryLimit }, (_, i) => play(String(i), i))
- const next = recordPlay(plays, play('new', 1000))
- expect(next).toHaveLength(500)
- expect(next[0]).toEqual(play('new', 1000))
- expect(next[499]).toEqual(play('498', 498))
- })
-})
-
-describe('parsePlays', () => {
- it('保留 B 站的播放记录', () => {
- const record = play('BV1xx411c7mD/2', 1, 'bilibili')
- expect(parsePlays(JSON.stringify([record]))).toEqual([record])
- })
-
- it('丢弃不完整的记录', () => {
- const broken = [
- { track: { source: 'netease', id: '2' }, playedAtMs: 1 },
- { ...play('3', 3), track: { source: 'other', id: '3' } },
- { ...play('4', 4), meta: { title: '4', artist: '', album: '', coverUrl: '', durationMs: 1 } },
- null,
- ]
- expect(parsePlays(JSON.stringify([play('1', 1), ...broken]))).toEqual([play('1', 1)])
- })
-
- it('没有记录或者内容无法解析时为空', () => {
- expect(parsePlays(null)).toEqual([])
- expect(parsePlays('{')).toEqual([])
- expect(parsePlays('{}')).toEqual([])
- })
-})
-
describe('recordSearch', () => {
it('不区分大小写和首尾空白,同一个查询只留最近的一条,平台和类别取最近一次', () => {
const searches = recordSearch([search('晴天', 1), search('Jay', 2, 'qq', 'tracks')], search(' jay ', 3, 'netease', 'playlists'), 10)
diff --git a/web/src/features/history/records.ts b/web/src/features/history/records.ts
index 498d22a..19a638b 100644
--- a/web/src/features/history/records.ts
+++ b/web/src/features/history/records.ts
@@ -1,11 +1,3 @@
-import type { Source, Track, TrackMeta } from '@/core/realtime'
-
-export interface PlayRecord {
- track: Track
- meta: TrackMeta
- playedAtMs: number
-}
-
export type SearchSource = 'netease' | 'qq'
export type SearchCategory = 'all' | 'tracks' | 'playlists'
@@ -16,12 +8,8 @@ export interface SearchRecord {
searchedAtMs: number
}
-export const playHistoryLimit = 500
-
-const trackSources: unknown[] = ['netease', 'qq', 'link', 'bilibili'] satisfies Source[]
const searchSources: unknown[] = ['netease', 'qq'] satisfies SearchSource[]
const searchCategories: unknown[] = ['all', 'tracks', 'playlists'] satisfies SearchCategory[]
-const metaText = ['title', 'artist', 'album', 'coverUrl', 'pageUrl'] as const
function isObject(value: unknown): value is Record {
return typeof value === 'object' && value !== null
@@ -37,28 +25,11 @@ function readList(stored: string | null): unknown[] {
}
}
-function isPlayRecord(value: unknown): value is PlayRecord {
- if (!isObject(value) || !isObject(value.track) || !isObject(value.meta) || typeof value.playedAtMs !== 'number')
- return false
- const { track, meta } = value
- return trackSources.includes(track.source) && typeof track.id === 'string'
- && metaText.every(key => typeof meta[key] === 'string') && typeof meta.durationMs === 'number'
-}
-
function isSearchRecord(value: unknown): value is SearchRecord {
return isObject(value) && typeof value.query === 'string' && typeof value.searchedAtMs === 'number'
&& searchSources.includes(value.source) && searchCategories.includes(value.category)
}
-export function parsePlays(stored: string | null): PlayRecord[] {
- return readList(stored).filter(isPlayRecord).slice(0, playHistoryLimit)
-}
-
-export function recordPlay(plays: PlayRecord[], record: PlayRecord): PlayRecord[] {
- const rest = plays.filter(play => play.track.source !== record.track.source || play.track.id !== record.track.id)
- return [record, ...rest].slice(0, playHistoryLimit)
-}
-
function queryKey(query: string): string {
return query.trim().toLowerCase()
}
diff --git a/web/src/features/settings/profile-store.test.ts b/web/src/features/settings/profile-store.test.ts
index 8de1d53..4da5002 100644
--- a/web/src/features/settings/profile-store.test.ts
+++ b/web/src/features/settings/profile-store.test.ts
@@ -337,3 +337,51 @@ describe('邮箱', () => {
expect(updates).toEqual([])
})
})
+
+describe('记录播放历史', () => {
+ beforeEach(() => {
+ local.setItem(memberTokenKey, 'good')
+ })
+
+ it('取回资料时一并取回开关,修改时调用对应的接口', async () => {
+ me = { member, recordListens: false }
+ const switched: string[] = []
+ extraRoutes.set('PUT /api/me/listen-recording', () => {
+ switched.push('on')
+ return new Response(null, { status: 204 })
+ })
+ extraRoutes.set('DELETE /api/me/listen-recording', () => {
+ switched.push('off')
+ return new Response(null, { status: 204 })
+ })
+ const { profileStore } = await load()
+ expect(profileStore.state.recordListens).toBeNull()
+ await profileStore.load()
+ expect(profileStore.state.recordListens).toBe(false)
+ expect(await profileStore.setRecordListens(true)).toBe(true)
+ expect(profileStore.state.recordListens).toBe(true)
+ expect(await profileStore.setRecordListens(false)).toBe(true)
+ expect(profileStore.state.recordListens).toBe(false)
+ expect(switched).toEqual(['on', 'off'])
+ })
+
+ it('修改失败时开关不变', async () => {
+ me = { member, recordListens: true }
+ extraRoutes.set('DELETE /api/me/listen-recording', () => new Response(JSON.stringify({ error: { code: 'UNAVAILABLE', message: '暂时不可用' } }), { status: 503 }))
+ const { profileStore } = await load()
+ await profileStore.load()
+ expect(await profileStore.setRecordListens(false)).toBe(false)
+ expect(profileStore.state.recordListens).toBe(true)
+ })
+
+ it('身份被拒绝时开关未知,换成新的游客之后是默认的开启', async () => {
+ me = { member, recordListens: false }
+ const { profileStore, identityEvents } = await load()
+ profileStore.start()
+ await profileStore.load()
+ identityEvents.emit({ kind: 'rejected' })
+ expect(profileStore.state.recordListens).toBeNull()
+ identityEvents.emit({ kind: 'renewed', member: renewed })
+ expect(profileStore.state.recordListens).toBe(true)
+ })
+})
diff --git a/web/src/features/settings/profile-store.ts b/web/src/features/settings/profile-store.ts
index abfe525..059396c 100644
--- a/web/src/features/settings/profile-store.ts
+++ b/web/src/features/settings/profile-store.ts
@@ -8,11 +8,20 @@ import { notificationStore } from '@/shared/ui'
// 游客的邮箱只保存在本机,服务端只保存它的哈希;已注册账号的邮箱保存在账号里。
const emailKey = 'alisten.email'
-class ProfileStore extends Store<{ member: Member | null, account: Account | null, email: string, failed: boolean }> {
+interface ProfileState {
+ member: Member | null
+ account: Account | null
+ email: string
+ // 是否记录播放历史,保存在服务端;还没有读到时为 null。
+ recordListens: boolean | null
+ failed: boolean
+}
+
+class ProfileStore extends Store {
#loading: Promise | null = null
constructor() {
- super({ member: null, account: null, email: '', failed: false })
+ super({ member: null, account: null, email: '', recordListens: null, failed: false })
}
start(): void {
@@ -58,6 +67,19 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
return await this.#update(changes) === null
}
+ // 返回 false 时,失败原因已经通过通知显示。
+ async setRecordListens(on: boolean): Promise {
+ try {
+ await (on ? api.enableListenRecording() : api.disableListenRecording())
+ this.setState({ recordListens: on })
+ return true
+ }
+ catch (error) {
+ notificationStore.error(error instanceof IdentityRenewedError ? '设置没有保存,再保存一次。' : errorMessage(error))
+ return false
+ }
+ }
+
// 失败时返回错误,失败原因已经通过通知显示。
async #update(changes: { name?: string, email?: string }): Promise {
try {
@@ -87,8 +109,8 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
async #load(): Promise {
this.setState({ failed: false })
try {
- const { member, account } = await api.getMe()
- this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', failed: false })
+ const { member, account, recordListens } = await api.getMe()
+ this.setState({ member, account: account ?? null, email: account ? account.email ?? '' : localStorage.getItem(emailKey) ?? '', recordListens, failed: false })
if (account && !account.email)
await this.#moveLocalEmail()
}
@@ -116,10 +138,11 @@ class ProfileStore extends Store<{ member: Member | null, account: Account | nul
case 'rejected':
// 本机保存的邮箱属于原来的身份。
localStorage.removeItem(emailKey)
- this.setState({ member: null, account: null, email: '', failed: false })
+ this.setState({ member: null, account: null, email: '', recordListens: null, failed: false })
return
case 'renewed':
- this.setState({ member: event.member, account: null, failed: false })
+ // 新的游客按默认记录播放历史。
+ this.setState({ member: event.member, account: null, recordListens: true, failed: false })
notificationStore.info('原来的身份已经失效,已为你换了一个新的身份。', 8000)
return
case 'renewalFailed':
diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx
index 178d506..05f6e95 100644
--- a/web/src/features/settings/settings-page.tsx
+++ b/web/src/features/settings/settings-page.tsx
@@ -27,7 +27,7 @@ interface SettingsDraft {
reducedMotion: boolean
searchesEnabled: boolean
searchLimit: number
- playsEnabled: boolean
+ recordListens: boolean
}
type DraftKey = keyof SettingsDraft
@@ -70,6 +70,7 @@ export function SettingsPage() {
const member = useStore(profileStore, s => s.member)
const email = useStore(profileStore, s => s.email)
const account = useStore(profileStore, s => s.account)
+ const recordListens = useStore(profileStore, s => s.recordListens)
const profileFailed = useStore(profileStore, s => s.failed)
const theme = useStore(appearanceStore, s => s.theme)
const fontSize = useStore(appearanceStore, s => s.fontSize)
@@ -81,7 +82,6 @@ export function SettingsPage() {
const reducedMotion = useStore(performanceStore, s => s.reducedMotion)
const searchesEnabled = useStore(historyStore, s => s.searchesEnabled)
const searchLimit = useStore(historyStore, s => s.searchLimit)
- const playsEnabled = useStore(historyStore, s => s.playsEnabled)
const [changes, setChanges] = useState>({})
const [saving, setSaving] = useState(false)
// state 要到下一次渲染才更新,连续提交时由这个 ref 阻止重复保存。
@@ -90,7 +90,7 @@ export function SettingsPage() {
headingRef.current?.focus({ preventScroll: true })
loadProfile()
}, [])
- const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, playsEnabled }
+ const current: SettingsDraft = { name: member?.name ?? '', email, theme, fontSize, volume, muted, reminderEnabled, systemNotifications, level, reducedMotion, searchesEnabled, searchLimit, recordListens: recordListens ?? false }
const draft: SettingsDraft = { ...current, ...changes }
const changed = (keys: DraftKey[]) => keys.some(key => draft[key] !== current[key])
const dirty = changed(Object.keys(changes) as DraftKey[])
@@ -146,9 +146,15 @@ export function SettingsPage() {
performanceStore.update(draft.level, draft.reducedMotion)
saved.push('level', 'reducedMotion')
}
- if (changed(['searchesEnabled', 'searchLimit', 'playsEnabled'])) {
- historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit, playsEnabled: draft.playsEnabled })
- saved.push('searchesEnabled', 'searchLimit', 'playsEnabled')
+ if (changed(['searchesEnabled', 'searchLimit'])) {
+ historyStore.updateSettings({ searchesEnabled: draft.searchesEnabled, searchLimit: draft.searchLimit })
+ saved.push('searchesEnabled', 'searchLimit')
+ }
+ if (changed(['recordListens'])) {
+ if (await profileStore.setRecordListens(draft.recordListens))
+ saved.push('recordListens')
+ else
+ failed = true
}
setChanges((previous) => {
const rest = { ...previous }
@@ -268,8 +274,8 @@ export function SettingsPage() {
options={searchLimits.map(limit => ({ value: String(limit), label: `${limit} 条` }))}
/>
-
- update('playsEnabled', event.target.checked)} />
+
+ update('recordListens', event.target.checked)} />