feat(music): support album subscription semantics

This commit is contained in:
jxxghp
2026-08-09 20:43:33 +08:00
parent dd6f644f05
commit 98a9f87768
11 changed files with 182 additions and 21 deletions

View File

@@ -162,11 +162,6 @@
"count": 1
}
},
"src/components/cards/__tests__/SubscribeCard.spec.ts": {
"sonarjs/super-linear-regex": {
"count": 2
}
},
"src/components/dialog/AboutDialog.vue": {
"@typescript-eslint/no-explicit-any": {
"count": 8
@@ -1056,4 +1051,4 @@
"count": 1
}
}
}
}

View File

@@ -18,7 +18,7 @@ export interface Subscribe {
name: string
// 订阅年份
year: string
// 订阅类型 电影/电视剧
// 订阅类型 电影/电视剧/音乐
type: string
// 搜索关键字
keyword?: string
@@ -34,6 +34,10 @@ export interface Subscribe {
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 其它媒体ID
mediaid?: string
// 季号
@@ -140,6 +144,10 @@ export interface SubscribeShare {
media_source?: MediaDataSource
// 数据源原生ID
media_id?: string
// 音乐实体类型recording 单曲、album 专辑、artist 艺术家
music_type?: MusicEntityType
// 专辑总曲目数
total_tracks?: number
// 季号
season?: number
// 海报

View File

@@ -25,6 +25,7 @@ const userStore = useUserStore()
const userPermissions = computed(() => buildUserPermissionContext(userStore.superUser, userStore.permissions))
const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
const canSubscribe = computed(() => hasPermission(userPermissions.value, 'subscribe'))
const isActionableMedia = computed(() => props.music?.music_type !== 'artist')
// 封面加载失败后改用专辑占位图标
const imageLoadError = ref(false)
@@ -90,7 +91,7 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
/** 查询当前音乐是否已订阅,用于决定心形图标是实心还是空心。 */
async function checkSubscribeStatus() {
if (!canSubscribe.value || !props.music?.media_id) return
if (!isActionableMedia.value || !canSubscribe.value || !props.music?.media_id) return
try {
isSubscribed.value = await getCachedMediaSubscribeStatus(getSubscribeStatusKey(), () =>
subscribeActions.checkSubscribe(null),
@@ -161,7 +162,7 @@ onMounted(checkSubscribeStatus)
</VChip>
</div>
<div class="music-card-supporting text-medium-emphasis">
<div v-if="props.music?.music_type !== 'artist'" class="music-card-supporting text-medium-emphasis">
<VIcon icon="mdi-account-music" size="16" />
<div class="music-card-artists">
<template v-if="artistLinks.length">
@@ -183,7 +184,10 @@ onMounted(checkSubscribeStatus)
</div>
</div>
<div v-if="props.music?.album" class="music-card-supporting text-medium-emphasis">
<div
v-if="props.music?.album && props.music?.music_type !== 'album'"
class="music-card-supporting text-medium-emphasis"
>
<VIcon icon="mdi-album" size="16" />
<div class="music-card-album">
<span>{{ t('music.album') }}</span>
@@ -216,7 +220,7 @@ onMounted(checkSubscribeStatus)
<div class="music-card-actions">
<IconBtn
v-if="canSubscribe"
v-if="canSubscribe && isActionableMedia"
:icon="isSubscribed ? 'mdi-heart' : 'mdi-heart-outline'"
:color="isSubscribed ? 'error' : 'medium-emphasis'"
:aria-label="isSubscribed ? t('music.unsubscribe') : t('music.subscribe')"
@@ -226,7 +230,7 @@ onMounted(checkSubscribeStatus)
@click.stop="subscribeActions.handleSubscribe()"
/>
<IconBtn
v-if="canSearch"
v-if="canSearch && isActionableMedia"
icon="mdi-magnify"
color="primary"
:aria-label="t('music.searchResources')"

View File

@@ -136,6 +136,12 @@ const subscribeProgressText = computed(() => {
return `${downloadedEpisode.value} / ${total}`
})
// 专辑订阅按整包完成,不伪造分集进度;这里只展示整专规模,提醒用户该目标包含多首曲目。
const musicAlbumTrackText = computed(() => {
if (props.media?.type !== '音乐' || props.media?.music_type !== 'album' || !props.media?.total_tracks) return ''
return `${t('music.entityAlbum')} · ${t('music.trackCount', { count: props.media.total_tracks })}`
})
// 订阅卡片 hover 文案:
// - 普通订阅:「已下载 X · 共 Y 集」
// - 洗版订阅:「已下载 X · 已洗版 N · 共 Y 集」
@@ -634,8 +640,11 @@ function handleCardClick() {
:data-subscribe-state-icon="compactStateDisplay.icon"
size="16"
/>
<span v-if="subscribeProgressText" class="subscribe-card-mobile-progress-text">
{{ subscribeProgressText }}
<span
v-if="subscribeProgressText || musicAlbumTrackText"
class="subscribe-card-mobile-progress-text"
>
{{ subscribeProgressText || musicAlbumTrackText }}
</span>
</div>
@@ -699,7 +708,9 @@ function handleCardClick() {
</div>
</div>
</VCardText>
<VCardText class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3">
<VCardText
class="absolute inset-x-0 bottom-2 z-10 flex min-w-0 justify-space-between align-center flex-wrap px-3"
>
<div class="flex min-w-0 max-w-full align-center">
<VIcon
v-if="props.media?.total_episode && props.sortable"
@@ -722,6 +733,13 @@ function handleCardClick() {
{{ subscribeProgressTooltip }}
</VTooltip>
</div>
<div
v-else-if="musicAlbumTrackText"
class="flex flex-shrink-0 align-center text-subtitle-2 me-2 text-white"
>
<VIcon icon="mdi-album" size="small" class="me-1" />
{{ musicAlbumTrackText }}
</div>
<VIcon
v-if="props.media?.username && props.sortable"
icon="mdi-account"

View File

@@ -138,7 +138,7 @@ describe('SubscribeCard display and progress', () => {
expect((image as HTMLImageElement).src).toContain('system/cache/image?url=')
expect((image as HTMLImageElement).src).toContain(encodeURIComponent(media.backdrop || ''))
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it('uses the poster as the background fallback and replaces failed images with the placeholder', async () => {
@@ -172,6 +172,21 @@ describe('SubscribeCard display and progress', () => {
expect(container.querySelector('img')).toBeNull()
})
it.each([480, 1024])('shows whole-album track count without fake episode progress at %ipx', async width => {
setViewport(width)
await renderCard({
music_type: 'album',
name: '叶惠美',
total_episode: undefined,
total_tracks: 11,
type: '音乐',
})
expect(screen.getByText('专辑 · 11 首')).toBeInTheDocument()
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()
expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
})
it.each([
['regular progress', 10, 4, '6 / 10', '60'],
['negative missing episodes', 10, -2, '10 / 10', '100'],
@@ -181,7 +196,7 @@ describe('SubscribeCard display and progress', () => {
await renderCard({ lack_episode: lackEpisode, season: 2, total_episode: totalEpisode, type: '电视剧' })
if (expectedText) expect(screen.getByText(expectedText)).toBeInTheDocument()
else expect(screen.queryByText(/\d+ \/ \d+/)).not.toBeInTheDocument()
else expect(screen.queryByText(/^\d{1,4} \/ \d{1,4}$/)).not.toBeInTheDocument()
if (expectedProgress) expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', expectedProgress)
else expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()

View File

@@ -9,6 +9,7 @@ import {
} from '@/composables/useMediaSubscribe'
import { getActiveRequestsCount } from '@/utils/requestOptimizer'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { createMediaInfo } from '@tests/support/factories/media'
import { createSubscribe, createSubscribeMovie, createSubscribeTv } from '@tests/support/factories/subscribe'
import {
createSubscribeHandler,
@@ -261,6 +262,56 @@ describe('useMediaSubscribe entry flows', () => {
expect(mocks.doneProgress).toHaveBeenCalledOnce()
})
it('creates an album subscription with its entity type and complete track count', async () => {
const media = createMediaInfo({
media_id: 'release-group-1',
music_type: 'album',
source: 'musicbrainz',
title: '叶惠美',
tmdb_id: undefined,
total_tracks: 11,
type: '音乐',
year: '2003',
})
const created = vi.fn()
server.use(createSubscribeHandler({ data: { id: 502 }, 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: 'release-group-1',
media_source: 'musicbrainz',
mediaid: 'musicbrainz:release-group-1',
music_type: 'album',
name: '叶惠美',
season: null,
total_tracks: 11,
type: '音乐',
year: '2003',
})
})
it('does not create a subscription for an artist browsing entity', async () => {
const media = createMediaInfo({
media_id: 'artist-1',
music_type: 'artist',
source: 'musicbrainz',
title: '周杰伦',
tmdb_id: undefined,
type: '音乐',
})
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' }))
expect(created).not.toHaveBeenCalled()
expect(mocks.startProgress).not.toHaveBeenCalled()
})
it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
const media = createSubscribeTv({
anilist_id: 154587,

View File

@@ -309,7 +309,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
addOptions: AddSubscribeOptions = {},
) {
const media = currentMedia()
if (!media) return
// 艺术家仅用于继续浏览,其下作品必须按单曲或专辑分别订阅。
if (!media || media.music_type === 'artist') return
const identity = getMediaSubscribeIdentity(media)
startNProgress()
@@ -326,6 +327,9 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
media_source: identity?.source,
media_id: identity?.mediaId,
mediaid: identity?.mediaKey ?? '',
// 专辑订阅必须保留实体类型和曲目总数,后端据此校验整专资源并决定何时完成订阅。
music_type: media.music_type,
total_tracks: media.total_tracks,
season: media.type === '电影' ? null : season,
...payload,
episode_group: episodeGroup.value,
@@ -499,7 +503,7 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) {
// 处理媒体主订阅入口,电视剧统一进入季选择弹窗。
function handlePrimarySubscribe() {
const media = currentMedia()
if (!media) return
if (!media || media.music_type === 'artist') return
const season = media.type === '电影' ? null : getPrimarySeason()

View File

@@ -1,5 +1,5 @@
import MusicPage from '@/pages/music.vue'
import { fireEvent, screen, waitFor } from '@testing-library/vue'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import { renderWithProviders } from '@tests/support/render'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -49,6 +49,33 @@ const musicResult = {
year: 2003,
}
const albumResult = {
album: '七里香',
album_id: 'release-group-2',
album_type: 'Album',
artist: '周杰伦',
artists: ['周杰伦'],
artist_ids: ['artist-1'],
category: 'Album',
media_id: 'release-group-2',
music_type: 'album',
release_date: '2004-08-03',
source: 'musicbrainz',
title: '七里香',
type: '音乐',
year: 2004,
}
const artistResult = {
category: 'Person',
media_id: 'artist-1',
music_type: 'artist',
source: 'musicbrainz',
title: '周杰伦',
type: '音乐',
version: 'Taiwanese singer-songwriter',
}
const musicSite = { id: 11, is_active: true, name: '音乐站点', url: 'https://music.example' }
/** 按请求路径分派音乐搜索与订阅状态查询。 */
@@ -181,6 +208,32 @@ describe('music page', () => {
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('renders album and artist search entities with entity-correct actions and routes', async () => {
mocks.apiGet.mockImplementation((path: string) => {
if (path === 'media/search') return Promise.resolve([musicResult, albumResult, artistResult])
if (path.startsWith('subscribe/media/')) return Promise.reject({ response: { status: 404 } })
return Promise.resolve([])
})
const { router } = await renderMusicPage()
const artistEntity = await screen.findByText('艺术家')
const artistCard = artistEntity.closest('.music-card')
expect(artistCard).not.toBeNull()
expect(within(artistCard as HTMLElement).queryByRole('button', { name: '订阅' })).not.toBeInTheDocument()
expect(within(artistCard as HTMLElement).queryByRole('button', { name: '搜索资源' })).not.toBeInTheDocument()
await fireEvent.click(screen.getByText('七里香'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/album'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'release-group-2' })
await router.push('/music?query=晴天')
const restoredArtistEntity = await screen.findByText('艺术家')
const restoredArtistCard = restoredArtistEntity.closest('.music-card')
await fireEvent.click(within(restoredArtistCard as HTMLElement).getByText('周杰伦'))
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/artist'))
expect(router.currentRoute.value.query).toMatchObject({ mediaid: 'artist-1' })
})
it('selects a music-capable site before routing the resource search', async () => {
const { router } = await renderMusicPage()

View File

@@ -81,6 +81,17 @@ describe('music utils', () => {
})
})
it('does not build a resource route for an artist browsing entity', () => {
expect(
buildMusicResourceRoute({
source: 'musicbrainz',
media_id: 'artist-1',
music_type: 'artist',
title: 'Queen',
} as never),
).toBeUndefined()
})
it('keeps the entity type inside the list key so albums and tracks never collide', () => {
const track = getMusicKey({ source: 'musicbrainz', media_id: 'same-id' })
const album = getMusicKey({ source: 'musicbrainz', media_id: 'same-id', music_type: 'album' })

View File

@@ -63,6 +63,8 @@ export function buildMusicResourceRoute(
item: MediaInfo | MusicAlbumInfo,
sites: number[] = [],
): RouteLocationRaw | undefined {
// 艺术家是浏览入口,不是可直接下载的媒体实体。
if ((item as MusicRouteTarget).music_type === 'artist') return undefined
const source = getMusicSource(item as MusicRouteTarget)
if (!source || !item.media_id) return undefined
return {

View File

@@ -151,7 +151,7 @@ function getSubscribeStatus(subscribe: Subscribe) {
return 'paused' // 暂停
}
// 电影和音乐没有分集进度,只有状态
// 电影、单曲和整专都是原子下载目标;整专曲目总数由订阅卡片单独展示,不映射成分集进度
if (subscribe.type === '电影' || subscribe.type === '音乐') {
return 'all'
}