mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 08:06:43 +08:00
feat(music): refine source browsing and subscriptions
This commit is contained in:
@@ -119,6 +119,9 @@ const musicSourceIconDict: Record<string, { color: string; icon: string }> = {
|
||||
doubanmusic: { color: '#00b51d', icon: 'mdi-music-circle' },
|
||||
}
|
||||
|
||||
// 媒体卡片只消费后端统一的主来源字段。
|
||||
const mediaSource = computed(() => props.media?.media_source)
|
||||
|
||||
// 绑定MediaCard元素
|
||||
const mediaCardRef = ref<HTMLElement | null>(null)
|
||||
|
||||
@@ -196,7 +199,7 @@ function getExistsStatusKey() {
|
||||
props.media?.year ?? '',
|
||||
props.media?.season ?? '',
|
||||
props.media?.type ?? '',
|
||||
props.media?.mediaid_prefix ?? '',
|
||||
props.media?.media_source ?? '',
|
||||
props.media?.media_id ?? '',
|
||||
].join('::')
|
||||
}
|
||||
@@ -668,16 +671,16 @@ onBeforeUnmount(() => {
|
||||
density="compact"
|
||||
class="absolute bottom-1 right-1"
|
||||
tile
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError"
|
||||
v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && mediaSource && !imageLoadError"
|
||||
>
|
||||
<VIcon v-if="props.media?.source === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VIcon v-if="mediaSource === 'anilist'" color="#02a9ff" icon="mdi-alpha-a-circle" size="24" />
|
||||
<VIcon
|
||||
v-else-if="musicSourceIconDict[props.media?.source]"
|
||||
:color="musicSourceIconDict[props.media.source].color"
|
||||
:icon="musicSourceIconDict[props.media.source].icon"
|
||||
v-else-if="musicSourceIconDict[mediaSource]"
|
||||
:color="musicSourceIconDict[mediaSource].color"
|
||||
:icon="musicSourceIconDict[mediaSource].icon"
|
||||
size="24"
|
||||
/>
|
||||
<VImg v-else cover :src="sourceIconDict[props.media?.source]" class="shadow-lg" />
|
||||
<VImg v-else cover :src="sourceIconDict[mediaSource]" class="shadow-lg" />
|
||||
</VAvatar>
|
||||
</VCard>
|
||||
</div>
|
||||
|
||||
@@ -25,7 +25,7 @@ const subtitle = computed(() => getMusicArtistSubtitle(props.artist))
|
||||
/** 打开艺术家详情页。 */
|
||||
function goArtistDetail() {
|
||||
if (!props.artist?.media_id) return
|
||||
router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.source))
|
||||
router.push(buildMusicArtistRoute(props.artist.media_id, props.artist.name, props.artist.media_source))
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -40,14 +40,14 @@ const isSubscribed = ref(false)
|
||||
|
||||
// 可点击跳转的艺术家
|
||||
const artistLinks = computed(() => getMusicArtistLinks(props.music))
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.music?.source, t))
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.music?.media_source, t))
|
||||
const sourceMeta = computed(() => {
|
||||
const sources: Record<string, { color: string; icon: string }> = {
|
||||
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' }
|
||||
return sources[props.music?.media_source || 'musicbrainz'] || { color: 'primary', icon: 'mdi-database-outline' }
|
||||
})
|
||||
|
||||
// 音乐实体标签和图标
|
||||
@@ -129,13 +129,13 @@ function goDetail() {
|
||||
/** 打开所属专辑详情页。 */
|
||||
function goAlbum() {
|
||||
if (!props.music?.album_id) return
|
||||
router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.source))
|
||||
router.push(buildMusicAlbumRoute(props.music.album_id, props.music.album, props.music.media_source))
|
||||
}
|
||||
|
||||
/** 打开艺术家详情页。 */
|
||||
function goArtist(artistId?: string, name?: string) {
|
||||
if (!artistId) return
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.music?.source))
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.music?.media_source))
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -295,7 +295,7 @@ describe('MediaCard', () => {
|
||||
],
|
||||
[
|
||||
'extension',
|
||||
createMediaInfo({ media_id: 'item-9204', mediaid_prefix: 'custom', tmdb_id: undefined }),
|
||||
createMediaInfo({ media_id: 'item-9204', media_source: 'custom' as never, tmdb_id: undefined }),
|
||||
'custom:item-9204',
|
||||
null,
|
||||
],
|
||||
@@ -368,8 +368,7 @@ describe('MediaCard', () => {
|
||||
const media = createMediaInfo({
|
||||
artist: '周杰伦',
|
||||
media_id: 'recording-1',
|
||||
mediaid_prefix: 'musicbrainz',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
tmdb_id: undefined,
|
||||
type: '音乐',
|
||||
@@ -395,7 +394,7 @@ describe('MediaCard', () => {
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/music/detail',
|
||||
query: {
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
mediaid: 'recording-1',
|
||||
title: '晴天',
|
||||
},
|
||||
@@ -412,10 +411,9 @@ describe('MediaCard', () => {
|
||||
const media = createMediaInfo({
|
||||
artist: 'Artist',
|
||||
media_id: mediaId,
|
||||
mediaid_prefix: source,
|
||||
media_source: source,
|
||||
music_type: 'album',
|
||||
poster_path: undefined,
|
||||
source,
|
||||
title,
|
||||
tmdb_id: undefined,
|
||||
total_tracks: 10,
|
||||
@@ -441,7 +439,7 @@ describe('MediaCard', () => {
|
||||
await waitFor(() =>
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith({
|
||||
path: '/music/album',
|
||||
query: { mediaid: mediaId, source, title },
|
||||
query: { media_source: source, mediaid: mediaId, title },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -473,7 +471,7 @@ describe('MediaCard', () => {
|
||||
it('uses an album placeholder instead of the movie fallback image for music without a cover', async () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'recording-2',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
poster_path: undefined,
|
||||
title: '无封面歌曲',
|
||||
tmdb_id: undefined,
|
||||
@@ -648,7 +646,7 @@ describe('MediaCard', () => {
|
||||
it('matches custom media IDs when collecting subscribed TV seasons', async () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'series-9553',
|
||||
mediaid_prefix: 'custom',
|
||||
media_source: 'custom' as never,
|
||||
season: 2,
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
@@ -681,9 +679,8 @@ describe('MediaCard', () => {
|
||||
'structured TMDB identity',
|
||||
createMediaInfo({
|
||||
media_id: 'series-9554',
|
||||
mediaid_prefix: undefined,
|
||||
season: 2,
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
}),
|
||||
@@ -696,7 +693,7 @@ describe('MediaCard', () => {
|
||||
],
|
||||
[
|
||||
'legacy AniList identity',
|
||||
createMediaInfo({ anilist_id: 154588, season: 2, source: 'anilist', tmdb_id: undefined, type: '电视剧' }),
|
||||
createMediaInfo({ anilist_id: 154588, season: 2, media_source: 'anilist', tmdb_id: undefined, type: '电视剧' }),
|
||||
'anilist:154588',
|
||||
[
|
||||
{ anilistid: 154588, id: 95, season: 1, type: '电视剧' },
|
||||
@@ -728,7 +725,7 @@ describe('MediaCard', () => {
|
||||
it('updates image badges on load and shows the typed placeholder after an image error', async () => {
|
||||
const media = createMediaInfo({
|
||||
poster_path: '/original/poster.jpg',
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
tmdb_id: 9552,
|
||||
type: '电视剧',
|
||||
vote_average: 8.6,
|
||||
@@ -830,7 +827,7 @@ describe('MediaCard', () => {
|
||||
createMediaInfo({
|
||||
anilist_id: 154588,
|
||||
poster_path: '/original/anilist.jpg',
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
tmdb_id: undefined,
|
||||
type: '电视剧',
|
||||
}),
|
||||
@@ -844,7 +841,7 @@ describe('MediaCard', () => {
|
||||
media_id: 'album-2109619',
|
||||
music_type: 'album',
|
||||
poster_path: undefined,
|
||||
source: 'theaudiodb',
|
||||
media_source: 'theaudiodb',
|
||||
tmdb_id: undefined,
|
||||
type: '音乐',
|
||||
}),
|
||||
@@ -858,7 +855,7 @@ describe('MediaCard', () => {
|
||||
media_id: '1401853',
|
||||
music_type: 'album',
|
||||
poster_path: undefined,
|
||||
source: 'doubanmusic',
|
||||
media_source: 'doubanmusic',
|
||||
tmdb_id: undefined,
|
||||
type: '音乐',
|
||||
}),
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('music image cache integration', () => {
|
||||
cover_url: cover,
|
||||
media_id: 'recording-1',
|
||||
music_type: 'recording',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '测试单曲',
|
||||
type: '音乐',
|
||||
} as MediaInfo
|
||||
@@ -66,7 +66,7 @@ describe('music image cache integration', () => {
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
name: '测试艺术家',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
type: '音乐',
|
||||
} as MusicArtistInfo
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const SUPPORTED_MEDIA_SOURCES: MediaDataSource[] = [
|
||||
|
||||
// 当前识别类型:优先使用媒体自身的数据源,否则使用全局识别来源
|
||||
const mediaSource = computed<MediaDataSource>(() => {
|
||||
const source = props.media?.source as MediaDataSource | undefined
|
||||
const source = props.media?.media_source
|
||||
if (source && SUPPORTED_MEDIA_SOURCES.includes(source)) return source
|
||||
if (props.torrent?.category === '音乐' || props.torrent?.category === 'music') return 'musicbrainz'
|
||||
if (SUPPORTED_MEDIA_SOURCES.includes(globalSettings.RECOGNIZE_SOURCE as MediaDataSource)) {
|
||||
@@ -95,7 +95,7 @@ const musicEntityOptions = computed(() => [
|
||||
watch(
|
||||
() => props.media,
|
||||
media => {
|
||||
if (media?.source && SUPPORTED_MEDIA_SOURCES.includes(media.source) && media.media_id) {
|
||||
if (media?.media_source && SUPPORTED_MEDIA_SOURCES.includes(media.media_source) && media.media_id) {
|
||||
mediaId.value = media.media_id
|
||||
}
|
||||
if (media?.music_type === 'recording' || media?.music_type === 'album') {
|
||||
|
||||
@@ -468,7 +468,7 @@ function searchMedia(searchType: MediaSearchType) {
|
||||
path: '/music',
|
||||
query: {
|
||||
query: searchWord.value,
|
||||
...(sources.length > 0 ? { source: sources.join(',') } : {}),
|
||||
...(sources.length > 0 ? { media_source: sources.join(',') } : {}),
|
||||
},
|
||||
})
|
||||
closeSearch()
|
||||
@@ -480,7 +480,7 @@ function searchMedia(searchType: MediaSearchType) {
|
||||
}
|
||||
// 多个来源以逗号分隔传给后端,未勾选任何来源时不传,由后端按全局配置搜索。
|
||||
const sources = selectedMediaSearchSources[searchType]
|
||||
if (sources.length > 0) query.source = sources.join(',')
|
||||
if (sources.length > 0) query.media_source = sources.join(',')
|
||||
router.push({
|
||||
path: '/browse/media/search',
|
||||
query,
|
||||
|
||||
@@ -77,7 +77,7 @@ const stateDict = computed<Record<string, string>>(() => ({
|
||||
|
||||
// 队列作业由来源原生媒体 ID 与季号共同标识;缺少媒体 ID 时才退回展示标题。
|
||||
function getMediaIdentity(item: TransferQueue) {
|
||||
const source = item.media.mediaid_prefix || item.media.source
|
||||
const source = item.media.media_source
|
||||
const mediaIdentity =
|
||||
source && item.media.media_id
|
||||
? `${source}:${item.media.media_id}`
|
||||
|
||||
@@ -158,7 +158,7 @@ function createMedia(overrides: Partial<MediaInfo> = {}): MediaInfo {
|
||||
return {
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
title: '测试电影',
|
||||
tmdb_id: 6001,
|
||||
type: '电影',
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('SearchBarDialog media source selection', () => {
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.path).toBe('/browse/media/search')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
title: '流浪地球',
|
||||
type: 'media',
|
||||
})
|
||||
@@ -106,7 +106,7 @@ describe('SearchBarDialog media source selection', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'themoviedb,douban',
|
||||
media_source: 'themoviedb,douban',
|
||||
title: '芙莉莲',
|
||||
type: 'media',
|
||||
})
|
||||
@@ -164,7 +164,7 @@ describe('SearchBarDialog media source selection', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
title: '芙莉莲',
|
||||
type: 'media',
|
||||
})
|
||||
@@ -187,7 +187,7 @@ describe('SearchBarDialog media source selection', () => {
|
||||
expect(router.currentRoute.value.path).toBe('/music')
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
query: 'Coldplay',
|
||||
source: 'theaudiodb',
|
||||
media_source: 'theaudiodb',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -220,7 +220,7 @@ describe('SearchBarDialog media source selection', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.currentRoute.value.query).toEqual({
|
||||
source: 'douban',
|
||||
media_source: 'douban',
|
||||
title: '刘德华',
|
||||
type: 'person',
|
||||
})
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('SubscribeSeasonDialog', () => {
|
||||
it('preserves source image URLs and hides unavailable season metadata', async () => {
|
||||
const media = createTvMedia({
|
||||
poster_path: 'https://images.example.com/main-poster.jpg',
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
tmdb_id: undefined,
|
||||
})
|
||||
server.use(
|
||||
@@ -220,20 +220,24 @@ describe('SubscribeSeasonDialog', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Douban', { douban_id: 'db-7303', source: 'douban', tmdb_id: undefined }, 'douban:db-7303'],
|
||||
['Douban', { douban_id: 'db-7303', media_source: 'douban', tmdb_id: undefined }, 'douban:db-7303'],
|
||||
[
|
||||
'Bangumi',
|
||||
{ bangumi_id: 'bgm-7304', douban_id: undefined, source: 'bangumi', tmdb_id: undefined },
|
||||
{ bangumi_id: 'bgm-7304', douban_id: undefined, media_source: 'bangumi', tmdb_id: undefined },
|
||||
'bangumi:bgm-7304',
|
||||
],
|
||||
['AniList', { anilist_id: 154587, bangumi_id: undefined, source: 'anilist', tmdb_id: undefined }, 'anilist:154587'],
|
||||
[
|
||||
'AniList',
|
||||
{ anilist_id: 154587, bangumi_id: undefined, media_source: 'anilist', tmdb_id: undefined },
|
||||
'anilist:154587',
|
||||
],
|
||||
[
|
||||
'custom source',
|
||||
{
|
||||
bangumi_id: undefined,
|
||||
douban_id: undefined,
|
||||
media_id: 'custom-7305',
|
||||
mediaid_prefix: 'custom',
|
||||
media_source: 'custom',
|
||||
tmdb_id: undefined,
|
||||
},
|
||||
'custom:custom-7305',
|
||||
@@ -244,8 +248,7 @@ describe('SubscribeSeasonDialog', () => {
|
||||
bangumi_id: undefined,
|
||||
douban_id: undefined,
|
||||
media_id: 'source-7306',
|
||||
mediaid_prefix: undefined,
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
tmdb_id: undefined,
|
||||
},
|
||||
'tmdb:source-7306',
|
||||
@@ -270,9 +273,9 @@ describe('SubscribeSeasonDialog', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Douban', { douban_id: 'db-7310', source: 'douban' }, 'douban:db-7310'],
|
||||
['Bangumi', { bangumi_id: 'bgm-7310', source: 'bangumi' }, 'bangumi:bgm-7310'],
|
||||
['AniList', { anilist_id: 154587, source: 'anilist' }, 'anilist:154587'],
|
||||
['Douban', { douban_id: 'db-7310', media_source: 'douban' }, 'douban:db-7310'],
|
||||
['Bangumi', { bangumi_id: 'bgm-7310', media_source: 'bangumi' }, 'bangumi:bgm-7310'],
|
||||
['AniList', { anilist_id: 154587, media_source: 'anilist' }, 'anilist:154587'],
|
||||
] as const)(
|
||||
'keeps the %s identity and skips episode groups when an auxiliary TMDB ID exists',
|
||||
async (_label, overrides, mediaId) => {
|
||||
|
||||
@@ -82,8 +82,7 @@ function createQueueItem({
|
||||
episode_run_time: [],
|
||||
origin_country: [],
|
||||
media_id: String(id),
|
||||
mediaid_prefix: 'themoviedb',
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
title,
|
||||
title_year: titleYear,
|
||||
year: '2026',
|
||||
@@ -211,8 +210,7 @@ describe('TransferQueueDialog', () => {
|
||||
title: '自定义来源',
|
||||
titleYear: '重复标题 (2026)',
|
||||
})
|
||||
custom.media.source = 'custom-source'
|
||||
custom.media.mediaid_prefix = 'custom-source'
|
||||
custom.media.media_source = 'custom-source'
|
||||
custom.media.media_id = 'custom-7302'
|
||||
const fallback = createQueueItem({
|
||||
id: 7399,
|
||||
@@ -220,8 +218,7 @@ describe('TransferQueueDialog', () => {
|
||||
title: '标题回退',
|
||||
titleYear: '唯一回退标题 (2026)',
|
||||
})
|
||||
fallback.media.source = undefined
|
||||
fallback.media.mediaid_prefix = undefined
|
||||
fallback.media.media_source = undefined
|
||||
fallback.media.media_id = undefined
|
||||
mocks.apiGet.mockResolvedValue([builtIn, custom, fallback])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user