From 910e7339adc11059a6a941a907fe1426eb461ebf Mon Sep 17 00:00:00 2001 From: DiheChen Date: Wed, 11 Nov 2026 00:06:40 +0800 Subject: [PATCH] feat: show listening history from the server and sync the recording switch across devices --- web/src/features/history/history-page.tsx | 129 +++++---- .../features/history/history-store.test.ts | 265 ++++++++++++++++++ web/src/features/history/history-store.ts | 144 +++++++--- web/src/features/history/records.test.ts | 54 +--- web/src/features/history/records.ts | 29 -- .../features/settings/profile-store.test.ts | 48 ++++ web/src/features/settings/profile-store.ts | 35 ++- web/src/features/settings/settings-page.tsx | 22 +- 8 files changed, 541 insertions(+), 185 deletions(-) create mode 100644 web/src/features/history/history-store.test.ts 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 ( +
  1. + {(index === 0 || playedOn(shown[index - 1].heardAtMs) !== day) &&

    {day}

    } + +
  2. + ) + })} +
+ ) + } + 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 ( -
  1. - {(index === 0 || playedOn(shown[index - 1].playedAtMs) !== day) &&

    {day}

    } - -
  2. - ) - })} -
- ) - : ( - - {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)} />