mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-15 10:34:35 +08:00
feat(music): support album subscription semantics
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// 海报
|
||||
|
||||
@@ -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')"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -151,7 +151,7 @@ function getSubscribeStatus(subscribe: Subscribe) {
|
||||
return 'paused' // 暂停
|
||||
}
|
||||
|
||||
// 电影和音乐没有分集进度,只有状态。
|
||||
// 电影、单曲和整专都是原子下载目标;整专曲目总数由订阅卡片单独展示,不映射成分集进度。
|
||||
if (subscribe.type === '电影' || subscribe.type === '音乐') {
|
||||
return 'all'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user