feat: open and import QQ Music playlists
CI / go (push) Canceled after 0s
CI / web (push) Canceled after 0s
CI / images (Dockerfile, server, alisten) (push) Canceled after 0s
CI / images (web/Dockerfile, web, ) (push) Canceled after 0s
CI / images (worker.Dockerfile, worker, alisten-worker) (push) Canceled after 0s
CI / helm-release (push) Canceled after 0s
CI / deploy (push) Canceled after 0s

This commit is contained in:
DiheChen committed 2026-11-11 00:30:04 +08:00
1 parent 19a135c983
commit 6f286c2eb2
23 files changed
+150 -81

No files matched your search

+3 -2
View File
@@ -32,7 +32,9 @@ describe('parseRoute', () => {
['/r/my-room/search?q=%E6%99%B4%E5%A4%A9&source=netease&view=playlists&page=2&panel=chat', { name: 'search', ref: 'my-room', panel: 'chat', query: { view: 'playlists', keyword: '晴天', source: 'netease', page: 2 } }],
['/r/my-room/search?source=netease&view=tracks', { name: 'search', ref: 'my-room', panel: null, query: { view: 'tracks', keyword: '', source: 'netease', page: 1 } }],
['/r/my-room/search?view=recommend', { name: 'search', ref: 'my-room', panel: null, query: { view: 'recommend' } }],
['/r/my-room/search?q=a&source=qq&view=playlists', { name: 'search', ref: 'my-room', panel: null, query: { view: 'playlists', keyword: 'a', source: 'qq', page: 1 } }],
['/r/my-room/playlists/netease/123?page=3&panel=queue', { name: 'playlist', ref: 'my-room', panel: 'queue', source: 'netease', id: '123', page: 3 }],
['/r/my-room/playlists/qq/123', { name: 'playlist', ref: 'my-room', panel: null, source: 'qq', id: '123', page: 1 }],
['/s/AbC_-1x', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: null }],
['/s/AbC_-1x?panel=queue', { name: 'shared-playlist', shareId: 'AbC_-1x', panel: 'queue' }],
])('%s', (url, route) => {
@@ -50,7 +52,6 @@ describe('parseRoute', () => {
['/r/my-room?panel=lyrics', '/r/my-room'],
['/r/my-room/search?q=%20a%20', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&source=kuwo&view=lyrics&page=0', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&source=qq&view=playlists', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?q=a&page=2', '/r/my-room/search?q=a&source=qq'],
['/r/my-room/search?view=tracks&page=2', '/r/my-room/search?view=tracks'],
['/r/my-room/search?view=recommend&q=a&source=netease&page=2', '/r/my-room/search?view=recommend'],
@@ -64,7 +65,7 @@ describe('parseRoute', () => {
expect(route && formatRoute(route)).toBe(canonical)
})
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/qq/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
it.each(['/r/', '/r/my-room/listen', '/r/my-room/queue/extra', '/settings/profile', '/settings/', '/settings/account/extra', '/admin/', '/admin/overview', '/r/my-room/playlists/bilibili/123', '/r/my-room/playlists/netease', '/s/', '/s/abc/def'])('%s 不对应任何页面', (url) => {
expect(parse(url)).toBeNull()
})
+2 -2
View File
@@ -3,7 +3,7 @@ import type { AdminSection } from '@/features/admin'
import type { SearchSource } from '@/features/history'
import type { SearchQuery } from '@/features/search'
import type { SettingsSection } from '@/features/settings'
import { canOpenPlaylists, defaultSearchSource, keywordSearch } from '@/features/search'
import { defaultSearchSource, keywordSearch } from '@/features/search'
export type RoomPage = 'listen' | 'queue' | 'chat'
export type Panel = 'queue' | 'history' | 'chat'
@@ -144,7 +144,7 @@ export function parseRoute({ pathname, search, hash }: Pick<Location, 'pathname'
}
const playlist = /^\/playlists\/([^/]+)\/([^/]+)$/.exec(subpath)
const source = parseSource(playlist?.[1] ?? null)
if (!playlist || !source || !canOpenPlaylists(source))
if (!playlist || !source)
return null
return { name: 'playlist', ref, panel, source, id: playlist[2], page: parsePage(params) }
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by protocolgen from protocol/idl.json; DO NOT EDIT.
export const PROTOCOL_VERSION = 'd68525cd68ef691f'
export const PROTOCOL_VERSION = '42749e16714e0d7c'
export interface APIKey {
key: string
+1 -1
View File
@@ -95,7 +95,7 @@ function ImportLinkDialog({ onClose, onImported }: { onClose: () => void, onImpo
>
<label>
歌单链接
<input data-autofocus required placeholder="粘贴网易云音乐的歌单链接或分享文字" value={link} onChange={event => setLink(event.target.value)} />
<input data-autofocus required placeholder="粘贴网易云音乐或 QQ 音乐的歌单链接、分享文字" value={link} onChange={event => setLink(event.target.value)} />
</label>
<label>
歌单名
+1 -1
View File
@@ -1,7 +1,7 @@
import { lazyPage } from '@/shared/ui'
export { HeaderSearch } from './header-search'
export { canOpenPlaylists, defaultSearchSource, keywordSearch, searchStore } from './search-store'
export { defaultSearchSource, keywordSearch, searchStore } from './search-store'
export type { SearchQuery } from './search-store'
export const SearchPage = lazyPage(() => import('./search-page').then(module => module.SearchPage))
export const PlaylistPage = lazyPage(() => import('./search-page').then(module => module.PlaylistPage))
+2 -2
View File
@@ -11,7 +11,7 @@ import { bookTitle } from '@/shared/lib/book-title'
import { useStore } from '@/shared/lib/use-store'
import { Artwork, AsyncIconButton, EmptyState, IconButton, PlayingBars, Select, Skeleton, SongArtwork, SongList } from '@/shared/ui'
import { SearchBox } from './search-box'
import { canOpenPlaylists, keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import { keywordSearch, pageSize, recordSearch, searchStore } from './search-store'
import './search.css'
const categories: { view: SearchCategory, label: string }[] = [
@@ -278,7 +278,7 @@ export function SearchPage({ inlineSearchClassName, onSearch, onOpenPlaylist }:
<div className="search-filter-bar">
<SearchBox className={`search-inline ${inlineSearchClassName}`} recent={false} onSearch={onSearch} />
<div className="search-filter-chips" role="group" aria-label="搜索类别">
{categories.filter(category => category.view !== 'playlists' || canOpenPlaylists(source)).map(category => (
{categories.map(category => (
<button
type="button"
key={category.view}
+2 -2
View File
@@ -255,10 +255,10 @@ describe('搜索请求', () => {
expect(notificationStore.state.notifications).toHaveLength(1)
})
it('只有网易云搜歌单:其他来源的全部类别只搜歌曲', async () => {
it('搜 QQ 音乐的全部类别时,也同时搜歌曲和歌单', async () => {
const { searchStore, keywordSearch } = await load()
searchStore.show(keywordSearch('a', 'qq', 'all'))
expect(calls.map(({ name }) => name)).toEqual(['searchTracks'])
expect(calls.map(({ name }) => name)).toEqual(['searchTracks', 'searchPlaylists'])
})
it('关键词太长时提示长度上限', async () => {
+2 -7
View File
@@ -58,13 +58,8 @@ interface SearchState extends Results {
const noResults: Results = { tracks: [], tracksTotal: 0, playlists: [], playlistsTotal: 0, recommendations: [] }
// 只有网易云能取歌单内的歌曲,其他平台的歌单搜到了也打不开,所以不搜。
export function canOpenPlaylists(source: Source): boolean {
return source === 'netease'
}
export function keywordSearch(keyword: string, source: SearchSource, category: SearchCategory, page = 1): KeywordQuery {
return { view: category === 'playlists' && !canOpenPlaylists(source) ? 'all' : category, keyword, source, page }
return { view: category, keyword, source, page }
}
// 用户发起的搜索记入搜索历史,包括提交、点最近搜索、换来源和类别。翻页、推荐、打开歌单,以及前进、后退还原的搜索都不记。
@@ -223,7 +218,7 @@ class SearchStore extends Store<SearchState> {
const tracks = (at: number) => requests.searchTracks({ source, keyword, page: at, pageSize }).then(({ items, total }) => ({ tracks: items, tracksTotal: total }))
const playlists = (at: number) => requests.searchPlaylists({ source, keyword, page: at, pageSize }).then(({ items, total }) => ({ playlists: items, playlistsTotal: total }))
if (view === 'all')
return canOpenPlaylists(source) ? [tracks(1), playlists(1)] : [tracks(1)]
return [tracks(1), playlists(1)]
return [view === 'tracks' ? tracks(page) : playlists(page)]
}