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()