From a423a2ce0425053bddad6770577a2abc1653bd70 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Wed, 12 Aug 2026 10:50:13 +0800 Subject: [PATCH] feat(music): expand source discovery and history --- src/components/cards/MusicCard.vue | 32 ++++ .../cards/__tests__/MediaCard.spec.ts | 140 ++++++++++++++++-- .../dialog/SubscribeHistoryDialog.vue | 12 +- .../__tests__/SubscribeHistoryDialog.spec.ts | 26 +++- src/locales/en-US.ts | 12 ++ src/locales/zh-CN.ts | 12 ++ src/locales/zh-TW.ts | 12 ++ src/pages/__tests__/discover.spec.ts | 22 ++- src/pages/__tests__/music-album.spec.ts | 110 +++++++++++++- src/pages/__tests__/music.spec.ts | 58 +++++++- src/pages/__tests__/recommend.spec.ts | 5 +- src/pages/__tests__/subscribe.spec.ts | 60 ++++---- src/pages/discover.vue | 7 +- src/pages/recommend.vue | 9 +- src/pages/subscribe.vue | 21 ++- src/router/i18n-menu.ts | 5 + src/utils/__tests__/recommendSources.spec.ts | 10 +- src/utils/recommendSources.ts | 18 +++ src/views/discover/DoubanView.vue | 42 +++++- src/views/discover/MusicAlbumView.vue | 13 +- src/views/discover/MusicView.vue | 72 ++++++++- .../discover/__tests__/DoubanView.spec.ts | 23 +++ .../discover/__tests__/MusicView.spec.ts | 20 ++- 23 files changed, 647 insertions(+), 94 deletions(-) diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue index 5081e7df..2de35fe4 100644 --- a/src/components/cards/MusicCard.vue +++ b/src/components/cards/MusicCard.vue @@ -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 = { + 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)
+
+ + {{ sourceLabel }} + +
{{ props.music?.title }}
@@ -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; diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index dabfde26..59b1190a 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -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[], 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) => 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: '', + props: ['color', 'icon'], + template: '', } 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 () => { diff --git a/src/components/dialog/SubscribeHistoryDialog.vue b/src/components/dialog/SubscribeHistoryDialog.vue index 7ba2afd9..de5b0d2b 100644 --- a/src/components/dialog/SubscribeHistoryDialog.vue +++ b/src/components/dialog/SubscribeHistoryDialog.vue @@ -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) { diff --git a/src/views/discover/MusicAlbumView.vue b/src/views/discover/MusicAlbumView.vue index 28e81383..5b7a6965 100644 --- a/src/views/discover/MusicAlbumView.vue +++ b/src/views/discover/MusicAlbumView.vue @@ -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 })
+ +
+ +
(), + { + 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 = { 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++ + }, +)