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()
|
||||
|
||||
Reference in New Issue
Block a user