feat(music): refine source browsing and subscriptions

This commit is contained in:
jxxghp
2026-08-12 12:56:40 +08:00
parent a423a2ce04
commit d7534ecba4
52 changed files with 513 additions and 414 deletions
+10 -7
View File
@@ -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>
+1 -1
View File
@@ -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>
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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') {
+2 -2
View File
@@ -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])