mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-04 15:10:56 +08:00
feat(music): expand source discovery and history
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '完成的订阅会显示在这里',
|
||||
|
||||
@@ -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: '完成的訂閱會顯示在這裡',
|
||||
|
||||
@@ -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',
|
||||
'缓存来源',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: '音乐',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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'] })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"'))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user