diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 8c8e568f..3223f08f 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -26,7 +26,7 @@ import { getCachedMediaSubscribeStatus, setCachedMediaExistsStatus, } from '@/utils/mediaStatusCache' -import { buildMusicDetailRoute } from '@/utils/music' +import { buildMusicDetailRoute, getMusicKey } from '@/utils/music' const SearchSiteDialog = defineAsyncComponent(() => import('@/components/dialog/SearchSiteDialog.vue')) @@ -151,7 +151,8 @@ function getMediaId() { } function getSubscribeStatusKey(season: number | null = props.media?.season ?? null) { - return `${getMediaId()}::${season ?? 'all'}` + const identity = props.media?.type === '音乐' ? getMusicKey(props.media) : getMediaId() + return `${identity}::${season ?? 'all'}` } function getExistsStatusKey() { @@ -167,6 +168,11 @@ function getExistsStatusKey() { } function isSameSubscribeMedia(subscribe: Subscribe) { + if (props.media?.type === '音乐') { + const expectedMusicType = props.media.music_type ?? 'recording' + const subscribeMusicType = subscribe.music_type ?? 'recording' + if (subscribeMusicType !== expectedMusicType) return false + } const mediaId = getMediaId() if (subscribe.media_source && subscribe.media_id) { const prefix = subscribe.media_source === 'themoviedb' ? 'tmdb' : subscribe.media_source diff --git a/src/components/cards/MusicCard.vue b/src/components/cards/MusicCard.vue index 715e8f75..243cd87d 100644 --- a/src/components/cards/MusicCard.vue +++ b/src/components/cards/MusicCard.vue @@ -3,7 +3,7 @@ import type { MediaInfo } from '@/api/types' import { useGlobalSettingsStore, useUserStore } from '@/stores' import { buildUserPermissionContext, hasPermission } from '@/utils/permission' import { getDisplayImageUrl } from '@/utils/imageUtils' -import { getMediaSubscribeId, useMediaSubscribe } from '@/composables/useMediaSubscribe' +import { useMediaSubscribe } from '@/composables/useMediaSubscribe' import { getCachedMediaSubscribeStatus } from '@/utils/mediaStatusCache' import { useMusicSiteSearch } from '@/composables/useMusicSiteSearch' import { @@ -13,6 +13,7 @@ import { buildMusicResourceRoute, formatMusicDuration, getMusicArtistLinks, + getMusicKey, } from '@/utils/music' const { t } = useI18n() @@ -80,7 +81,7 @@ const showCover = computed(() => Boolean(coverUrl.value) && !imageLoadError.valu /** 生成订阅状态缓存键。 */ function getSubscribeStatusKey() { - return `${getMediaSubscribeId(props.music)}::all` + return `${props.music ? getMusicKey(props.music) : ''}::all` } const subscribeActions = useMediaSubscribe({ diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 5dfe41cb..bec71b72 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -329,6 +329,7 @@ describe('MediaCard', () => { const { container } = await renderCard(media) getStatusObservers()[0]?.trigger() await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce()) + expect((subscribeRequest.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('recording') expect(existsRequest).not.toHaveBeenCalled() await fireEvent.mouseEnter(getHoverArea(container)) diff --git a/src/composables/__tests__/useMediaSubscribe.spec.ts b/src/composables/__tests__/useMediaSubscribe.spec.ts index 65bdbcd3..54ddaaa1 100644 --- a/src/composables/__tests__/useMediaSubscribe.spec.ts +++ b/src/composables/__tests__/useMediaSubscribe.spec.ts @@ -293,6 +293,34 @@ describe('useMediaSubscribe entry flows', () => { }) }) + it('creates a recording subscription without its album track count', async () => { + const media = createMediaInfo({ + media_id: 'recording-1', + music_type: 'recording', + source: 'musicbrainz', + title: '晴天', + tmdb_id: undefined, + total_tracks: 11, + type: '音乐', + year: '2003', + }) + const created = vi.fn() + server.use(createSubscribeHandler({ data: { id: 503 }, success: true }, 200, created)) + await renderSubscribeHarness({ media }) + + await fireEvent.click(screen.getByRole('button', { name: 'primary' })) + + await waitFor(() => expect(created).toHaveBeenCalledOnce()) + expect(created.mock.calls[0][0]).toMatchObject({ + media_id: 'recording-1', + media_source: 'musicbrainz', + music_type: 'recording', + name: '晴天', + type: '音乐', + }) + expect(created.mock.calls[0][0]).not.toHaveProperty('total_tracks') + }) + it('does not create a subscription for an artist browsing entity', async () => { const media = createMediaInfo({ media_id: 'artist-1', @@ -517,6 +545,37 @@ describe('useMediaSubscribe entry flows', () => { expect(screen.getByTestId('season-map')).toHaveTextContent('"2":false') }) + it('queries and cancels music subscriptions with their entity type', async () => { + const media = createMediaInfo({ + media_id: 'release-group-1', + music_type: 'album', + source: 'musicbrainz', + title: '叶惠美', + tmdb_id: undefined, + type: '音乐', + }) + const queried = vi.fn() + const deleted = vi.fn() + server.use( + querySubscribeByMediaHandler( + 'musicbrainz:release-group-1', + createSubscribe({ id: 801, music_type: 'album', type: '音乐' }), + 200, + url => queried(url), + ), + deleteSubscribeByMediaHandler('musicbrainz:release-group-1', { success: true }, 200, url => deleted(url)), + ) + await renderSubscribeHarness({ isSubscribed: true, media }) + + await fireEvent.click(screen.getByRole('button', { name: 'check' })) + await waitFor(() => expect(screen.getByTestId('check-result')).toHaveTextContent('subscribed')) + expect((queried.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album') + + await fireEvent.click(screen.getByRole('button', { name: 'remove' })) + await waitFor(() => expect(deleted).toHaveBeenCalledOnce()) + expect((deleted.mock.calls[0][0] as URL).searchParams.get('music_type')).toBe('album') + }) + it('aligns visible seasons while preserving hidden subscriptions', async () => { const media = createSubscribeTv({ title: '多季剧集', tmdb_id: 105 }) const deleted = vi.fn() diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index 59d267d0..ca41081f 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -100,6 +100,11 @@ export function getMediaSubscribeId(media?: MediaInfo) { return getMediaSubscribeIdentity(media)?.mediaKey ?? '' } +/** 返回订阅 API 使用的音乐实体;旧音乐对象缺省时按单曲兼容。 */ +function getMusicSubscribeType(media?: MediaInfo) { + return media?.type === '音乐' ? (media.music_type ?? 'recording') : undefined +} + // 将订阅模式转换为后端订阅字段。 function getSubscribePayload(mode: SubscribeMode): SubscribePayload { return { @@ -328,8 +333,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { media_id: identity?.mediaId, mediaid: identity?.mediaKey ?? '', // 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。 - music_type: media.music_type, - total_tracks: media.total_tracks, + music_type: getMusicSubscribeType(media), + total_tracks: getMusicSubscribeType(media) === 'album' ? media.total_tracks : undefined, season: media.type === '电影' ? null : season, ...payload, episode_group: episodeGroup.value, @@ -387,6 +392,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const result: { [key: string]: any } = await api.delete(`subscribe/media/${getMediaId()}`, { params: { season: media.type === '电影' ? null : season, + music_type: getMusicSubscribeType(media), }, }) @@ -417,6 +423,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { params: { season, title: currentMedia()?.title, + music_type: getMusicSubscribeType(currentMedia()), }, }) @@ -435,6 +442,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { params: { season, title: currentMedia()?.title, + music_type: getMusicSubscribeType(currentMedia()), }, })