feat(music): expand source discovery and history

This commit is contained in:
jxxghp
2026-08-12 10:50:13 +08:00
parent 6768f4e8a7
commit a423a2ce04
23 changed files with 647 additions and 94 deletions
+32
View File
@@ -15,6 +15,7 @@ import {
formatMusicAudioSpecs,
getMusicArtistLinks,
getMusicKey,
getMusicSourceLabel,
} from '@/utils/music'
const { t } = useI18n()
@@ -39,6 +40,15 @@ const isSubscribed = ref(false)
// 可点击跳转的艺术家
const artistLinks = computed(() => getMusicArtistLinks(props.music))
const sourceLabel = computed(() => getMusicSourceLabel(props.music?.source, t))
const sourceMeta = computed(() => {
const sources: Record<string, { color: string; icon: string }> = {
musicbrainz: { color: '#eb743b', icon: 'mdi-music-circle' },
theaudiodb: { color: '#35a7a0', icon: 'mdi-music-box-multiple' },
doubanmusic: { color: '#00b51d', icon: 'mdi-music-circle' },
}
return sources[props.music?.source || 'musicbrainz'] || { color: 'primary', icon: 'mdi-database-outline' }
})
// 音乐实体标签和图标
const entityMeta = computed(() => {
@@ -164,6 +174,17 @@ onMounted(checkSubscribeStatus)
</div>
<div class="music-card-body">
<div class="music-card-source-row">
<VChip
data-testid="music-source"
:color="sourceMeta.color"
:prepend-icon="sourceMeta.icon"
size="x-small"
variant="tonal"
>
{{ sourceLabel }}
</VChip>
</div>
<div class="music-card-heading">
<div class="music-card-title" :title="props.music?.title">{{ props.music?.title }}</div>
<VChip v-if="props.music?.version" size="x-small" variant="tonal" class="music-card-version">
@@ -316,6 +337,13 @@ onMounted(checkSubscribeStatus)
min-inline-size: 0;
}
.music-card-source-row {
display: flex;
align-items: center;
min-block-size: 20px;
margin-block-end: 0.25rem;
}
.music-card-heading {
display: flex;
align-items: flex-start;
@@ -425,6 +453,10 @@ onMounted(checkSubscribeStatus)
max-inline-size: calc(100% - 0.75rem);
}
.music-card-source-row {
padding-inline-end: 5.5rem;
}
.music-card-footer {
display: block;
padding-block-start: 0.5rem;
+126 -14
View File
@@ -4,7 +4,12 @@ import { clearCachedMediaSubscribeStatuses } from '@/utils/mediaStatusCache'
import { fireEvent, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { mediaExistsHandler } from '@tests/support/msw/handlers/media'
import { querySubscribeByMediaHandler, subscribeListHandler } from '@tests/support/msw/handlers/subscribe'
import {
createSubscribeHandler,
defaultSubscribeConfigHandler,
querySubscribeByMediaHandler,
subscribeListHandler,
} from '@tests/support/msw/handlers/subscribe'
import { server } from '@tests/support/msw/server'
import { renderWithProviders } from '@tests/support/render'
import { HttpResponse, http } from 'msw'
@@ -29,6 +34,7 @@ vi.mock('@/router', () => ({
const API_BASE_URL = 'http://localhost/api/v1/'
const movieSiteListUrl = new URL('site/media/movie', API_BASE_URL).href
const tvSiteListUrl = new URL('site/media/tv', API_BASE_URL).href
const musicSiteListUrl = new URL('site/media/music', API_BASE_URL).href
const selectedSitesUrl = new URL('system/setting/public/IndexerSites', API_BASE_URL).href
let intersectionObservers: IntersectionObserverMock[] = []
@@ -190,10 +196,15 @@ function getStatusObservers() {
function installSearchHandlers(
sites: Record<string, unknown>[],
selected: number[],
mediaType: 'movie' | 'tv' = 'movie',
mediaType: 'movie' | 'music' | 'tv' = 'movie',
) {
const siteListUrl = {
movie: movieSiteListUrl,
music: musicSiteListUrl,
tv: tvSiteListUrl,
}[mediaType]
server.use(
http.get(mediaType === 'tv' ? tvSiteListUrl : movieSiteListUrl, () => HttpResponse.json(sites)),
http.get(siteListUrl, () => HttpResponse.json(sites)),
http.get(selectedSitesUrl, () => HttpResponse.json({ data: { value: selected }, success: true })),
)
}
@@ -392,6 +403,73 @@ describe('MediaCard', () => {
)
})
it.each([
['TheAudioDB', 'theaudiodb', 'album-2109619', 'Parachutes'],
['豆瓣音乐', 'doubanmusic', '1401853', '范特西'],
])(
'keeps %s identity for explore-card detail, subscribe, and resource actions',
async (_label, source, mediaId, title) => {
const media = createMediaInfo({
artist: 'Artist',
media_id: mediaId,
mediaid_prefix: source,
music_type: 'album',
poster_path: undefined,
source,
title,
tmdb_id: undefined,
total_tracks: 10,
type: '音乐',
})
const subscribeRequest = vi.fn<(url: URL) => void>()
const created = vi.fn<(payload: Record<string, unknown>) => void>()
server.use(
querySubscribeByMediaHandler(`${source}:${mediaId}`, {}, 200, subscribeRequest),
createSubscribeHandler({ data: { id: 101 }, success: true }, 200, created),
defaultSubscribeConfigHandler('音乐', { show_edit_dialog: false }),
)
installSearchHandlers([], [21], 'music')
const { container } = await renderCard(media)
getStatusObservers()[0]?.trigger()
await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce())
expect(subscribeRequest.mock.calls[0][0].searchParams.get('music_type')).toBe('album')
await fireEvent.mouseEnter(getHoverArea(container))
await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering'))
await fireEvent.click(getCard(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/music/album',
query: { mediaid: mediaId, source, title },
}),
)
await fireEvent.click(getActionButtons(container).at(-1) as HTMLButtonElement)
await waitFor(() => expect(created).toHaveBeenCalledOnce())
expect(created.mock.calls[0][0]).toMatchObject({
media_id: mediaId,
media_source: source,
music_type: 'album',
name: title,
type: '音乐',
})
await fireEvent.click(getSearchButton(container))
await waitFor(() =>
expect(mocks.routerPush).toHaveBeenCalledWith({
path: '/resource',
query: expect.objectContaining({
keyword: `${source}:${mediaId}`,
music_type: 'album',
sites: '21',
type: '音乐',
}),
}),
)
},
)
it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => {
const media = createMediaInfo({
media_id: 'recording-2',
@@ -746,14 +824,48 @@ describe('MediaCard', () => {
await waitFor(() => expect(getCard(container)).toHaveAttribute('data-glass-optical-mode', 'excluded'))
})
it('renders the AniList source badge after the poster loads', async () => {
const media = createMediaInfo({
anilist_id: 154588,
poster_path: '/original/anilist.jpg',
source: 'anilist',
tmdb_id: undefined,
type: '电视剧',
})
it.each([
[
'AniList',
createMediaInfo({
anilist_id: 154588,
poster_path: '/original/anilist.jpg',
source: 'anilist',
tmdb_id: undefined,
type: '电视剧',
}),
'mdi-alpha-a-circle',
'#02a9ff',
],
[
'TheAudioDB',
createMediaInfo({
cover_url: 'https://example.com/theaudiodb.jpg',
media_id: 'album-2109619',
music_type: 'album',
poster_path: undefined,
source: 'theaudiodb',
tmdb_id: undefined,
type: '音乐',
}),
'mdi-music-box-multiple',
'#35a7a0',
],
[
'豆瓣音乐',
createMediaInfo({
cover_url: 'https://example.com/doubanmusic.jpg',
media_id: '1401853',
music_type: 'album',
poster_path: undefined,
source: 'doubanmusic',
tmdb_id: undefined,
type: '音乐',
}),
'mdi-music-circle',
'#00b51d',
],
])('renders the %s source badge after the cover loads', async (_label, media, icon, color) => {
const VImgStub = defineComponent({
name: 'VImg',
emits: ['load'],
@@ -765,8 +877,8 @@ describe('MediaCard', () => {
},
})
const VIconStub = {
props: ['icon'],
template: '<i :data-icon="icon" />',
props: ['color', 'icon'],
template: '<i :data-color="color" :data-icon="icon" />',
}
const { container } = await renderWithProviders(MediaCard, {
props: { media, width: '9rem' },
@@ -776,7 +888,7 @@ describe('MediaCard', () => {
await fireEvent.click(container.querySelector('[aria-label="图片加载成功"]') as HTMLElement)
await waitFor(() => expect(container.querySelector('[data-icon="mdi-alpha-a-circle"]')).not.toBeNull())
await waitFor(() => expect(container.querySelector(`[data-icon="${icon}"]`)).toHaveAttribute('data-color', color))
})
it('hides search and subscribe actions when the user lacks both permissions', async () => {
@@ -92,6 +92,8 @@ async function loadHistory({ done }: { done: any }) {
async function reSubscribe(item: Subscribe) {
if (item.type === '电影') {
progressText.value = t('dialog.subscribeHistory.resubscribeMovie', { name: item.name })
} else if (item.type === '音乐') {
progressText.value = t('dialog.subscribeHistory.resubscribeMusic', { name: item.name })
} else {
progressText.value = t('dialog.subscribeHistory.resubscribeTv', { name: item.name, season: item.season })
}
@@ -180,16 +182,16 @@ function getMediaTypeText(type: string | undefined) {
<VListItem>
<template #prepend>
<VImg
height="75"
width="50"
:height="item.type === '音乐' ? 64 : 75"
:width="item.type === '音乐' ? 64 : 50"
:src="item.poster"
aspect-ratio="2/3"
class="object-cover rounded ring-gray-500 me-3"
:aspect-ratio="item.type === '音乐' ? 1 : 2 / 3"
class="subscribe-history-poster object-cover rounded ring-gray-500 me-3"
cover
>
<template #placeholder>
<div class="w-full h-full">
<VSkeletonLoader class="object-cover aspect-w-2 aspect-h-3" />
<VSkeletonLoader class="object-cover h-100" />
</div>
</template>
</VImg>
@@ -83,7 +83,11 @@ const VirtualScrollStub = defineComponent({
},
setup(props, { slots }) {
const itemRef = () => {}
return () => h('div', props.items.map(item => slots.default?.({ item, itemRef })))
return () =>
h(
'div',
props.items.map(item => slots.default?.({ item, itemRef })),
)
},
})
@@ -141,7 +145,7 @@ function createHistory(overrides: Partial<Subscribe> = {}): Subscribe {
}
}
async function renderDialog(type: '电影' | '电视剧' = '电影') {
async function renderDialog(type: '电影' | '电视剧' | '音乐' = '电影') {
const events = {
close: vi.fn(),
save: vi.fn(),
@@ -220,6 +224,18 @@ describe('SubscribeHistoryDialog', () => {
expect(requests[0].searchParams.get('count')).toBe('30')
})
it('loads music history with a square cover and no season copy', async () => {
const music = createHistory({ name: '首载专辑', type: '音乐' })
server.use(subscribeHistoryHandler('音乐', [music]))
await renderDialog('音乐')
expect(await screen.findByText('首载专辑')).toBeInTheDocument()
expect(screen.getByText(`${mediaTypeDict['音乐']}订阅历史`)).toBeInTheDocument()
expect(screen.queryByText(/第 \d+ 季/)).not.toBeInTheDocument()
expect(historyRow(music).querySelector('.subscribe-history-poster')).toHaveStyle({ height: '64px', width: '64px' })
})
it('appends later pages and keeps existing rows when the next page is empty', async () => {
const first = createHistory({ name: '第一页电影' })
const second = createHistory({ name: '第二页电影' })
@@ -310,6 +326,7 @@ describe('SubscribeHistoryDialog', () => {
createHistory({ name: '重新订阅剧集', season: 2, type: '电视剧' }),
'正在重新订阅 重新订阅剧集 第 2 季...',
],
['音乐', createHistory({ name: '重新订阅专辑', type: '音乐' }), '正在重新订阅 重新订阅专辑...'],
] as const)('shows the %s pending copy and emits save only after success', async (type, item, progressText) => {
const pending = createDeferred<{ success: boolean }>()
let payload: JsonBodyType | undefined
@@ -339,10 +356,7 @@ describe('SubscribeHistoryDialog', () => {
it('toasts a business failure when resubscribing and does not emit save', async () => {
const movie = createHistory({ name: '业务失败电影' })
server.use(
subscribeHistoryHandler('电影', [movie]),
createSubscribeHandler({ success: false }),
)
server.use(subscribeHistoryHandler('电影', [movie]), createSubscribeHandler({ success: false }))
const user = userEvent.setup()
const { events } = await renderDialog()
expect(await screen.findByText(movie.name)).toBeInTheDocument()
+12
View File
@@ -449,6 +449,7 @@ export default {
listenCount: 'Listens',
listenCountValue: '{count} listens',
relatedArtists: 'Related Artists',
relatedAlbums: 'Related Albums',
artistAlbums: "Artist's Albums",
artistType: 'Artist Type',
artistNotFound: 'Artist not found',
@@ -494,6 +495,12 @@ export default {
upcoming: 'Upcoming',
all: 'All',
withCover: 'With Cover Only',
country: 'Country/Region',
countryUs: 'United States',
countryGb: 'United Kingdom',
countryCn: 'China',
countryJp: 'Japan',
countryKr: 'South Korea',
},
},
settingTabs: {
@@ -568,6 +575,7 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
music: 'Music',
theaudiodb: 'TheAudioDB',
},
user: {
admin: 'Admin',
@@ -1373,6 +1381,9 @@ export default {
anilistTrendingNow: 'AniList TRENDING NOW',
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
listenBrainzWeekly: 'Weekly Popular Music',
theAudioDbAlbums: 'TheAudioDB Top Albums',
theAudioDbTracks: 'TheAudioDB Top Tracks',
doubanMusic: 'Douban Music Picks',
tmdbHotMovies: 'TMDB Hot Movies',
tmdbHotTVShows: 'TMDB Hot TV Shows',
doubanHotMovies: 'Douban Hot Movies',
@@ -3520,6 +3531,7 @@ export default {
resubscribe: 'Resubscribe',
resubscribeMovie: 'Resubscribing {name}...',
resubscribeTv: 'Resubscribing {name} Season {season}...',
resubscribeMusic: 'Resubscribing {name}...',
season: 'Season {season}',
noData: 'No completed subscriptions',
noDataHint: 'Completed subscription history will be displayed here',
+12
View File
@@ -441,6 +441,7 @@ export default {
listenCount: '收听次数',
listenCountValue: '收听 {count} 次',
relatedArtists: '关联艺术家',
relatedAlbums: '相关推荐',
artistAlbums: '艺术家的专辑',
artistType: '艺术家类型',
artistNotFound: '没有找到该艺术家',
@@ -486,6 +487,12 @@ export default {
upcoming: '即将发行',
all: '全部',
withCover: '仅有封面',
country: '国家/地区',
countryUs: '美国',
countryGb: '英国',
countryCn: '中国',
countryJp: '日本',
countryKr: '韩国',
},
},
settingTabs: {
@@ -559,6 +566,7 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音乐',
theaudiodb: 'TheAudioDB',
},
user: {
admin: '管理员',
@@ -1363,6 +1371,9 @@ export default {
anilistTrendingNow: 'AniList 当前趋势',
anilistPopularThisSeason: 'AniList 本季热门',
listenBrainzWeekly: '本周热门音乐',
theAudioDbAlbums: 'TheAudioDB 热门专辑',
theAudioDbTracks: 'TheAudioDB 热门单曲',
doubanMusic: '豆瓣音乐推荐',
tmdbHotMovies: 'TMDB热门电影',
tmdbHotTVShows: 'TMDB热门电视剧',
doubanHotMovies: '豆瓣热门电影',
@@ -3462,6 +3473,7 @@ export default {
resubscribe: '重新订阅',
resubscribeMovie: '正在重新订阅 {name}...',
resubscribeTv: '正在重新订阅 {name} 第 {season} 季...',
resubscribeMusic: '正在重新订阅 {name}...',
season: '第 {season} 季',
noData: '没有已完成的订阅',
noDataHint: '完成的订阅会显示在这里',
+12
View File
@@ -441,6 +441,7 @@ export default {
listenCount: '收聽次數',
listenCountValue: '收聽 {count} 次',
relatedArtists: '關聯藝術家',
relatedAlbums: '相關推薦',
artistAlbums: '藝術家的專輯',
artistType: '藝術家類型',
artistNotFound: '沒有找到該藝術家',
@@ -486,6 +487,12 @@ export default {
upcoming: '即將發行',
all: '全部',
withCover: '僅有封面',
country: '國家/地區',
countryUs: '美國',
countryGb: '英國',
countryCn: '中國',
countryJp: '日本',
countryKr: '韓國',
},
},
settingTabs: {
@@ -559,6 +566,7 @@ export default {
bangumi: 'Bangumi',
anilist: 'AniList',
music: '音樂',
theaudiodb: 'TheAudioDB',
},
user: {
admin: '管理員',
@@ -1361,6 +1369,9 @@ export default {
anilistTrendingNow: 'AniList 當前趨勢',
anilistPopularThisSeason: 'AniList 本季熱門',
listenBrainzWeekly: '本週熱門音樂',
theAudioDbAlbums: 'TheAudioDB 熱門專輯',
theAudioDbTracks: 'TheAudioDB 熱門單曲',
doubanMusic: '豆瓣音樂推薦',
tmdbHotMovies: 'TMDB熱門電影',
tmdbHotTVShows: 'TMDB熱門電視劇',
doubanHotMovies: '豆瓣熱門電影',
@@ -3460,6 +3471,7 @@ export default {
resubscribe: '重新訂閱',
resubscribeMovie: '正在重新訂閱 {name}...',
resubscribeTv: '正在重新訂閱 {name} 第 {season} 季...',
resubscribeMusic: '正在重新訂閱 {name}...',
season: '第 {season} 季',
noData: '沒有已完成的訂閱',
noDataHint: '完成的訂閱會顯示在這裡',
+20 -2
View File
@@ -214,6 +214,7 @@ describe('discover page', () => {
'Bangumi',
'AniList',
'音乐',
'TheAudioDB',
]),
)
expect(configRequested).toHaveBeenCalledOnce()
@@ -224,6 +225,7 @@ describe('discover page', () => {
'bangumi',
'anilist',
'musicbrainz',
'theaudiodb',
])
})
@@ -245,6 +247,7 @@ describe('discover page', () => {
'豆瓣',
'AniList',
'音乐',
'TheAudioDB',
'自定义来源',
]),
)
@@ -271,7 +274,13 @@ describe('discover page', () => {
await renderDiscover()
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['音乐', 'TheMovieDb', 'Bangumi', 'AniList']),
expect(getHeaderItems().map(item => item.title)).toEqual([
'音乐',
'TheMovieDb',
'Bangumi',
'AniList',
'TheAudioDB',
]),
)
expect(getHeaderConfig().modelValue.value).toBe('musicbrainz')
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteConfig))
@@ -287,7 +296,14 @@ describe('discover page', () => {
await waitFor(() => expect(configRequested).toHaveBeenCalledOnce())
await waitFor(() =>
expect(getHeaderItems().map(item => item.title)).toEqual(['Bangumi', 'TheMovieDb', '豆瓣', 'AniList', '音乐']),
expect(getHeaderItems().map(item => item.title)).toEqual([
'Bangumi',
'TheMovieDb',
'豆瓣',
'AniList',
'音乐',
'TheAudioDB',
]),
)
expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual(
remoteOrder.map(item => ({ enabled: true, name: item.name })),
@@ -309,6 +325,7 @@ describe('discover page', () => {
'Bangumi',
'AniList',
'音乐',
'TheAudioDB',
'可用扩展源',
])
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
@@ -400,6 +417,7 @@ describe('discover page', () => {
'Bangumi',
'AniList',
'音乐',
'TheAudioDB',
'缓存来源',
])
})
+109 -1
View File
@@ -1,6 +1,7 @@
import MusicAlbumPage from '@/pages/music-album.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -82,6 +83,16 @@ const album = {
const musicSite = { id: 13, is_active: true, name: '专辑站点', url: 'https://album-music.example' }
const MediaCardSlideViewStub = defineComponent({
name: 'MediaCardSlideView',
props: {
apipath: String,
linkurl: String,
title: String,
},
template: '<div data-testid="media-card-slide" :data-api-path="apipath" :data-link-url="linkurl">{{ title }}</div>',
})
/** 按请求路径分派专辑详情与订阅状态查询。 */
function mockAlbumRequests(subscribed = false) {
mocks.apiGet.mockImplementation((path: string) => {
@@ -103,7 +114,9 @@ function renderAlbumPage() {
return renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?source=musicbrainz&mediaid=release-group-1',
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
},
})
}
@@ -197,4 +210,99 @@ describe('music album page', () => {
type: '音乐',
})
})
it('shows Douban related albums without an unsupported artist browse section', async () => {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'music/album/1401853') {
return Promise.resolve({
...album,
artist_ids: ['1050015'],
media_id: '1401853',
source: 'doubanmusic',
title: '范特西',
})
}
if (path.startsWith('subscribe/media/')) return Promise.reject({ response: { status: 404 } })
return Promise.resolve([])
})
await renderWithProviders(MusicAlbumPage, {
initialRoute: '/music/album?source=doubanmusic&mediaid=1401853',
initialState: { user: { superUser: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
},
})
expect(await screen.findByRole('heading', { name: '范特西' })).toBeInTheDocument()
const slides = screen.getAllByTestId('media-card-slide')
expect(slides).toHaveLength(1)
expect(slides[0]).toHaveAttribute('data-api-path', 'music/album/1401853/related?source=doubanmusic')
expect(slides[0]).toHaveAttribute(
'data-link-url',
expect.stringContaining('/browse/music/album/1401853/related?source=doubanmusic'),
)
})
it.each([
['TheAudioDB', 'theaudiodb', '2109619', 'Parachutes'],
['豆瓣音乐', 'doubanmusic', '1401853', '范特西'],
])('keeps %s identity for detail-page subscribe and resource actions', async (_label, source, mediaId, title) => {
mocks.apiGet.mockImplementation((path: string) => {
if (path === `music/album/${mediaId}`) {
return Promise.resolve({
...album,
artist_ids: source === 'theaudiodb' ? ['artist-1'] : [],
media_id: mediaId,
source,
title,
})
}
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [13] }, success: true })
}
if (path.startsWith('subscribe/media/')) return Promise.reject({ response: { status: 404 } })
return Promise.resolve([])
})
const { router } = await renderWithProviders(MusicAlbumPage, {
initialRoute: `/music/album?source=${source}&mediaid=${mediaId}`,
initialState: { user: { superUser: true } },
global: {
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
},
})
await fireEvent.click(await screen.findByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: mediaId,
media_source: source,
music_type: 'album',
name: title,
type: '音乐',
}),
),
)
await fireEvent.click(screen.getByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, , dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
unknown,
{ search: (sites: number[]) => void },
]
dialogEvents.search([13])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`,
music_type: 'album',
sites: '13',
type: '音乐',
})
})
})
+54 -4
View File
@@ -79,9 +79,9 @@ const artistResult = {
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' }
/** 按请求路径分派音乐搜索与订阅状态查询。 */
function mockSearchAndSubscribeState(subscribed: boolean) {
function mockSearchAndSubscribeState(subscribed: boolean, result = musicResult) {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([musicResult])
if (path === 'media/search') return Promise.resolve([result])
if (path === 'site/media/music') return Promise.resolve([musicSite])
if (path === 'system/setting/public/IndexerSites') {
return Promise.resolve({ data: { value: [11, 99] }, success: true })
@@ -94,9 +94,9 @@ function mockSearchAndSubscribeState(subscribed: boolean) {
}
/** 渲染音乐搜索结果页,统一提供超级用户权限与路由关键词。 */
function renderMusicPage() {
function renderMusicPage(initialRoute = '/music?query=晴天') {
return renderWithProviders(MusicPage, {
initialRoute: '/music?query=晴天',
initialRoute,
initialState: { user: { superUser: true } },
global: { stubs: { NoDataFound: true, VPageContentTitle: true } },
})
@@ -149,6 +149,7 @@ describe('music page', () => {
expect(screen.getByText('2003-07-31')).toBeInTheDocument()
expect(screen.getByText('4:29')).toBeInTheDocument()
expect(screen.getByText('Album')).toBeInTheDocument()
expect(screen.getByTestId('music-source')).toHaveTextContent('MusicBrainz')
})
it('uses three columns from the desktop breakpoint', async () => {
@@ -280,4 +281,53 @@ describe('music page', () => {
type: '音乐',
})
})
it.each([
['TheAudioDB', 'theaudiodb', 'album-2109619', 'Parachutes'],
['豆瓣音乐', 'doubanmusic', '1401853', '范特西'],
])('keeps %s identity on search result actions', async (label, source, mediaId, title) => {
const result = {
...albumResult,
album: title,
album_id: mediaId,
artist_ids: source === 'theaudiodb' ? ['artist-1'] : [],
media_id: mediaId,
source,
title,
}
mockSearchAndSubscribeState(false, result)
const { router } = await renderMusicPage(`/music?query=${encodeURIComponent(title)}&source=${source}`)
expect(await screen.findByTestId('music-source')).toHaveTextContent(label)
await fireEvent.click(screen.getByRole('button', { name: '订阅' }))
await waitFor(() =>
expect(mocks.apiPost).toHaveBeenCalledWith(
'subscribe/',
expect.objectContaining({
media_id: mediaId,
media_source: source,
music_type: 'album',
name: title,
type: '音乐',
}),
),
)
await fireEvent.click(screen.getByRole('button', { name: '搜索资源' }))
await waitFor(() => expect(mocks.openSharedDialog).toHaveBeenCalledOnce())
const [, , dialogEvents] = mocks.openSharedDialog.mock.calls[0] as [
unknown,
unknown,
{ search: (sites: number[]) => void },
]
dialogEvents.search([11])
await waitFor(() => expect(router.currentRoute.value.path).toBe('/resource'))
expect(router.currentRoute.value.query).toMatchObject({
keyword: `${source}:${mediaId}`,
music_type: 'album',
sites: '11',
type: '音乐',
})
})
})
+4 -1
View File
@@ -139,10 +139,13 @@ describe('recommend page', () => {
await renderRecommend()
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
expect(screen.getAllByTestId('recommend-view')).toHaveLength(5)
expect(screen.getAllByTestId('recommend-view')).toHaveLength(8)
expect(screen.getByText('AniList 当前趋势')).toBeInTheDocument()
expect(screen.getByText('AniList 本季热门')).toBeInTheDocument()
expect(screen.getByText('本周热门音乐')).toBeInTheDocument()
expect(screen.getByText('TheAudioDB 热门专辑')).toBeInTheDocument()
expect(screen.getByText('TheAudioDB 热门单曲')).toBeInTheDocument()
expect(screen.getByText('豆瓣音乐推荐')).toBeInTheDocument()
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
expect(remoteConfigRequests).toBe(0)
})
+28 -32
View File
@@ -3,16 +3,7 @@ import type { DynamicButtonMenuItem } from '@/composables/useDynamicButton'
import { DEFAULT_PERMISSIONS } from '@/utils/permission'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import {
computed,
defineComponent,
h,
nextTick,
ref,
unref,
type ComputedRef,
type Ref,
} from 'vue'
import { computed, defineComponent, h, nextTick, ref, unref, type ComputedRef, type Ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -108,23 +99,14 @@ const SubscribeListViewStub = defineComponent({
h('output', { 'aria-label': 'list active state' }, String(props.active)),
h('output', { 'aria-label': 'list batch state' }, JSON.stringify(batchState.value)),
h('output', { 'aria-label': 'last list command' }, lastCommand.value),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', true) },
'emit sort mode on',
),
h(
'button',
{ type: 'button', onClick: () => emit('update:sortMode', false) },
'emit sort mode off',
),
h('button', { type: 'button', onClick: () => emit('update:sortMode', true) }, 'emit sort mode on'),
h('button', { type: 'button', onClick: () => emit('update:sortMode', false) }, 'emit sort mode off'),
h('button', { type: 'button', onClick: () => emit('update:sortBy', 'date') }, 'emit date sort'),
h(
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 2, totalCount: 3, allSelected: false }),
onClick: () => publishBatchState({ enabled: true, selectedCount: 2, totalCount: 3, allSelected: false }),
},
'publish batch selection',
),
@@ -132,8 +114,7 @@ const SubscribeListViewStub = defineComponent({
'button',
{
type: 'button',
onClick: () =>
publishBatchState({ enabled: true, selectedCount: 3, totalCount: 3, allSelected: true }),
onClick: () => publishBatchState({ enabled: true, selectedCount: 3, totalCount: 3, allSelected: true }),
},
'publish all selected batch',
),
@@ -187,7 +168,7 @@ interface DynamicButtonConfig {
interface RenderSubscribeOptions {
appMode?: boolean
initialRoute?: string
subType?: '电影' | '电视剧'
subType?: '电影' | '电视剧' | '音乐'
subscribePermission?: boolean
superUser?: boolean
}
@@ -197,7 +178,8 @@ async function renderSubscribe(options: RenderSubscribeOptions = {}) {
mocks.appMode = options.appMode ?? false
return renderWithProviders(SubscribePage, {
initialRoute: options.initialRoute ?? `/subscribe/${subType === '电影' ? 'movie' : 'tv'}`,
initialRoute:
options.initialRoute ?? `/subscribe/${subType === '电影' ? 'movie' : subType === '音乐' ? 'music' : 'tv'}`,
initialRouteMeta: { subType },
initialState: {
user: {
@@ -415,6 +397,25 @@ describe('subscribe page', () => {
])
})
it('exposes only subscription history for music on desktop and PWA', async () => {
const { unmount } = await renderSubscribe({ subType: '音乐', superUser: true })
await waitFor(() => expect(document.querySelectorAll('.compact-fab button')).toHaveLength(1))
await fireEvent.click(document.querySelector<HTMLButtonElement>('.compact-fab button')!)
expect(getListOutput('last list command')).toHaveTextContent('open-history')
expect(mocks.openSharedDialog).not.toHaveBeenCalled()
unmount()
await renderSubscribe({ appMode: true, subType: '音乐', superUser: true })
const dynamicButton = getDynamicButtonConfig()
expect(unref(dynamicButton.show)).toBe(true)
expect(unref(dynamicButton.icon)).toBe('mdi-history')
expect(unref(dynamicButton.menuItems)).toBeUndefined()
dynamicButton.onClick?.()
await nextTick()
expect(getListOutput('last list command')).toHaveTextContent('open-history')
})
it.each([
[true, true],
[false, false],
@@ -431,12 +432,7 @@ describe('subscribe page', () => {
if (visible) {
expect(unref(dynamicButton.icon)).toBe('mdi-chart-line')
dynamicButton.onClick?.()
expect(mocks.openSharedDialog).toHaveBeenCalledWith(
expect.any(Object),
{},
{},
{ closeOn: ['close'] },
)
expect(mocks.openSharedDialog).toHaveBeenCalledWith(expect.any(Object), {}, {}, { closeOn: ['close'] })
}
})
+6 -1
View File
@@ -330,7 +330,12 @@ onActivated(async () => {
</VWindowItem>
<VWindowItem value="musicbrainz">
<div>
<MusicView />
<MusicView source="musicbrainz" />
</div>
</VWindowItem>
<VWindowItem value="theaudiodb">
<div>
<MusicView source="theaudiodb" />
</div>
</VWindowItem>
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
+8 -1
View File
@@ -72,7 +72,14 @@ function openRecommendSettings() {
const builtInRecommendSources = createBuiltInRecommendSources(t)
const viewList = reactive<RecommendViewSource[]>([...builtInRecommendSources])
const newlyAddedBuiltInPaths = new Set(['anilist/trending', 'anilist/popular-this-season', 'recommend/music_weekly'])
const newlyAddedBuiltInPaths = new Set([
'anilist/trending',
'anilist/popular-this-season',
'recommend/music_weekly',
'recommend/music_theaudiodb_albums',
'recommend/music_theaudiodb_tracks',
'recommend/music_douban',
])
//
const filteredViews = computed(() => {
+13 -8
View File
@@ -234,7 +234,7 @@ const userPermissions = computed(() => buildUserPermissionContext(userStore.supe
const canAdmin = computed(() => hasPermission(userPermissions.value, 'admin'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const showDefaultRuleAction = computed(() => activeTab.value === 'mysub' && canAdmin.value && subType !== '音乐')
const showSubscribeHistoryAction = computed(() => showDefaultRuleAction.value && canAdmin.value)
const showSubscribeHistoryAction = computed(() => activeTab.value === 'mysub' && canAdmin.value)
const showShareStatisticsAction = computed(() => activeTab.value === 'share' && canSubscribe.value)
const subscribeRoutePath = computed(() => {
if (subType === '电影') return '/subscribe/movie'
@@ -419,12 +419,14 @@ const subscribeDynamicMenuItems = computed<DynamicButtonMenuItem[] | undefined>(
})
}
items.push({
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
permission: 'admin',
action: openDefaultRuleDialog,
})
if (showDefaultRuleAction.value) {
items.push({
titleKey: 'dialog.subscribeEdit.titleDefault',
icon: 'mdi-clipboard-edit-outline',
permission: 'admin',
action: openDefaultRuleDialog,
})
}
return items.length > 1 ? items : undefined
}
@@ -468,7 +470,10 @@ useDynamicButton({
show: computed(
() =>
appMode.value &&
(subscribeBatchState.value.enabled || showDefaultRuleAction.value || showShareStatisticsAction.value),
(subscribeBatchState.value.enabled ||
showDefaultRuleAction.value ||
showSubscribeHistoryAction.value ||
showShareStatisticsAction.value),
),
})
+5
View File
@@ -346,6 +346,11 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
tab: 'musicbrainz',
icon: 'mdi-music-note-outline',
},
{
title: t('discoverTabs.theaudiodb'),
tab: 'theaudiodb',
icon: 'mdi-music-box-multiple-outline',
},
]
}
+9 -1
View File
@@ -34,7 +34,7 @@ describe('recommendSources', () => {
it('creates the complete built-in source contract', () => {
const sources = createBuiltInRecommendSources(translate)
expect(sources).toHaveLength(16)
expect(sources).toHaveLength(19)
expect(sources[0]).toEqual({
apipath: 'recommend/tmdb_trending',
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
@@ -66,6 +66,14 @@ describe('recommendSources', () => {
title: 'translated:recommend.listenBrainzWeekly',
type: 'translated:recommend.categoryMusic',
})
expect(
sources.filter(source => source.type === 'translated:recommend.categoryMusic').map(source => source.apipath),
).toEqual([
'recommend/music_weekly',
'recommend/music_theaudiodb_albums',
'recommend/music_theaudiodb_tracks',
'recommend/music_douban',
])
expect(
sources.filter(source => source.type === 'translated:recommend.categoryAnime').map(source => source.apipath),
).toEqual([
+18
View File
@@ -48,6 +48,24 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
title: t('recommend.listenBrainzWeekly'),
type: t('recommend.categoryMusic'),
},
{
apipath: 'recommend/music_theaudiodb_albums',
linkurl: '/browse/recommend/music_theaudiodb_albums?title=' + t('recommend.theAudioDbAlbums'),
title: t('recommend.theAudioDbAlbums'),
type: t('recommend.categoryMusic'),
},
{
apipath: 'recommend/music_theaudiodb_tracks',
linkurl: '/browse/recommend/music_theaudiodb_tracks?title=' + t('recommend.theAudioDbTracks'),
title: t('recommend.theAudioDbTracks'),
type: t('recommend.categoryMusic'),
},
{
apipath: 'recommend/music_douban',
linkurl: '/browse/recommend/music_douban?title=' + t('recommend.doubanMusic'),
title: t('recommend.doubanMusic'),
type: t('recommend.categoryMusic'),
},
{
apipath: 'recommend/tmdb_movies',
linkurl: '/browse/recommend/tmdb_movies?title=' + t('recommend.tmdbHotMovies'),
+34 -8
View File
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
// movies/tvs
const type = ref('movies')
// 使
const type = ref<'movies' | 'tvs' | 'music'>('movies')
//
const filterParams = reactive({
@@ -21,6 +21,8 @@ const doubanZone = ref('')
//
const doubanYear = ref('')
const coverFilter = ref<'all' | 'with_cover'>('all')
const isMusic = computed(() => type.value === 'music')
//
const categoryDict = {
@@ -99,6 +101,18 @@ const doubanSortDict = {
'S': t('douban.sortType.highScore'),
}
const listApiPath = computed(() => (isMusic.value ? 'music/explore' : `discover/douban_${type.value}`))
const listParams = computed<Record<string, unknown>>(() => {
if (isMusic.value) {
return {
count: 30,
source: 'doubanmusic',
with_cover: coverFilter.value === 'with_cover',
}
}
return { ...filterParams }
})
// ,tags
watch([doubanCategory, doubanZone, doubanYear], () => {
filterParams.tags = [doubanCategory.value, doubanZone.value, doubanYear.value].filter(Boolean).join(',')
@@ -108,7 +122,7 @@ watch([doubanCategory, doubanZone, doubanYear], () => {
const currentKey = ref(0)
//
watch([type, filterParams], () => {
watch([type, filterParams, coverFilter], () => {
if (!type.value) {
type.value = 'movies'
}
@@ -128,9 +142,12 @@ watch([type, filterParams], () => {
<VChipGroup v-model="type">
<VChip :color="type == 'movies' ? 'primary' : ''" filter tile value="movies">{{ t('mediaType.movie') }}</VChip>
<VChip :color="type == 'tvs' ? 'primary' : ''" filter tile value="tvs">{{ t('mediaType.tv') }}</VChip>
<VChip data-testid="douban-type-music" :color="type == 'music' ? 'primary' : ''" filter tile value="music">
{{ t('mediaType.music') }}
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center">
<div v-if="!isMusic" class="flex justify-start align-center">
<div class="mr-5">
<VLabel>{{ t('douban.sort') }}</VLabel>
</div>
@@ -147,7 +164,7 @@ watch([type, filterParams], () => {
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center">
<div v-if="!isMusic" class="flex justify-start align-center">
<div class="mr-5">
<VLabel>{{ t('douban.genre') }}</VLabel>
</div>
@@ -164,7 +181,7 @@ watch([type, filterParams], () => {
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center">
<div v-if="!isMusic" class="flex justify-start align-center">
<div class="mr-5">
<VLabel>{{ t('douban.zone') }}</VLabel>
</div>
@@ -181,7 +198,7 @@ watch([type, filterParams], () => {
</VChip>
</VChipGroup>
</div>
<div class="flex justify-start align-center">
<div v-if="!isMusic" class="flex justify-start align-center">
<div class="mr-5">
<VLabel>{{ t('douban.year') }}</VLabel>
</div>
@@ -198,8 +215,17 @@ watch([type, filterParams], () => {
</VChip>
</VChipGroup>
</div>
<div v-else class="flex justify-start align-center">
<div class="mr-5">
<VLabel>{{ t('music.filter.cover') }}</VLabel>
</div>
<VChipGroup v-model="coverFilter" mandatory>
<VChip value="all" filter tile>{{ t('music.filter.all') }}</VChip>
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
</VChipGroup>
</div>
</div>
<div>
<MediaCardListView :key="currentKey" :apipath="`discover/douban_${type}`" :params="filterParams" />
<MediaCardListView :key="currentKey" :apipath="listApiPath" :params="listParams" />
</div>
</template>
+12 -1
View File
@@ -44,7 +44,10 @@ const isSubscribed = ref(false)
const artistLinks = computed(() => getMusicArtistLinks(album.value))
//
const primaryArtistId = computed(() => artistLinks.value.find(artist => artist.id)?.id)
const supportsArtistBrowsing = computed(() => ['musicbrainz', 'theaudiodb'].includes(props.source))
const primaryArtistId = computed(() =>
supportsArtistBrowsing.value ? artistLinks.value.find(artist => artist.id)?.id : undefined,
)
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
//
@@ -239,6 +242,14 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
<div v-if="primaryArtistId && props.source === 'musicbrainz'" class="music-section">
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
</div>
<div v-if="props.source === 'doubanmusic'" class="music-section">
<MediaCardSlideView
:apipath="`music/album/${props.mediaid}/related?source=doubanmusic`"
:linkurl="`/browse/music/album/${props.mediaid}/related?source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
:title="t('music.relatedAlbums')"
/>
</div>
</MusicDetailLayout>
<NoDataFound
v-else
+65 -7
View File
@@ -4,6 +4,20 @@ import { useI18n } from 'vue-i18n'
const { t } = useI18n()
type MusicExploreSource = 'musicbrainz' | 'theaudiodb'
const props = withDefaults(
defineProps<{
source?: MusicExploreSource
}>(),
{
source: 'musicbrainz',
},
)
const isMusicBrainz = computed(() => props.source === 'musicbrainz')
const isTheAudioDb = computed(() => props.source === 'theaudiodb')
// ListenBrainz
const mode = ref<'chart' | 'fresh'>('chart')
@@ -26,6 +40,7 @@ const freshDays = ref(14)
const freshScope = ref<'all' | 'past' | 'future'>('all')
const coverFilter = ref('all')
const country = ref('us')
const currentKey = ref(0)
const modeOptions = computed(() => ({
@@ -75,12 +90,26 @@ const freshScopeOptions = computed(() => ({
future: t('music.filter.upcoming'),
}))
const countryOptions = computed(() => [
{ title: t('music.filter.countryUs'), value: 'us' },
{ title: t('music.filter.countryGb'), value: 'gb' },
{ title: t('music.filter.countryCn'), value: 'cn' },
{ title: t('music.filter.countryJp'), value: 'jp' },
{ title: t('music.filter.countryKr'), value: 'kr' },
])
const filterParams = computed(() => {
const params: Record<string, unknown> = {
count: 30,
mode: mode.value,
source: props.source,
with_cover: coverFilter.value === 'with_cover',
}
if (isTheAudioDb.value) {
params.entity = entity.value
params.country = country.value
return params
}
params.mode = mode.value
if (mode.value === 'fresh') {
params.sort = freshSort.value
params.days = freshDays.value
@@ -94,14 +123,17 @@ const filterParams = computed(() => {
return params
})
watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter], () => {
currentKey.value++
})
watch(
[() => props.source, mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter, country],
() => {
currentKey.value++
},
)
</script>
<template>
<div class="px-3 music-explore-filters">
<div class="music-filter-row">
<div v-if="isMusicBrainz" class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.mode') }}</VLabel>
<VChipGroup v-model="mode" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in modeOptions" :key="value" :value="value" filter tile>
@@ -110,7 +142,7 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</VChipGroup>
</div>
<template v-if="mode === 'chart'">
<template v-if="isMusicBrainz && mode === 'chart'">
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.entity') }}</VLabel>
<VChipGroup v-model="entity" mandatory class="music-filter-chips">
@@ -137,7 +169,7 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</div>
</template>
<template v-else>
<template v-else-if="isMusicBrainz">
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.sort') }}</VLabel>
<VChipGroup v-model="freshSort" mandatory class="music-filter-chips">
@@ -165,6 +197,28 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
</div>
</template>
<template v-else-if="isTheAudioDb">
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.entity') }}</VLabel>
<VChipGroup v-model="entity" mandatory class="music-filter-chips">
<VChip v-for="(label, value) in entityOptions" :key="value" :value="value" filter tile>
{{ label }}
</VChip>
</VChipGroup>
</div>
<div class="music-filter-row">
<VSelect
v-model="country"
:items="countryOptions"
:label="t('music.filter.country')"
density="compact"
variant="outlined"
hide-details
class="music-country-filter"
/>
</div>
</template>
<div class="music-filter-row">
<VLabel class="music-filter-label">{{ t('music.filter.cover') }}</VLabel>
<VChipGroup v-model="coverFilter" mandatory class="music-filter-chips">
@@ -209,4 +263,8 @@ watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverF
flex: 0 0 auto;
max-inline-size: 10rem;
}
.music-country-filter {
max-inline-size: 14rem;
}
</style>
@@ -103,4 +103,27 @@ describe('DoubanView', () => {
})
})
})
it('merges music into the Douban source and hides unsupported video filters', async () => {
const user = userEvent.setup()
await renderView()
await user.click(screen.getByTestId('douban-type-music'))
await waitFor(() => {
expect(latestMediaListRequest(mediaList)).toEqual({
apipath: 'music/explore',
params: {
count: 30,
source: 'doubanmusic',
with_cover: false,
},
})
})
expect(screen.queryByText('高分优先')).not.toBeInTheDocument()
expect(screen.queryByText('2020年代')).not.toBeInTheDocument()
await user.click(screen.getByText('仅有封面'))
await waitFor(() => expect(latestMediaListRequest(mediaList).params.with_cover).toBe(true))
})
})
+18 -2
View File
@@ -1,5 +1,5 @@
import MusicView from '@/views/discover/MusicView.vue'
import { screen } from '@testing-library/vue'
import { screen, waitFor } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { defineComponent } from 'vue'
@@ -11,8 +11,9 @@ const MediaCardListViewStub = defineComponent({
})
/** 渲染音乐探索筛选,列表区域用桩组件回显请求参数。 */
function renderMusicView() {
function renderMusicView(source: 'musicbrainz' | 'theaudiodb' = 'musicbrainz') {
return renderWithProviders(MusicView, {
props: { source },
global: { stubs: { MediaCardListView: MediaCardListViewStub } },
})
}
@@ -63,4 +64,19 @@ describe('MusicView', () => {
expect(params).toHaveTextContent('"past":false')
expect(params).toHaveTextContent('"future":true')
})
it('uses the same card list with TheAudioDB entity and country filters', async () => {
const user = userEvent.setup()
await renderMusicView('theaudiodb')
const params = screen.getByTestId('music-params')
expect(params).toHaveTextContent('"source":"theaudiodb"')
expect(params).toHaveTextContent('"entity":"recording"')
expect(params).toHaveTextContent('"country":"us"')
expect(params).not.toHaveTextContent('"mode"')
expect(screen.queryByText('模式')).not.toBeInTheDocument()
await user.click(screen.getByText('热门专辑'))
await waitFor(() => expect(screen.getByTestId('music-params')).toHaveTextContent('"entity":"album"'))
})
})