mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-05 15:36:49 +08:00
feat(music): add MusicAlbumView, MusicArtistView, and MusicDetailView components
- Implement MusicAlbumView for displaying album details, including tracks and artist links. - Create MusicArtistView to show artist information and their albums. - Develop MusicDetailView for individual music tracks, integrating album details and related artists. - Introduce MusicDetailLayout for consistent layout across music-related views. - Add MusicArtistSlideView for displaying related artists in a slide format. - Enhance user experience with loading states and error handling in NoDataFound component.
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
buildMusicAlbumRoute,
|
||||
buildMusicArtistRoute,
|
||||
buildMusicDetailRoute,
|
||||
buildMusicResourceRoute,
|
||||
formatMusicDuration,
|
||||
getMusicArtistLinks,
|
||||
getMusicArtistSubtitle,
|
||||
getMusicKey,
|
||||
} from '@/utils/music'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('music utils', () => {
|
||||
it('routes a recording to the music detail page', () => {
|
||||
expect(buildMusicDetailRoute({ source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
|
||||
path: '/music/detail',
|
||||
query: { source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an album entity to the album page', () => {
|
||||
expect(
|
||||
buildMusicDetailRoute({
|
||||
source: 'musicbrainz',
|
||||
media_id: 'release-group-1',
|
||||
music_type: 'album',
|
||||
title: '叶惠美',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/music/album',
|
||||
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an artist entity to the artist page', () => {
|
||||
expect(
|
||||
buildMusicDetailRoute({
|
||||
source: 'musicbrainz',
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
name: 'Queen',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/music/artist',
|
||||
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the search page without a standard identity', () => {
|
||||
expect(buildMusicDetailRoute({ title: '晴天' })).toEqual({
|
||||
path: '/music',
|
||||
query: { query: '晴天' },
|
||||
})
|
||||
})
|
||||
|
||||
it('builds album and artist routes with the default source', () => {
|
||||
expect(buildMusicAlbumRoute('release-group-1', '叶惠美')).toEqual({
|
||||
path: '/music/album',
|
||||
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
|
||||
})
|
||||
expect(buildMusicArtistRoute('artist-1', 'Queen')).toEqual({
|
||||
path: '/music/artist',
|
||||
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
})
|
||||
})
|
||||
|
||||
it('builds the site resource route from the metadata identity', () => {
|
||||
expect(
|
||||
buildMusicResourceRoute({
|
||||
source: 'musicbrainz',
|
||||
media_id: 'recording-1',
|
||||
title: '晴天',
|
||||
year: '2003',
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
path: '/resource',
|
||||
query: { keyword: 'musicbrainz:recording-1', type: '音乐' },
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
expect(track).not.toEqual(album)
|
||||
})
|
||||
|
||||
it('pairs artist names with their standard ids by position', () => {
|
||||
expect(getMusicArtistLinks({ artists: ['A', 'B'], artist_ids: ['', 'artist-b'] })).toEqual([
|
||||
{ name: 'A' },
|
||||
{ name: 'B', id: 'artist-b' },
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to the joined artist text when the list is missing', () => {
|
||||
expect(getMusicArtistLinks({ artist: 'A / B' })).toEqual([{ name: 'A / B' }])
|
||||
})
|
||||
|
||||
it('formats durations with and without hours', () => {
|
||||
expect(formatMusicDuration(269)).toBe('4:29')
|
||||
expect(formatMusicDuration(5)).toBe('0:05')
|
||||
expect(formatMusicDuration(3725)).toBe('1:02:05')
|
||||
expect(formatMusicDuration(undefined)).toBe('')
|
||||
})
|
||||
|
||||
it('joins the artist relation, type and disambiguation into a subtitle', () => {
|
||||
expect(
|
||||
getMusicArtistSubtitle({
|
||||
relation: 'member of band',
|
||||
artist_type: 'Person',
|
||||
disambiguation: 'guitarist',
|
||||
}),
|
||||
).toBe('member of band · Person · guitarist')
|
||||
})
|
||||
})
|
||||
+58
-11
@@ -1,15 +1,22 @@
|
||||
import type { MediaInfo } from '@/api/types'
|
||||
import type { MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from '@/api/types'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export interface MusicRouteTarget {
|
||||
source?: string
|
||||
media_source?: string
|
||||
media_id?: string | number
|
||||
music_type?: MusicEntityType
|
||||
title?: string
|
||||
name?: string
|
||||
year?: string | number
|
||||
}
|
||||
|
||||
// 音乐体系中一位可跳转的艺术家
|
||||
export interface MusicArtistLink {
|
||||
name: string
|
||||
id?: string
|
||||
}
|
||||
|
||||
/** 返回音乐对象可用于路由和订阅的统一来源。 */
|
||||
export function getMusicSource(item: MusicRouteTarget): string | undefined {
|
||||
return item.source || item.media_source
|
||||
@@ -18,31 +25,42 @@ export function getMusicSource(item: MusicRouteTarget): string | undefined {
|
||||
/** 返回音乐候选在列表和状态缓存中的稳定身份。 */
|
||||
export function getMusicKey(item: MusicRouteTarget): string {
|
||||
const source = getMusicSource(item) || 'music'
|
||||
return `${source}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
|
||||
const entity = item.music_type || 'recording'
|
||||
return `${source}:${entity}:${item.media_id || `${item.title || item.name}-${item.year || ''}`}`
|
||||
}
|
||||
|
||||
/** 构造音乐详情路由,缺少标准身份时回退到音乐搜索页。 */
|
||||
/** 构造专辑详情路由。 */
|
||||
export function buildMusicAlbumRoute(albumId: string, title?: string, source = 'musicbrainz'): RouteLocationRaw {
|
||||
return { path: '/music/album', query: { source, mediaid: albumId, title } }
|
||||
}
|
||||
|
||||
/** 构造艺术家详情路由。 */
|
||||
export function buildMusicArtistRoute(artistId: string, name?: string, source = 'musicbrainz'): RouteLocationRaw {
|
||||
return { path: '/music/artist', query: { source, mediaid: artistId, title: name } }
|
||||
}
|
||||
|
||||
/** 按音乐实体类型构造详情路由,缺少标准身份时回退到音乐搜索页。 */
|
||||
export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw {
|
||||
const source = getMusicSource(item)
|
||||
if (!source || !item.media_id) {
|
||||
return {
|
||||
path: '/music',
|
||||
query: { query: item.title || item.name },
|
||||
}
|
||||
const mediaId = item.media_id?.toString()
|
||||
if (!source || !mediaId) {
|
||||
return { path: '/music', query: { query: item.title || item.name } }
|
||||
}
|
||||
if (item.music_type === 'album') return buildMusicAlbumRoute(mediaId, item.title || item.name, source)
|
||||
if (item.music_type === 'artist') return buildMusicArtistRoute(mediaId, item.name || item.title, source)
|
||||
return {
|
||||
path: '/music/detail',
|
||||
query: {
|
||||
source,
|
||||
mediaid: item.media_id.toString(),
|
||||
mediaid: mediaId,
|
||||
title: item.title || item.name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 构造音乐元数据身份对应的站点资源搜索路由。 */
|
||||
export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | undefined {
|
||||
const source = getMusicSource(item)
|
||||
export function buildMusicResourceRoute(item: MediaInfo | MusicAlbumInfo): RouteLocationRaw | undefined {
|
||||
const source = getMusicSource(item as MusicRouteTarget)
|
||||
if (!source || !item.media_id) return undefined
|
||||
return {
|
||||
path: '/resource',
|
||||
@@ -56,3 +74,32 @@ export function buildMusicResourceRoute(item: MediaInfo): RouteLocationRaw | und
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 把音乐信息整理成可点击跳转的艺术家列表,ID 与名称按下标对应。 */
|
||||
export function getMusicArtistLinks(
|
||||
item?: Pick<MediaInfo, 'artists' | 'artist_ids' | 'artist'> | MusicAlbumInfo,
|
||||
): MusicArtistLink[] {
|
||||
if (!item) return []
|
||||
const names = item.artists?.length ? item.artists : item.artist ? [item.artist] : []
|
||||
return names.map((name, index) => {
|
||||
const id = item.artist_ids?.[index]
|
||||
return id ? { name, id } : { name }
|
||||
})
|
||||
}
|
||||
|
||||
/** 把秒数格式化为音乐展示使用的时长文本。 */
|
||||
export function formatMusicDuration(seconds?: number): string {
|
||||
if (!seconds || seconds <= 0) return ''
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainder = Math.floor(seconds % 60)
|
||||
const paddedSeconds = remainder.toString().padStart(2, '0')
|
||||
if (!hours) return `${minutes}:${paddedSeconds}`
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}:${paddedSeconds}`
|
||||
}
|
||||
|
||||
/** 返回艺术家卡片和详情页展示用的副标题。 */
|
||||
export function getMusicArtistSubtitle(artist?: MusicArtistInfo): string {
|
||||
if (!artist) return ''
|
||||
return [artist.relation, artist.artist_type, artist.disambiguation].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user