mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-06 16:16:42 +08:00
feat(music): refine source browsing and subscriptions
This commit is contained in:
+6
-7
@@ -373,8 +373,8 @@ export interface DownloadHistory {
|
||||
|
||||
// 媒体信息
|
||||
export interface MediaInfo {
|
||||
// 来源:themoviedb、douban、bangumi、anilist、musicbrainz、theaudiodb、doubanmusic
|
||||
source?: string
|
||||
// 主媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 类型 电影、电视剧、音乐、合集
|
||||
type?: string
|
||||
// 媒体标题
|
||||
@@ -404,7 +404,6 @@ export interface MediaInfo {
|
||||
// 合集ID
|
||||
collection_id?: number
|
||||
// 其它媒体ID前缀
|
||||
mediaid_prefix?: string
|
||||
// 其它媒体ID值
|
||||
media_id?: string
|
||||
// 媒体原语种
|
||||
@@ -564,8 +563,8 @@ export interface MusicAlbumInfo {
|
||||
type?: string
|
||||
// 实体类型,固定为 album
|
||||
music_type?: MusicEntityType
|
||||
// 来源
|
||||
source?: string
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 专辑标准ID
|
||||
media_id?: string
|
||||
// 专辑名称
|
||||
@@ -622,8 +621,8 @@ export interface MusicArtistInfo {
|
||||
type?: string
|
||||
// 实体类型,固定为 artist
|
||||
music_type?: MusicEntityType
|
||||
// 来源
|
||||
source?: string
|
||||
// 媒体数据源
|
||||
media_source?: MediaDataSource
|
||||
// 艺术家标准ID
|
||||
media_id?: string
|
||||
// 艺术家名称
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('media subscribe identifiers and modes', () => {
|
||||
['AniList after Bangumi', { anilist_id: 40, bangumi_id: undefined, tmdb_id: undefined }, 'anilist:40'],
|
||||
[
|
||||
'generic identifiers when provider ids are absent',
|
||||
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', mediaid_prefix: 'custom', tmdb_id: undefined },
|
||||
{ bangumi_id: undefined, douban_id: undefined, media_id: 'abc', media_source: 'custom', tmdb_id: undefined },
|
||||
'custom:abc',
|
||||
],
|
||||
])('uses %s', (_case, overrides, expected) => {
|
||||
@@ -197,7 +197,7 @@ describe('media subscribe identifiers and modes', () => {
|
||||
it('keeps the declared AniList identity when TMDB is only auxiliary data', () => {
|
||||
const media = createSubscribeTv({
|
||||
anilist_id: 154587,
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
tmdb_id: 209867,
|
||||
})
|
||||
|
||||
@@ -208,6 +208,20 @@ describe('media subscribe identifiers and modes', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the unified media source for the primary identity', () => {
|
||||
const media = createSubscribeMovie({
|
||||
media_id: '1402',
|
||||
media_source: 'douban',
|
||||
tmdb_id: 1402,
|
||||
})
|
||||
|
||||
expect(getMediaSubscribeIdentity(media)).toEqual({
|
||||
mediaId: '1402',
|
||||
mediaKey: 'douban:1402',
|
||||
source: 'douban',
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ best_version: false, best_version_full: true }, 'normal'],
|
||||
[{ best_version: 0, best_version_full: 1 }, 'normal'],
|
||||
@@ -266,7 +280,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'release-group-1',
|
||||
music_type: 'album',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '叶惠美',
|
||||
tmdb_id: undefined,
|
||||
total_tracks: 11,
|
||||
@@ -300,7 +314,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'recording-1',
|
||||
music_type: 'recording',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
tmdb_id: undefined,
|
||||
total_tracks: 11,
|
||||
@@ -331,7 +345,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '周杰伦',
|
||||
tmdb_id: undefined,
|
||||
type: '音乐',
|
||||
@@ -349,7 +363,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
it('creates an AniList subscription without promoting its auxiliary TMDB ID', async () => {
|
||||
const media = createSubscribeTv({
|
||||
anilist_id: 154587,
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
title: 'AniList 订阅剧集',
|
||||
tmdb_id: 209867,
|
||||
})
|
||||
@@ -516,7 +530,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
},
|
||||
{
|
||||
label: 'generic provider',
|
||||
media: createSubscribeTv({ media_id: 'series-1', mediaid_prefix: 'custom', tmdb_id: undefined }),
|
||||
media: createSubscribeTv({ media_id: 'series-1', media_source: 'custom', tmdb_id: undefined }),
|
||||
mediaId: 'custom:series-1',
|
||||
record: createSubscribe({ mediaid: 'custom:series-1', season: 2, tmdbid: 0, type: '电视剧' }),
|
||||
},
|
||||
@@ -555,7 +569,7 @@ describe('useMediaSubscribe entry flows', () => {
|
||||
const media = createMediaInfo({
|
||||
media_id: 'release-group-1',
|
||||
music_type: 'album',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '叶惠美',
|
||||
tmdb_id: undefined,
|
||||
type: '音乐',
|
||||
|
||||
@@ -115,7 +115,7 @@ describe('native subscribe media normalization', () => {
|
||||
success: true,
|
||||
media: expect.objectContaining({
|
||||
media_id: 'subject-42',
|
||||
source: 'custom-source',
|
||||
media_source: 'custom-source',
|
||||
type: '电影',
|
||||
}),
|
||||
})
|
||||
@@ -160,11 +160,23 @@ describe('plugin native subscribe flow', () => {
|
||||
mocks.apiGet.mockResolvedValue({ success: true })
|
||||
const nativeSubscribe = await renderNativeSubscribeHarness()
|
||||
|
||||
await expect(nativeSubscribe({ title: '原生电影', tmdb_id: 600, type: '电影' })).resolves.toEqual({ success: true })
|
||||
await expect(
|
||||
nativeSubscribe({
|
||||
media_source: 'themoviedb',
|
||||
media_id: '600',
|
||||
title: '原生电影',
|
||||
type: '电影',
|
||||
}),
|
||||
).resolves.toEqual({ success: true })
|
||||
|
||||
expect(mocks.checkSubscribe).toHaveBeenCalledWith(null)
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('mediaserver/exists', {
|
||||
params: expect.objectContaining({ mtype: '电影', title: '原生电影', tmdbid: 600 }),
|
||||
params: expect.objectContaining({
|
||||
media_source: 'themoviedb',
|
||||
media_id: '600',
|
||||
mtype: '电影',
|
||||
title: '原生电影',
|
||||
}),
|
||||
})
|
||||
expect(mocks.subscribeOptions?.isSubscribed.value).toBe(true)
|
||||
expect(mocks.subscribeOptions?.isExists()).toBe(true)
|
||||
|
||||
@@ -80,7 +80,7 @@ export function getMediaSubscribeIdentity(media?: MediaInfo): MediaSubscribeIden
|
||||
}
|
||||
}
|
||||
|
||||
const declaredSources = [media.mediaid_prefix, media.source]
|
||||
const declaredSources = [media.media_source]
|
||||
.map(normalizeSource)
|
||||
.filter((source, index, sources) => source && sources.indexOf(source) === index)
|
||||
for (const source of declaredSources) {
|
||||
|
||||
@@ -64,7 +64,7 @@ function normalizeStringId(value: unknown) {
|
||||
return id && id !== '0' ? id : undefined
|
||||
}
|
||||
|
||||
/** 规范插件媒体信息并兼容后端字段别名,校验结果可供宿主明确拒绝无效调用。 */
|
||||
/** 规范插件媒体信息并校验统一媒体身份,校验结果可供宿主明确拒绝无效调用。 */
|
||||
export function normalizeNativeSubscribeMedia(input: unknown): MediaNormalizationResult {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
return { success: false, reason: 'invalidMedia' }
|
||||
@@ -77,16 +77,14 @@ export function normalizeNativeSubscribeMedia(input: unknown): MediaNormalizatio
|
||||
const title = typeof raw.title === 'string' ? raw.title.trim() : ''
|
||||
if (!title) return { success: false, reason: 'missingTitle' }
|
||||
|
||||
const source = normalizeStringId(raw.source ?? raw.media_source)
|
||||
const mediaidPrefix = normalizeStringId(raw.mediaid_prefix)
|
||||
const mediaSource = normalizeStringId(raw.media_source)
|
||||
const normalizedMedia = {
|
||||
...raw,
|
||||
anilist_id: normalizeNumericId(raw.anilist_id ?? raw.anilistid),
|
||||
bangumi_id: normalizeStringId(raw.bangumi_id ?? raw.bangumiid),
|
||||
douban_id: normalizeStringId(raw.douban_id ?? raw.doubanid),
|
||||
media_id: normalizeStringId(raw.media_id),
|
||||
mediaid_prefix: mediaidPrefix,
|
||||
source,
|
||||
media_source: mediaSource,
|
||||
title,
|
||||
tmdb_id: normalizeNumericId(raw.tmdb_id ?? raw.tmdbid),
|
||||
type,
|
||||
@@ -150,9 +148,10 @@ export function usePluginNativeSubscribe(): NativeSubscribe {
|
||||
api.get('mediaserver/exists', {
|
||||
params: {
|
||||
mtype: currentMedia.type,
|
||||
media_source: currentMedia.media_source,
|
||||
media_id: currentMedia.media_id,
|
||||
season: currentMedia.season,
|
||||
title: currentMedia.title,
|
||||
tmdbid: currentMedia.tmdb_id,
|
||||
year: currentMedia.year,
|
||||
},
|
||||
}) as Promise<{ success?: boolean }>,
|
||||
|
||||
+18
-4
@@ -574,8 +574,7 @@ export default {
|
||||
douban: 'Douban',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
music: 'Music',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
music: 'MusicBrainz',
|
||||
},
|
||||
user: {
|
||||
admin: 'Admin',
|
||||
@@ -1381,8 +1380,6 @@ export default {
|
||||
anilistTrendingNow: 'AniList TRENDING NOW',
|
||||
anilistPopularThisSeason: 'AniList POPULAR THIS SEASON',
|
||||
listenBrainzWeekly: 'Weekly Popular Music',
|
||||
theAudioDbAlbums: 'TheAudioDB Top Albums',
|
||||
theAudioDbTracks: 'TheAudioDB Top Tracks',
|
||||
doubanMusic: 'Douban Music Picks',
|
||||
tmdbHotMovies: 'TMDB Hot Movies',
|
||||
tmdbHotTVShows: 'TMDB Hot TV Shows',
|
||||
@@ -4234,6 +4231,23 @@ export default {
|
||||
genre: 'Genre',
|
||||
zone: 'Region',
|
||||
year: 'Year',
|
||||
music: {
|
||||
browse: 'Browse',
|
||||
mode: { chart: 'Charts', tag: 'Categories' },
|
||||
genre: {
|
||||
pop: 'Pop',
|
||||
rock: 'Rock',
|
||||
folk: 'Folk',
|
||||
electronic: 'Electronic',
|
||||
jazz: 'Jazz',
|
||||
classical: 'Classical',
|
||||
soundtrack: 'Soundtrack',
|
||||
indie: 'Indie',
|
||||
instrumental: 'Instrumental',
|
||||
},
|
||||
region: { mainland: 'Mainland China', hongKong: 'Hong Kong', taiwan: 'Taiwan', cantonese: 'Cantonese' },
|
||||
sort: { comprehensive: 'Comprehensive', rating: 'Rating', date: 'Date', markCount: 'Most Marked' },
|
||||
},
|
||||
sortType: {
|
||||
comprehensive: 'Comprehensive',
|
||||
releaseDate: 'Release Date',
|
||||
|
||||
+18
-4
@@ -565,8 +565,7 @@ export default {
|
||||
douban: '豆瓣',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
music: '音乐',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
music: 'MusicBrainz',
|
||||
},
|
||||
user: {
|
||||
admin: '管理员',
|
||||
@@ -1371,8 +1370,6 @@ export default {
|
||||
anilistTrendingNow: 'AniList 当前趋势',
|
||||
anilistPopularThisSeason: 'AniList 本季热门',
|
||||
listenBrainzWeekly: '本周热门音乐',
|
||||
theAudioDbAlbums: 'TheAudioDB 热门专辑',
|
||||
theAudioDbTracks: 'TheAudioDB 热门单曲',
|
||||
doubanMusic: '豆瓣音乐推荐',
|
||||
tmdbHotMovies: 'TMDB热门电影',
|
||||
tmdbHotTVShows: 'TMDB热门电视剧',
|
||||
@@ -4170,6 +4167,23 @@ export default {
|
||||
genre: '风格',
|
||||
zone: '地区',
|
||||
year: '年代',
|
||||
music: {
|
||||
browse: '浏览',
|
||||
mode: { chart: '排行榜', tag: '分类浏览' },
|
||||
genre: {
|
||||
pop: '流行',
|
||||
rock: '摇滚',
|
||||
folk: '民谣',
|
||||
electronic: '电子',
|
||||
jazz: '爵士',
|
||||
classical: '古典',
|
||||
soundtrack: '原声',
|
||||
indie: '独立音乐',
|
||||
instrumental: '纯音乐',
|
||||
},
|
||||
region: { mainland: '内地', hongKong: '香港', taiwan: '台湾', cantonese: '粤语' },
|
||||
sort: { comprehensive: '综合排序', rating: '评分排序', date: '日期排序', markCount: '标注次数' },
|
||||
},
|
||||
sortType: {
|
||||
comprehensive: '综合排序',
|
||||
releaseDate: '首播时间',
|
||||
|
||||
+18
-4
@@ -565,8 +565,7 @@ export default {
|
||||
douban: '豆瓣',
|
||||
bangumi: 'Bangumi',
|
||||
anilist: 'AniList',
|
||||
music: '音樂',
|
||||
theaudiodb: 'TheAudioDB',
|
||||
music: 'MusicBrainz',
|
||||
},
|
||||
user: {
|
||||
admin: '管理員',
|
||||
@@ -1369,8 +1368,6 @@ export default {
|
||||
anilistTrendingNow: 'AniList 當前趨勢',
|
||||
anilistPopularThisSeason: 'AniList 本季熱門',
|
||||
listenBrainzWeekly: '本週熱門音樂',
|
||||
theAudioDbAlbums: 'TheAudioDB 熱門專輯',
|
||||
theAudioDbTracks: 'TheAudioDB 熱門單曲',
|
||||
doubanMusic: '豆瓣音樂推薦',
|
||||
tmdbHotMovies: 'TMDB熱門電影',
|
||||
tmdbHotTVShows: 'TMDB熱門電視劇',
|
||||
@@ -4166,6 +4163,23 @@ export default {
|
||||
genre: '風格',
|
||||
zone: '地區',
|
||||
year: '年代',
|
||||
music: {
|
||||
browse: '瀏覽',
|
||||
mode: { chart: '排行榜', tag: '分類瀏覽' },
|
||||
genre: {
|
||||
pop: '流行',
|
||||
rock: '搖滾',
|
||||
folk: '民謠',
|
||||
electronic: '電子',
|
||||
jazz: '爵士',
|
||||
classical: '古典',
|
||||
soundtrack: '原聲',
|
||||
indie: '獨立音樂',
|
||||
instrumental: '純音樂',
|
||||
},
|
||||
region: { mainland: '內地', hongKong: '香港', taiwan: '台灣', cantonese: '粵語' },
|
||||
sort: { comprehensive: '綜合排序', rating: '評分排序', date: '日期排序', markCount: '標註次數' },
|
||||
},
|
||||
sortType: {
|
||||
comprehensive: '綜合排序',
|
||||
releaseDate: '首播時間',
|
||||
|
||||
@@ -213,8 +213,7 @@ describe('discover page', () => {
|
||||
'TheMovieDb',
|
||||
'Bangumi',
|
||||
'AniList',
|
||||
'音乐',
|
||||
'TheAudioDB',
|
||||
'MusicBrainz',
|
||||
]),
|
||||
)
|
||||
expect(configRequested).toHaveBeenCalledOnce()
|
||||
@@ -225,7 +224,6 @@ describe('discover page', () => {
|
||||
'bangumi',
|
||||
'anilist',
|
||||
'musicbrainz',
|
||||
'theaudiodb',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -246,8 +244,7 @@ describe('discover page', () => {
|
||||
'TheMovieDb',
|
||||
'豆瓣',
|
||||
'AniList',
|
||||
'音乐',
|
||||
'TheAudioDB',
|
||||
'MusicBrainz',
|
||||
'自定义来源',
|
||||
]),
|
||||
)
|
||||
@@ -274,13 +271,7 @@ describe('discover page', () => {
|
||||
await renderDiscover()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual([
|
||||
'音乐',
|
||||
'TheMovieDb',
|
||||
'Bangumi',
|
||||
'AniList',
|
||||
'TheAudioDB',
|
||||
]),
|
||||
expect(getHeaderItems().map(item => item.title)).toEqual(['MusicBrainz', 'TheMovieDb', 'Bangumi', 'AniList']),
|
||||
)
|
||||
expect(getHeaderConfig().modelValue.value).toBe('musicbrainz')
|
||||
expect(localStorage.getItem('MP_DISCOVER_TAB_ORDER')).toBe(JSON.stringify(remoteConfig))
|
||||
@@ -301,8 +292,7 @@ describe('discover page', () => {
|
||||
'TheMovieDb',
|
||||
'豆瓣',
|
||||
'AniList',
|
||||
'音乐',
|
||||
'TheAudioDB',
|
||||
'MusicBrainz',
|
||||
]),
|
||||
)
|
||||
expect(JSON.parse(localStorage.getItem('MP_DISCOVER_TAB_ORDER') ?? 'null')).toEqual(
|
||||
@@ -324,8 +314,7 @@ describe('discover page', () => {
|
||||
'豆瓣',
|
||||
'Bangumi',
|
||||
'AniList',
|
||||
'音乐',
|
||||
'TheAudioDB',
|
||||
'MusicBrainz',
|
||||
'可用扩展源',
|
||||
])
|
||||
expect(getHeaderConfig().modelValue.value).toBe('themoviedb')
|
||||
@@ -416,8 +405,7 @@ describe('discover page', () => {
|
||||
'豆瓣',
|
||||
'Bangumi',
|
||||
'AniList',
|
||||
'音乐',
|
||||
'TheAudioDB',
|
||||
'MusicBrainz',
|
||||
'缓存来源',
|
||||
])
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ const album = {
|
||||
track_count: 12,
|
||||
},
|
||||
],
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: 'A Night at the Opera',
|
||||
total_tracks: 2,
|
||||
tracks: [
|
||||
@@ -66,7 +66,7 @@ const album = {
|
||||
track_number: 1,
|
||||
disc_number: 1,
|
||||
duration: 224,
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
{
|
||||
media_id: 'recording-2',
|
||||
@@ -74,7 +74,7 @@ const album = {
|
||||
track_number: 2,
|
||||
disc_number: 1,
|
||||
duration: 355,
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
],
|
||||
type: '音乐',
|
||||
@@ -112,7 +112,7 @@ function mockAlbumRequests(subscribed = false) {
|
||||
/** 渲染专辑详情页,统一提供超级用户权限与路由身份。 */
|
||||
function renderAlbumPage() {
|
||||
return renderWithProviders(MusicAlbumPage, {
|
||||
initialRoute: '/music/album?source=musicbrainz&mediaid=release-group-1',
|
||||
initialRoute: '/music/album?media_source=musicbrainz&mediaid=release-group-1',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: {
|
||||
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
|
||||
@@ -135,7 +135,7 @@ describe('music album page', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'A Night at the Opera' })).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
|
||||
params: { source: 'musicbrainz' },
|
||||
params: { media_source: 'musicbrainz' },
|
||||
})
|
||||
expect(screen.getByText('Bohemian Rhapsody')).toBeInTheDocument()
|
||||
expect(screen.getByText('5:55')).toBeInTheDocument()
|
||||
@@ -218,7 +218,7 @@ describe('music album page', () => {
|
||||
...album,
|
||||
artist_ids: ['1050015'],
|
||||
media_id: '1401853',
|
||||
source: 'doubanmusic',
|
||||
media_source: 'doubanmusic',
|
||||
title: '范特西',
|
||||
})
|
||||
}
|
||||
@@ -227,7 +227,7 @@ describe('music album page', () => {
|
||||
})
|
||||
|
||||
await renderWithProviders(MusicAlbumPage, {
|
||||
initialRoute: '/music/album?source=doubanmusic&mediaid=1401853',
|
||||
initialRoute: '/music/album?media_source=doubanmusic&mediaid=1401853',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: {
|
||||
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
|
||||
@@ -237,10 +237,10 @@ describe('music album page', () => {
|
||||
expect(await screen.findByRole('heading', { name: '范特西' })).toBeInTheDocument()
|
||||
const slides = screen.getAllByTestId('media-card-slide')
|
||||
expect(slides).toHaveLength(1)
|
||||
expect(slides[0]).toHaveAttribute('data-api-path', 'music/album/1401853/related?source=doubanmusic')
|
||||
expect(slides[0]).toHaveAttribute('data-api-path', 'music/album/1401853/related?media_source=doubanmusic')
|
||||
expect(slides[0]).toHaveAttribute(
|
||||
'data-link-url',
|
||||
expect.stringContaining('/browse/music/album/1401853/related?source=doubanmusic'),
|
||||
expect.stringContaining('/browse/music/album/1401853/related?media_source=doubanmusic'),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -254,7 +254,7 @@ describe('music album page', () => {
|
||||
...album,
|
||||
artist_ids: source === 'theaudiodb' ? ['artist-1'] : [],
|
||||
media_id: mediaId,
|
||||
source,
|
||||
media_source: source,
|
||||
title,
|
||||
})
|
||||
}
|
||||
@@ -267,7 +267,7 @@ describe('music album page', () => {
|
||||
})
|
||||
|
||||
const { router } = await renderWithProviders(MusicAlbumPage, {
|
||||
initialRoute: `/music/album?source=${source}&mediaid=${mediaId}`,
|
||||
initialRoute: `/music/album?media_source=${source}&mediaid=${mediaId}`,
|
||||
initialState: { user: { superUser: true } },
|
||||
global: {
|
||||
stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true },
|
||||
|
||||
@@ -39,7 +39,7 @@ const artist = {
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
name: 'Queen',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
type: '音乐',
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ const musicSite = { id: 14, is_active: true, name: '艺术家站点', url: 'http
|
||||
/** 渲染艺术家详情页,统一提供超级用户权限与路由身份。 */
|
||||
function renderArtistPage() {
|
||||
return renderWithProviders(MusicArtistPage, {
|
||||
initialRoute: '/music/artist?source=musicbrainz&mediaid=artist-1',
|
||||
initialRoute: '/music/artist?media_source=musicbrainz&mediaid=artist-1',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { NoDataFound: true, MediaCardSlideView: MediaCardSlideViewStub, MusicArtistSlideView: true } },
|
||||
})
|
||||
@@ -74,7 +74,7 @@ describe('music artist page', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Queen' })).toBeInTheDocument()
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('music/artist/artist-1', {
|
||||
params: { source: 'musicbrainz' },
|
||||
params: { media_source: 'musicbrainz' },
|
||||
})
|
||||
expect(screen.getByText('UK rock group')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('United Kingdom').length).toBeGreaterThan(0)
|
||||
|
||||
@@ -46,7 +46,7 @@ const recording = {
|
||||
media_id: 'recording-1',
|
||||
music_type: 'recording',
|
||||
release_date: '2003-07-31',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
type: '音乐',
|
||||
year: 2003,
|
||||
@@ -60,18 +60,25 @@ const album = {
|
||||
media_id: 'release-group-1',
|
||||
music_type: 'album',
|
||||
release_date: '2003-07-31',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '叶惠美',
|
||||
total_tracks: 2,
|
||||
tracks: [
|
||||
{ media_id: 'recording-1', title: '晴天', track_number: 1, disc_number: 1, duration: 269, source: 'musicbrainz' },
|
||||
{
|
||||
media_id: 'recording-1',
|
||||
title: '晴天',
|
||||
track_number: 1,
|
||||
disc_number: 1,
|
||||
duration: 269,
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
{
|
||||
media_id: 'recording-2',
|
||||
title: '以父之名',
|
||||
track_number: 2,
|
||||
disc_number: 1,
|
||||
duration: 341,
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
},
|
||||
],
|
||||
type: '音乐',
|
||||
@@ -101,7 +108,7 @@ function mockDetailRequests(subscribed = false) {
|
||||
/** 渲染音乐详情页,统一提供超级用户权限与路由身份。 */
|
||||
function renderMusicDetailPage() {
|
||||
return renderWithProviders(MusicDetailPage, {
|
||||
initialRoute: '/music/detail?source=musicbrainz&mediaid=recording-1&title=晴天',
|
||||
initialRoute: '/music/detail?media_source=musicbrainz&mediaid=recording-1&title=晴天',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
|
||||
})
|
||||
@@ -122,13 +129,13 @@ describe('music detail page', () => {
|
||||
|
||||
expect(await screen.findByRole('heading', { name: '晴天' })).toBeInTheDocument()
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('music/recognize', {
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
media_id: 'recording-1',
|
||||
music_type: 'recording',
|
||||
})
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('music/album/release-group-1', {
|
||||
params: { source: 'musicbrainz' },
|
||||
params: { media_source: 'musicbrainz' },
|
||||
}),
|
||||
)
|
||||
expect(await screen.findByText('以父之名')).toBeInTheDocument()
|
||||
@@ -188,7 +195,7 @@ describe('music detail page', () => {
|
||||
})
|
||||
|
||||
const { router } = await renderWithProviders(MusicDetailPage, {
|
||||
initialRoute: '/music/detail?source=musicbrainz&mediaid=release-group-1',
|
||||
initialRoute: '/music/detail?media_source=musicbrainz&mediaid=release-group-1',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { NoDataFound: true, MediaCardSlideView: true, MusicArtistSlideView: true } },
|
||||
})
|
||||
|
||||
@@ -43,7 +43,7 @@ const musicResult = {
|
||||
media_id: 'recording-1',
|
||||
music_type: 'recording',
|
||||
release_date: '2003-07-31',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
type: '音乐',
|
||||
year: 2003,
|
||||
@@ -60,7 +60,7 @@ const albumResult = {
|
||||
media_id: 'release-group-2',
|
||||
music_type: 'album',
|
||||
release_date: '2004-08-03',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '七里香',
|
||||
type: '音乐',
|
||||
year: 2004,
|
||||
@@ -70,7 +70,7 @@ const artistResult = {
|
||||
category: 'Person',
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '周杰伦',
|
||||
type: '音乐',
|
||||
version: 'Taiwanese singer-songwriter',
|
||||
@@ -127,14 +127,14 @@ describe('music page', () => {
|
||||
|
||||
it('forwards an alternate music source from the route', async () => {
|
||||
await renderWithProviders(MusicPage, {
|
||||
initialRoute: '/music?query=Coldplay&source=theaudiodb',
|
||||
initialRoute: '/music?query=Coldplay&media_source=theaudiodb',
|
||||
initialState: { user: { superUser: true } },
|
||||
global: { stubs: { NoDataFound: true, VPageContentTitle: true } },
|
||||
})
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/search', {
|
||||
params: { type: 'music', count: 30, title: 'Coldplay', source: 'theaudiodb' },
|
||||
params: { type: 'music', count: 30, title: 'Coldplay', media_source: 'theaudiodb' },
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -206,7 +206,7 @@ describe('music page', () => {
|
||||
|
||||
await waitFor(() => expect(router.currentRoute.value.path).toBe('/music/detail'))
|
||||
expect(router.currentRoute.value.query).toMatchObject({
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
mediaid: 'recording-1',
|
||||
title: '晴天',
|
||||
})
|
||||
@@ -291,12 +291,13 @@ describe('music page', () => {
|
||||
album: title,
|
||||
album_id: mediaId,
|
||||
artist_ids: source === 'theaudiodb' ? ['artist-1'] : [],
|
||||
duration: 0,
|
||||
media_id: mediaId,
|
||||
source,
|
||||
media_source: source,
|
||||
title,
|
||||
}
|
||||
mockSearchAndSubscribeState(false, result)
|
||||
const { router } = await renderMusicPage(`/music?query=${encodeURIComponent(title)}&source=${source}`)
|
||||
const { router } = await renderMusicPage(`/music?query=${encodeURIComponent(title)}&media_source=${source}`)
|
||||
|
||||
expect(await screen.findByTestId('music-source')).toHaveTextContent(label)
|
||||
await fireEvent.click(screen.getByRole('button', { name: '订阅' }))
|
||||
|
||||
@@ -139,12 +139,12 @@ describe('recommend page', () => {
|
||||
await renderRecommend()
|
||||
|
||||
expect(await screen.findByText('自定义来源')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('recommend-view')).toHaveLength(8)
|
||||
expect(screen.getAllByTestId('recommend-view')).toHaveLength(6)
|
||||
expect(screen.getByText('AniList 当前趋势')).toBeInTheDocument()
|
||||
expect(screen.getByText('AniList 本季热门')).toBeInTheDocument()
|
||||
expect(screen.getByText('本周热门音乐')).toBeInTheDocument()
|
||||
expect(screen.getByText('TheAudioDB 热门专辑')).toBeInTheDocument()
|
||||
expect(screen.getByText('TheAudioDB 热门单曲')).toBeInTheDocument()
|
||||
expect(screen.queryByText('TheAudioDB 热门专辑')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('TheAudioDB 热门单曲')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('豆瓣音乐推荐')).toBeInTheDocument()
|
||||
expect(screen.queryByText('重复来源')).not.toBeInTheDocument()
|
||||
expect(remoteConfigRequests).toBe(0)
|
||||
|
||||
@@ -330,12 +330,7 @@ onActivated(async () => {
|
||||
</VWindowItem>
|
||||
<VWindowItem value="musicbrainz">
|
||||
<div>
|
||||
<MusicView source="musicbrainz" />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem value="theaudiodb">
|
||||
<div>
|
||||
<MusicView source="theaudiodb" />
|
||||
<MusicView />
|
||||
</div>
|
||||
</VWindowItem>
|
||||
<VWindowItem v-for="item in extraDiscoverSources" :key="item.mediaid_prefix" :value="item.mediaid_prefix">
|
||||
|
||||
@@ -8,11 +8,11 @@ const route = useRoute()
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
|
||||
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MusicAlbumView :mediaid="mediaid" :source="source" />
|
||||
<MusicAlbumView :mediaid="mediaid" :media-source="mediaSource" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,11 +8,11 @@ const route = useRoute()
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
|
||||
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MusicArtistView :mediaid="mediaid" :source="source" />
|
||||
<MusicArtistView :mediaid="mediaid" :media-source="mediaSource" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -8,11 +8,11 @@ const route = useRoute()
|
||||
const mediaid = computed(() => route.query?.mediaid?.toString())
|
||||
|
||||
// 音乐元数据来源
|
||||
const source = computed(() => route.query?.source?.toString() || 'musicbrainz')
|
||||
const mediaSource = computed(() => route.query?.media_source?.toString() || 'musicbrainz')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<MusicDetailView :mediaid="mediaid" :source="source" />
|
||||
<MusicDetailView :mediaid="mediaid" :media-source="mediaSource" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+3
-3
@@ -14,7 +14,7 @@ const results = ref<MediaInfo[]>([])
|
||||
|
||||
// 搜索入口统一在全局搜索,本页只消费路由关键词
|
||||
const query = computed(() => route.query.query?.toString().trim() || '')
|
||||
const source = computed(() => route.query.source?.toString().trim() || '')
|
||||
const mediaSource = computed(() => route.query.media_source?.toString().trim() || '')
|
||||
|
||||
/** 调用统一音乐元数据接口搜索候选。 */
|
||||
async function searchMusic() {
|
||||
@@ -32,7 +32,7 @@ async function searchMusic() {
|
||||
type: 'music',
|
||||
count: 30,
|
||||
}
|
||||
if (source.value) params.source = source.value
|
||||
if (mediaSource.value) params.media_source = mediaSource.value
|
||||
results.value = (await api.get('media/search', { params })) || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
@@ -42,7 +42,7 @@ async function searchMusic() {
|
||||
}
|
||||
}
|
||||
|
||||
watch([query, source], searchMusic, { immediate: true })
|
||||
watch([query, mediaSource], searchMusic, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -76,8 +76,6 @@ const newlyAddedBuiltInPaths = new Set([
|
||||
'anilist/trending',
|
||||
'anilist/popular-this-season',
|
||||
'recommend/music_weekly',
|
||||
'recommend/music_theaudiodb_albums',
|
||||
'recommend/music_theaudiodb_tracks',
|
||||
'recommend/music_douban',
|
||||
])
|
||||
|
||||
|
||||
@@ -346,11 +346,6 @@ export function getDiscoverTabs(t: Composer['t']): NavMenuTabItem[] {
|
||||
tab: 'musicbrainz',
|
||||
icon: 'mdi-music-note-outline',
|
||||
},
|
||||
{
|
||||
title: t('discoverTabs.theaudiodb'),
|
||||
tab: 'theaudiodb',
|
||||
icon: 'mdi-music-box-multiple-outline',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -12,37 +12,37 @@ 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({
|
||||
expect(buildMusicDetailRoute({ media_source: 'musicbrainz', media_id: 'recording-1', title: '晴天' })).toEqual({
|
||||
path: '/music/detail',
|
||||
query: { source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
|
||||
query: { media_source: 'musicbrainz', mediaid: 'recording-1', title: '晴天' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an album entity to the album page', () => {
|
||||
expect(
|
||||
buildMusicDetailRoute({
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
media_id: 'release-group-1',
|
||||
music_type: 'album',
|
||||
title: '叶惠美',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/music/album',
|
||||
query: { source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
|
||||
query: { media_source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
|
||||
})
|
||||
})
|
||||
|
||||
it('routes an artist entity to the artist page', () => {
|
||||
expect(
|
||||
buildMusicDetailRoute({
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
name: 'Queen',
|
||||
}),
|
||||
).toEqual({
|
||||
path: '/music/artist',
|
||||
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
query: { media_source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -56,11 +56,11 @@ describe('music utils', () => {
|
||||
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: '叶惠美' },
|
||||
query: { media_source: 'musicbrainz', mediaid: 'release-group-1', title: '叶惠美' },
|
||||
})
|
||||
expect(buildMusicArtistRoute('artist-1', 'Queen')).toEqual({
|
||||
path: '/music/artist',
|
||||
query: { source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
query: { media_source: 'musicbrainz', mediaid: 'artist-1', title: 'Queen' },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('music utils', () => {
|
||||
expect(
|
||||
buildMusicResourceRoute(
|
||||
{
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
media_id: 'recording-1',
|
||||
title: '晴天',
|
||||
year: '2003',
|
||||
@@ -89,7 +89,7 @@ describe('music utils', () => {
|
||||
it('does not build a resource route for an artist browsing entity', () => {
|
||||
expect(
|
||||
buildMusicResourceRoute({
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
media_id: 'artist-1',
|
||||
music_type: 'artist',
|
||||
title: 'Queen',
|
||||
@@ -98,8 +98,8 @@ describe('music utils', () => {
|
||||
})
|
||||
|
||||
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' })
|
||||
const track = getMusicKey({ media_source: 'musicbrainz', media_id: 'same-id' })
|
||||
const album = getMusicKey({ media_source: 'musicbrainz', media_id: 'same-id', music_type: 'album' })
|
||||
|
||||
expect(track).not.toEqual(album)
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ describe('recommendSources', () => {
|
||||
it('creates the complete built-in source contract', () => {
|
||||
const sources = createBuiltInRecommendSources(translate)
|
||||
|
||||
expect(sources).toHaveLength(19)
|
||||
expect(sources).toHaveLength(17)
|
||||
expect(sources[0]).toEqual({
|
||||
apipath: 'recommend/tmdb_trending',
|
||||
linkurl: '/browse/recommend/tmdb_trending?title=translated:recommend.trendingNow',
|
||||
@@ -68,12 +68,7 @@ describe('recommendSources', () => {
|
||||
})
|
||||
expect(
|
||||
sources.filter(source => source.type === 'translated:recommend.categoryMusic').map(source => source.apipath),
|
||||
).toEqual([
|
||||
'recommend/music_weekly',
|
||||
'recommend/music_theaudiodb_albums',
|
||||
'recommend/music_theaudiodb_tracks',
|
||||
'recommend/music_douban',
|
||||
])
|
||||
).toEqual(['recommend/music_weekly', 'recommend/music_douban'])
|
||||
expect(
|
||||
sources.filter(source => source.type === 'translated:recommend.categoryAnime').map(source => source.apipath),
|
||||
).toEqual([
|
||||
|
||||
+6
-7
@@ -2,7 +2,6 @@ import type { MediaInfo, MusicAlbumInfo, MusicArtistInfo, MusicEntityType } from
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export interface MusicRouteTarget {
|
||||
source?: string
|
||||
media_source?: string
|
||||
media_id?: string | number
|
||||
music_type?: MusicEntityType
|
||||
@@ -59,7 +58,7 @@ export function formatMusicAudioSpecs(item?: MusicAudioInfo): string {
|
||||
|
||||
/** 返回音乐对象可用于路由和订阅的统一来源。 */
|
||||
export function getMusicSource(item: MusicRouteTarget): string | undefined {
|
||||
return item.source || item.media_source
|
||||
return item.media_source
|
||||
}
|
||||
|
||||
/** 返回内置音乐元数据源的用户可见名称。 */
|
||||
@@ -80,13 +79,13 @@ export function getMusicKey(item: MusicRouteTarget): string {
|
||||
}
|
||||
|
||||
/** 构造专辑详情路由。 */
|
||||
export function buildMusicAlbumRoute(albumId: string, title?: string, source = 'musicbrainz'): RouteLocationRaw {
|
||||
return { path: '/music/album', query: { source, mediaid: albumId, title } }
|
||||
export function buildMusicAlbumRoute(albumId: string, title?: string, mediaSource = 'musicbrainz'): RouteLocationRaw {
|
||||
return { path: '/music/album', query: { media_source: mediaSource, 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 buildMusicArtistRoute(artistId: string, name?: string, mediaSource = 'musicbrainz'): RouteLocationRaw {
|
||||
return { path: '/music/artist', query: { media_source: mediaSource, mediaid: artistId, title: name } }
|
||||
}
|
||||
|
||||
/** 按音乐实体类型构造详情路由,缺少标准身份时回退到音乐搜索页。 */
|
||||
@@ -101,7 +100,7 @@ export function buildMusicDetailRoute(item: MusicRouteTarget): RouteLocationRaw
|
||||
return {
|
||||
path: '/music/detail',
|
||||
query: {
|
||||
source,
|
||||
media_source: source,
|
||||
mediaid: mediaId,
|
||||
title: item.title || item.name,
|
||||
},
|
||||
|
||||
@@ -48,18 +48,6 @@ export function createBuiltInRecommendSources(t: Translate): RecommendViewSource
|
||||
title: t('recommend.listenBrainzWeekly'),
|
||||
type: t('recommend.categoryMusic'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/music_theaudiodb_albums',
|
||||
linkurl: '/browse/recommend/music_theaudiodb_albums?title=' + t('recommend.theAudioDbAlbums'),
|
||||
title: t('recommend.theAudioDbAlbums'),
|
||||
type: t('recommend.categoryMusic'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/music_theaudiodb_tracks',
|
||||
linkurl: '/browse/recommend/music_theaudiodb_tracks?title=' + t('recommend.theAudioDbTracks'),
|
||||
title: t('recommend.theAudioDbTracks'),
|
||||
type: t('recommend.categoryMusic'),
|
||||
},
|
||||
{
|
||||
apipath: 'recommend/music_douban',
|
||||
linkurl: '/browse/recommend/music_douban?title=' + t('recommend.doubanMusic'),
|
||||
|
||||
@@ -22,6 +22,10 @@ const doubanZone = ref('')
|
||||
// 年代
|
||||
const doubanYear = ref('')
|
||||
const coverFilter = ref<'all' | 'with_cover'>('all')
|
||||
const musicMode = ref<'chart' | 'tag'>('chart')
|
||||
const musicCategory = ref('流行')
|
||||
const musicZone = ref('')
|
||||
const musicSort = ref<'U' | 'S' | 'R' | 'O'>('U')
|
||||
const isMusic = computed(() => type.value === 'music')
|
||||
|
||||
// 豆瓣风格字典
|
||||
@@ -101,12 +105,50 @@ const doubanSortDict = {
|
||||
'S': t('douban.sortType.highScore'),
|
||||
}
|
||||
|
||||
// 豆瓣音乐官网标签页中使用频率最高的风格与地区标签。
|
||||
const musicCategoryDict = {
|
||||
'流行': t('douban.music.genre.pop'),
|
||||
'摇滚': t('douban.music.genre.rock'),
|
||||
'民谣': t('douban.music.genre.folk'),
|
||||
'电子': t('douban.music.genre.electronic'),
|
||||
'爵士': t('douban.music.genre.jazz'),
|
||||
'古典': t('douban.music.genre.classical'),
|
||||
'原声': t('douban.music.genre.soundtrack'),
|
||||
'独立音乐': t('douban.music.genre.indie'),
|
||||
'纯音乐': t('douban.music.genre.instrumental'),
|
||||
'R&B': 'R&B',
|
||||
'hip-hop': 'Hip-Hop',
|
||||
}
|
||||
|
||||
const musicZoneDict = {
|
||||
'华语': t('douban.zoneType.chinese'),
|
||||
'欧美': t('douban.zoneType.europeanAmerican'),
|
||||
'日本': t('douban.zoneType.japanese'),
|
||||
'韩国': t('douban.zoneType.korean'),
|
||||
'内地': t('douban.music.region.mainland'),
|
||||
'香港': t('douban.music.region.hongKong'),
|
||||
'台湾': t('douban.music.region.taiwan'),
|
||||
'美国': t('douban.zoneType.usa'),
|
||||
'英国': t('douban.zoneType.uk'),
|
||||
'粤语': t('douban.music.region.cantonese'),
|
||||
}
|
||||
|
||||
const musicSortDict = {
|
||||
'U': t('douban.music.sort.comprehensive'),
|
||||
'S': t('douban.music.sort.rating'),
|
||||
'R': t('douban.music.sort.date'),
|
||||
'O': t('douban.music.sort.markCount'),
|
||||
}
|
||||
|
||||
const listApiPath = computed(() => (isMusic.value ? 'music/explore' : `discover/douban_${type.value}`))
|
||||
const listParams = computed<Record<string, unknown>>(() => {
|
||||
if (isMusic.value) {
|
||||
return {
|
||||
count: 30,
|
||||
source: 'doubanmusic',
|
||||
media_source: 'doubanmusic',
|
||||
mode: musicMode.value,
|
||||
tags: musicMode.value === 'tag' ? [musicCategory.value, musicZone.value].filter(Boolean).join(',') : '',
|
||||
douban_sort: musicSort.value,
|
||||
with_cover: coverFilter.value === 'with_cover',
|
||||
}
|
||||
}
|
||||
@@ -122,7 +164,7 @@ watch([doubanCategory, doubanZone, doubanYear], () => {
|
||||
const currentKey = ref(0)
|
||||
|
||||
// 类型和过滤参数变化后重新刷新列表
|
||||
watch([type, filterParams, coverFilter], () => {
|
||||
watch([type, filterParams, coverFilter, musicMode, musicCategory, musicZone, musicSort], () => {
|
||||
if (!type.value) {
|
||||
type.value = 'movies'
|
||||
}
|
||||
@@ -215,15 +257,56 @@ watch([type, filterParams, coverFilter], () => {
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div v-else class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('music.filter.cover') }}</VLabel>
|
||||
<template v-else>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('douban.music.browse') }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="musicMode" mandatory>
|
||||
<VChip value="chart" filter tile>{{ t('douban.music.mode.chart') }}</VChip>
|
||||
<VChip value="tag" filter tile>{{ t('douban.music.mode.tag') }}</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<VChipGroup v-model="coverFilter" mandatory>
|
||||
<VChip value="all" filter tile>{{ t('music.filter.all') }}</VChip>
|
||||
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div v-if="musicMode === 'tag'" class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('douban.genre') }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="musicCategory" mandatory>
|
||||
<VChip v-for="(value, key) in musicCategoryDict" :key="key" :value="key" filter tile>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div v-if="musicMode === 'tag'" class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('douban.zone') }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="musicZone">
|
||||
<VChip v-for="(value, key) in musicZoneDict" :key="key" :value="key" filter tile>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div v-if="musicMode === 'tag'" class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('douban.sort') }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="musicSort" mandatory>
|
||||
<VChip v-for="(value, key) in musicSortDict" :key="key" :value="key" filter tile>
|
||||
{{ value }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="flex justify-start align-center">
|
||||
<div class="mr-5">
|
||||
<VLabel>{{ t('music.filter.cover') }}</VLabel>
|
||||
</div>
|
||||
<VChipGroup v-model="coverFilter" mandatory>
|
||||
<VChip value="all" filter tile>{{ t('music.filter.all') }}</VChip>
|
||||
<VChip value="with_cover" filter tile>{{ t('music.filter.withCover') }}</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div>
|
||||
<MediaCardListView :key="currentKey" :apipath="listApiPath" :params="listParams" />
|
||||
|
||||
@@ -50,24 +50,22 @@ function getParams() {
|
||||
}
|
||||
}
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
'source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
'imdb_id',
|
||||
'tvdb_id',
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'anilist_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
|
||||
// 去重、分页终止和渲染必须共用同一媒体身份,避免状态与 DOM key 分叉。
|
||||
function getMediaIdentity(item: MediaInfo) {
|
||||
return JSON.stringify(dedupFields.map(field => item[field] ?? null))
|
||||
if (item.media_source && item.media_id !== undefined && item.media_id !== null && String(item.media_id).trim()) {
|
||||
return JSON.stringify(['media', item.media_source, String(item.media_id), item.type ?? null, item.season ?? null])
|
||||
}
|
||||
|
||||
// 数据源偶发返回无 ID 条目时仍按可见元数据区分,不能把整页折叠成一条。
|
||||
return JSON.stringify([
|
||||
'metadata',
|
||||
item.type ?? null,
|
||||
item.season ?? null,
|
||||
item.title ?? null,
|
||||
item.original_title ?? null,
|
||||
item.year ?? null,
|
||||
item.release_date ?? null,
|
||||
])
|
||||
}
|
||||
|
||||
function deduplicate(items: MediaInfo[]): MediaInfo[] {
|
||||
|
||||
@@ -25,7 +25,7 @@ const props = defineProps({
|
||||
// 音乐数据源原生专辑 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
mediaSource: {
|
||||
type: String,
|
||||
default: 'musicbrainz',
|
||||
},
|
||||
@@ -44,11 +44,11 @@ const isSubscribed = ref(false)
|
||||
const artistLinks = computed(() => getMusicArtistLinks(album.value))
|
||||
|
||||
// 关联浏览统一以首个艺术家为入口
|
||||
const supportsArtistBrowsing = computed(() => ['musicbrainz', 'theaudiodb'].includes(props.source))
|
||||
const supportsArtistBrowsing = computed(() => ['musicbrainz', 'theaudiodb'].includes(props.mediaSource))
|
||||
const primaryArtistId = computed(() =>
|
||||
supportsArtistBrowsing.value ? artistLinks.value.find(artist => artist.id)?.id : undefined,
|
||||
)
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
|
||||
|
||||
// 专辑订阅复用影视订阅链,年份需要按订阅表的字符串格式传递
|
||||
const albumMedia = computed<MediaInfo | undefined>(() => {
|
||||
@@ -87,10 +87,10 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
|
||||
|
||||
/** 加载专辑详情、曲目列表和发行版本。 */
|
||||
async function loadAlbumDetail() {
|
||||
if (!props.source || !props.mediaid) return
|
||||
if (!props.mediaSource || !props.mediaid) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
album.value = await api.get(`music/album/${props.mediaid}`, { params: { source: props.source } })
|
||||
album.value = await api.get(`music/album/${props.mediaid}`, { params: { media_source: props.mediaSource } })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
album.value = undefined
|
||||
@@ -113,7 +113,7 @@ async function checkSubscribeStatus() {
|
||||
/** 打开艺术家详情页。 */
|
||||
function goArtist(artistId?: string, name?: string) {
|
||||
if (!artistId) return
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.source))
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource))
|
||||
}
|
||||
|
||||
/** 返回发行版本的介质与地区说明。 */
|
||||
@@ -121,7 +121,7 @@ function getReleaseSubtitle(release: NonNullable<MusicAlbumInfo['releases']>[num
|
||||
return [release.formats?.join(' + '), release.country, release.packaging, release.status].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
watch(() => [props.mediaSource, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -233,20 +233,23 @@ watch(() => [props.source, props.mediaid], loadAlbumDetail, { immediate: true })
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:title="t('music.artistAlbums')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryArtistId && props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
|
||||
<div v-if="primaryArtistId && props.mediaSource === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/related?media_source=musicbrainz`"
|
||||
:title="t('music.relatedArtists')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="props.source === 'doubanmusic'" class="music-section">
|
||||
<div v-if="props.mediaSource === 'doubanmusic'" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/album/${props.mediaid}/related?source=doubanmusic`"
|
||||
:linkurl="`/browse/music/album/${props.mediaid}/related?source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
|
||||
:apipath="`music/album/${props.mediaid}/related?media_source=doubanmusic`"
|
||||
:linkurl="`/browse/music/album/${props.mediaid}/related?media_source=doubanmusic&title=${encodeURIComponent(t('music.relatedAlbums'))}`"
|
||||
:title="t('music.relatedAlbums')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@ const props = defineProps({
|
||||
// 音乐数据源原生艺术家 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
mediaSource: {
|
||||
type: String,
|
||||
default: 'musicbrainz',
|
||||
},
|
||||
@@ -28,7 +28,7 @@ const canSearch = computed(() => hasPermission(userPermissions.value, 'search'))
|
||||
|
||||
const isRefreshed = ref(false)
|
||||
const artist = ref<MusicArtistInfo>()
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
|
||||
|
||||
const { openMusicSiteSearch } = useMusicSiteSearch(sites => {
|
||||
if (!artist.value?.name) return undefined
|
||||
@@ -63,10 +63,10 @@ const attributes = computed(() => {
|
||||
|
||||
/** 加载艺术家详情。 */
|
||||
async function loadArtistDetail() {
|
||||
if (!props.source || !props.mediaid) return
|
||||
if (!props.mediaSource || !props.mediaid) return
|
||||
isRefreshed.value = false
|
||||
try {
|
||||
artist.value = await api.get(`music/artist/${props.mediaid}`, { params: { source: props.source } })
|
||||
artist.value = await api.get(`music/artist/${props.mediaid}`, { params: { media_source: props.mediaSource } })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
artist.value = undefined
|
||||
@@ -77,10 +77,10 @@ async function loadArtistDetail() {
|
||||
|
||||
/** 返回指定专辑类型的浏览列表路由。 */
|
||||
function getAlbumsBrowseRoute(albumType: string, title: string) {
|
||||
return `/browse/music/artist/${props.mediaid}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(title)}&album_type=${albumType}`
|
||||
return `/browse/music/artist/${props.mediaid}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(title)}&album_type=${albumType}`
|
||||
}
|
||||
|
||||
watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true })
|
||||
watch(() => [props.mediaSource, props.mediaid], loadArtistDetail, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -163,14 +163,17 @@ watch(() => [props.source, props.mediaid], loadArtistDetail, { immediate: true }
|
||||
|
||||
<div v-for="section in albumSections" :key="section.type" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${props.mediaid}/albums?source=${encodeURIComponent(props.source)}&album_type=${section.type}`"
|
||||
:apipath="`music/artist/${props.mediaid}/albums?media_source=${encodeURIComponent(props.mediaSource)}&album_type=${section.type}`"
|
||||
:linkurl="getAlbumsBrowseRoute(section.type, section.title)"
|
||||
:title="section.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${props.mediaid}/related`" :title="t('music.relatedArtists')" />
|
||||
<div v-if="props.mediaSource === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView
|
||||
:apipath="`music/artist/${props.mediaid}/related?media_source=musicbrainz`"
|
||||
:title="t('music.relatedArtists')"
|
||||
/>
|
||||
</div>
|
||||
</MusicDetailLayout>
|
||||
<NoDataFound
|
||||
|
||||
@@ -26,7 +26,7 @@ const props = defineProps({
|
||||
// 音乐数据源原生单曲 ID
|
||||
mediaid: String,
|
||||
// 音乐元数据来源
|
||||
source: {
|
||||
mediaSource: {
|
||||
type: String,
|
||||
default: 'musicbrainz',
|
||||
},
|
||||
@@ -61,7 +61,7 @@ const attributes = computed(() => {
|
||||
|
||||
// 专辑内除当前单曲外仍然展示完整曲目,方便对照曲序
|
||||
const albumTracks = computed(() => album.value?.tracks ?? [])
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.source, t))
|
||||
const sourceLabel = computed(() => getMusicSourceLabel(props.mediaSource, t))
|
||||
|
||||
function getSubscribeStatusKey() {
|
||||
return `${getMediaSubscribeId(music.value)}::all`
|
||||
@@ -80,12 +80,12 @@ const { openMusicSiteSearch } = useMusicSiteSearch(sites =>
|
||||
|
||||
/** 加载单曲详情,并按所属专辑补全曲目列表。 */
|
||||
async function loadMusicDetail() {
|
||||
if (!props.source || !props.mediaid) return
|
||||
if (!props.mediaSource || !props.mediaid) return
|
||||
isRefreshed.value = false
|
||||
album.value = undefined
|
||||
try {
|
||||
music.value = await api.post('music/recognize', {
|
||||
source: props.source,
|
||||
media_source: props.mediaSource,
|
||||
media_id: props.mediaid,
|
||||
music_type: 'recording',
|
||||
})
|
||||
@@ -97,7 +97,7 @@ async function loadMusicDetail() {
|
||||
}
|
||||
// 订阅只保存来源和 ID,专辑订阅同样会进入本页,识别为专辑后改由专辑详情页承载
|
||||
if (music.value?.music_type === 'album' && music.value.media_id) {
|
||||
router.replace(buildMusicAlbumRoute(music.value.media_id, music.value.title, props.source))
|
||||
router.replace(buildMusicAlbumRoute(music.value.media_id, music.value.title, props.mediaSource))
|
||||
return
|
||||
}
|
||||
await Promise.all([loadAlbum(), checkSubscribeStatus()])
|
||||
@@ -108,7 +108,7 @@ async function loadAlbum() {
|
||||
const albumId = music.value?.album_id
|
||||
if (!albumId) return
|
||||
try {
|
||||
album.value = await api.get(`music/album/${albumId}`, { params: { source: props.source } })
|
||||
album.value = await api.get(`music/album/${albumId}`, { params: { media_source: props.mediaSource } })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
@@ -127,16 +127,16 @@ async function checkSubscribeStatus() {
|
||||
/** 打开所属专辑详情页。 */
|
||||
function goAlbum() {
|
||||
if (!music.value?.album_id) return
|
||||
router.push(buildMusicAlbumRoute(music.value.album_id, music.value.album, props.source))
|
||||
router.push(buildMusicAlbumRoute(music.value.album_id, music.value.album, props.mediaSource))
|
||||
}
|
||||
|
||||
/** 打开艺术家详情页。 */
|
||||
function goArtist(artistId?: string, name?: string) {
|
||||
if (!artistId) return
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.source))
|
||||
router.push(buildMusicArtistRoute(artistId, name, props.mediaSource))
|
||||
}
|
||||
|
||||
watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
watch(() => [props.mediaSource, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -240,14 +240,17 @@ watch(() => [props.source, props.mediaid], loadMusicDetail, { immediate: true })
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="primaryArtistId && props.source === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView :apipath="`music/artist/${primaryArtistId}/related`" :title="t('music.relatedArtists')" />
|
||||
<div v-if="primaryArtistId && props.mediaSource === 'musicbrainz'" class="music-section">
|
||||
<MusicArtistSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/related?media_source=musicbrainz`"
|
||||
:title="t('music.relatedArtists')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="primaryArtistId" class="music-section">
|
||||
<MediaCardSlideView
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?source=${encodeURIComponent(props.source)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:apipath="`music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}`"
|
||||
:linkurl="`/browse/music/artist/${primaryArtistId}/albums?media_source=${encodeURIComponent(props.mediaSource)}&title=${encodeURIComponent(t('music.artistAlbums'))}`"
|
||||
:title="t('music.artistAlbums')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -4,20 +4,6 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
type MusicExploreSource = 'musicbrainz' | 'theaudiodb'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
source?: MusicExploreSource
|
||||
}>(),
|
||||
{
|
||||
source: 'musicbrainz',
|
||||
},
|
||||
)
|
||||
|
||||
const isMusicBrainz = computed(() => props.source === 'musicbrainz')
|
||||
const isTheAudioDb = computed(() => props.source === 'theaudiodb')
|
||||
|
||||
// 探索模式:对齐 ListenBrainz 官方的热门统计与新发行两个入口
|
||||
const mode = ref<'chart' | 'fresh'>('chart')
|
||||
|
||||
@@ -40,7 +26,6 @@ const freshDays = ref(14)
|
||||
const freshScope = ref<'all' | 'past' | 'future'>('all')
|
||||
|
||||
const coverFilter = ref('all')
|
||||
const country = ref('us')
|
||||
const currentKey = ref(0)
|
||||
|
||||
const modeOptions = computed(() => ({
|
||||
@@ -90,25 +75,12 @@ const freshScopeOptions = computed(() => ({
|
||||
future: t('music.filter.upcoming'),
|
||||
}))
|
||||
|
||||
const countryOptions = computed(() => [
|
||||
{ title: t('music.filter.countryUs'), value: 'us' },
|
||||
{ title: t('music.filter.countryGb'), value: 'gb' },
|
||||
{ title: t('music.filter.countryCn'), value: 'cn' },
|
||||
{ title: t('music.filter.countryJp'), value: 'jp' },
|
||||
{ title: t('music.filter.countryKr'), value: 'kr' },
|
||||
])
|
||||
|
||||
const filterParams = computed(() => {
|
||||
const params: Record<string, unknown> = {
|
||||
count: 30,
|
||||
source: props.source,
|
||||
media_source: 'musicbrainz',
|
||||
with_cover: coverFilter.value === 'with_cover',
|
||||
}
|
||||
if (isTheAudioDb.value) {
|
||||
params.entity = entity.value
|
||||
params.country = country.value
|
||||
return params
|
||||
}
|
||||
params.mode = mode.value
|
||||
if (mode.value === 'fresh') {
|
||||
params.sort = freshSort.value
|
||||
@@ -123,17 +95,14 @@ const filterParams = computed(() => {
|
||||
return params
|
||||
})
|
||||
|
||||
watch(
|
||||
[() => props.source, mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter, country],
|
||||
() => {
|
||||
currentKey.value++
|
||||
},
|
||||
)
|
||||
watch([mode, entity, rangeName, sortBy, freshSort, freshDays, freshScope, coverFilter], () => {
|
||||
currentKey.value++
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-3 music-explore-filters">
|
||||
<div v-if="isMusicBrainz" class="music-filter-row">
|
||||
<div class="music-filter-row">
|
||||
<VLabel class="music-filter-label">{{ t('music.filter.mode') }}</VLabel>
|
||||
<VChipGroup v-model="mode" mandatory class="music-filter-chips">
|
||||
<VChip v-for="(label, value) in modeOptions" :key="value" :value="value" filter tile>
|
||||
@@ -142,7 +111,7 @@ watch(
|
||||
</VChipGroup>
|
||||
</div>
|
||||
|
||||
<template v-if="isMusicBrainz && mode === 'chart'">
|
||||
<template v-if="mode === 'chart'">
|
||||
<div class="music-filter-row">
|
||||
<VLabel class="music-filter-label">{{ t('music.filter.entity') }}</VLabel>
|
||||
<VChipGroup v-model="entity" mandatory class="music-filter-chips">
|
||||
@@ -169,7 +138,7 @@ watch(
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isMusicBrainz">
|
||||
<template v-else>
|
||||
<div class="music-filter-row">
|
||||
<VLabel class="music-filter-label">{{ t('music.filter.sort') }}</VLabel>
|
||||
<VChipGroup v-model="freshSort" mandatory class="music-filter-chips">
|
||||
@@ -197,28 +166,6 @@ watch(
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="isTheAudioDb">
|
||||
<div class="music-filter-row">
|
||||
<VLabel class="music-filter-label">{{ t('music.filter.entity') }}</VLabel>
|
||||
<VChipGroup v-model="entity" mandatory class="music-filter-chips">
|
||||
<VChip v-for="(label, value) in entityOptions" :key="value" :value="value" filter tile>
|
||||
{{ label }}
|
||||
</VChip>
|
||||
</VChipGroup>
|
||||
</div>
|
||||
<div class="music-filter-row">
|
||||
<VSelect
|
||||
v-model="country"
|
||||
:items="countryOptions"
|
||||
:label="t('music.filter.country')"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="music-country-filter"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="music-filter-row">
|
||||
<VLabel class="music-filter-label">{{ t('music.filter.cover') }}</VLabel>
|
||||
<VChipGroup v-model="coverFilter" mandatory class="music-filter-chips">
|
||||
@@ -263,8 +210,4 @@ watch(
|
||||
flex: 0 0 auto;
|
||||
max-inline-size: 10rem;
|
||||
}
|
||||
|
||||
.music-country-filter {
|
||||
max-inline-size: 14rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('DoubanView', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('merges music into the Douban source and hides unsupported video filters', async () => {
|
||||
it('merges music into Douban with chart and official tag browsing filters', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderView()
|
||||
|
||||
@@ -115,7 +115,10 @@ describe('DoubanView', () => {
|
||||
apipath: 'music/explore',
|
||||
params: {
|
||||
count: 30,
|
||||
source: 'doubanmusic',
|
||||
douban_sort: 'U',
|
||||
media_source: 'doubanmusic',
|
||||
mode: 'chart',
|
||||
tags: '',
|
||||
with_cover: false,
|
||||
},
|
||||
})
|
||||
@@ -123,6 +126,21 @@ describe('DoubanView', () => {
|
||||
expect(screen.queryByText('高分优先')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('2020年代')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('分类浏览'))
|
||||
await user.click(screen.getByText('华语'))
|
||||
await user.click(screen.getByText('评分排序'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(latestMediaListRequest(mediaList).params).toEqual({
|
||||
count: 30,
|
||||
douban_sort: 'S',
|
||||
media_source: 'doubanmusic',
|
||||
mode: 'tag',
|
||||
tags: '流行,华语',
|
||||
with_cover: false,
|
||||
})
|
||||
})
|
||||
|
||||
await user.click(screen.getByText('仅有封面'))
|
||||
await waitFor(() => expect(latestMediaListRequest(mediaList).params.with_cover).toBe(true))
|
||||
})
|
||||
|
||||
@@ -227,33 +227,19 @@ describe('MediaCardListView', () => {
|
||||
expect(requestedPages).toEqual(['1', '2'])
|
||||
})
|
||||
|
||||
it('deduplicates the complete composite identity and uses it for stable render keys', async () => {
|
||||
it('deduplicates the unified identity and uses it for stable render keys', async () => {
|
||||
setScrollHeight(() => 900)
|
||||
const base = createMediaInfo({
|
||||
anilist_id: 4200,
|
||||
bangumi_id: 'bangumi-42',
|
||||
douban_id: 'douban-42',
|
||||
imdb_id: 'tt0000042',
|
||||
media_id: 'media-42',
|
||||
mediaid_prefix: 'fixture',
|
||||
media_source: 'themoviedb',
|
||||
season: 1,
|
||||
source: 'themoviedb',
|
||||
title: '复合标识基准',
|
||||
tmdb_id: 42,
|
||||
tvdb_id: 'tvdb-42',
|
||||
title: '统一标识基准',
|
||||
type: '电视剧',
|
||||
})
|
||||
const variants: Array<Partial<MediaInfo>> = [
|
||||
{ source: 'douban', title: '不同 source' },
|
||||
{ media_source: 'douban', title: '不同 media_source' },
|
||||
{ type: '电影', title: '不同 type' },
|
||||
{ season: 2, title: '不同 season' },
|
||||
{ tmdb_id: 43, title: '不同 tmdb_id' },
|
||||
{ imdb_id: 'tt0000043', title: '不同 imdb_id' },
|
||||
{ tvdb_id: 'tvdb-43', title: '不同 tvdb_id' },
|
||||
{ douban_id: 'douban-43', title: '不同 douban_id' },
|
||||
{ bangumi_id: 'bangumi-43', title: '不同 bangumi_id' },
|
||||
{ anilist_id: 4300, title: '不同 anilist_id' },
|
||||
{ mediaid_prefix: 'fixture-v2', title: '不同 mediaid_prefix' },
|
||||
{ media_id: 'media-43', title: '不同 media_id' },
|
||||
]
|
||||
const response = [base, { ...base, title: '完全重复项' }, ...variants.map(variant => ({ ...base, ...variant }))]
|
||||
@@ -261,34 +247,59 @@ describe('MediaCardListView', () => {
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByRole('article', { name: '媒体卡片 复合标识基准' })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('article', { name: '媒体卡片 统一标识基准' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('article', { name: '媒体卡片 完全重复项' })).not.toBeInTheDocument()
|
||||
expect(screen.getAllByRole('article')).toHaveLength(variants.length + 1)
|
||||
const identityFields = [
|
||||
'source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
'imdb_id',
|
||||
'tvdb_id',
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'anilist_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
const expectedItems = [base, ...variants.map(variant => ({ ...base, ...variant }))]
|
||||
expect(gridKeys()).toEqual(
|
||||
expectedItems.map(item => JSON.stringify(identityFields.map(field => item[field] ?? null))),
|
||||
expectedItems.map(item =>
|
||||
JSON.stringify(['media', item.media_source, item.media_id, item.type, item.season ?? null]),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps a complete TMDB page distinct with unified source identities', async () => {
|
||||
setScrollHeight(() => 900)
|
||||
const response = Array.from({ length: 20 }, (_, index) =>
|
||||
createMediaInfo({
|
||||
media_id: String(1000 + index),
|
||||
media_source: 'themoviedb',
|
||||
title: `统一 TMDB 媒体 ${index + 1}`,
|
||||
tmdb_id: undefined,
|
||||
}),
|
||||
)
|
||||
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByRole('article', { name: '媒体卡片 统一 TMDB 媒体 1' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('article')).toHaveLength(20)
|
||||
expect(gridKeys()).toEqual(
|
||||
response.map(item => JSON.stringify(['media', 'themoviedb', item.media_id, item.type, item.season ?? null])),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps identity-less records distinct by visible metadata', async () => {
|
||||
setScrollHeight(() => 900)
|
||||
const response = [
|
||||
createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 A', tmdb_id: undefined }),
|
||||
createMediaInfo({ media_id: undefined, media_source: undefined, title: '无标识媒体 B', tmdb_id: undefined }),
|
||||
]
|
||||
server.use(http.get(LIST_URL, () => HttpResponse.json(response as unknown as JsonBodyType)))
|
||||
|
||||
await renderList()
|
||||
|
||||
expect(await screen.findByRole('article', { name: '媒体卡片 无标识媒体 A' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('article', { name: '媒体卡片 无标识媒体 B' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('article')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('continues after a first-seen page signature whose items were all seen globally', async () => {
|
||||
const requestedPages: string[] = []
|
||||
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
|
||||
const first = createMediaInfo({ title: '签名第一页 A', tmdb_id: 101 })
|
||||
const second = createMediaInfo({ title: '签名第一页 B', tmdb_id: 102 })
|
||||
const later = createMediaInfo({ title: '签名第三页新媒体', tmdb_id: 103 })
|
||||
const first = createMediaInfo({ media_id: '101', title: '签名第一页 A', tmdb_id: 101 })
|
||||
const second = createMediaInfo({ media_id: '102', title: '签名第一页 B', tmdb_id: 102 })
|
||||
const later = createMediaInfo({ media_id: '103', title: '签名第三页新媒体', tmdb_id: 103 })
|
||||
server.use(
|
||||
http.get(LIST_URL, ({ request }) => {
|
||||
const page = new URL(request.url).searchParams.get('page') ?? ''
|
||||
@@ -309,9 +320,9 @@ describe('MediaCardListView', () => {
|
||||
it('stops when a raw page repeats any historical identity set regardless of order and duplicate count', async () => {
|
||||
const requestedPages: string[] = []
|
||||
setScrollHeight(() => (requestedPages.length >= 3 ? 900 : 500))
|
||||
const first = createMediaInfo({ title: '历史签名 A', tmdb_id: 201 })
|
||||
const second = createMediaInfo({ title: '历史签名 B', tmdb_id: 202 })
|
||||
const between = createMediaInfo({ title: '中间签名 C', tmdb_id: 203 })
|
||||
const first = createMediaInfo({ media_id: '201', title: '历史签名 A', tmdb_id: 201 })
|
||||
const second = createMediaInfo({ media_id: '202', title: '历史签名 B', tmdb_id: 202 })
|
||||
const between = createMediaInfo({ media_id: '203', title: '中间签名 C', tmdb_id: 203 })
|
||||
server.use(
|
||||
http.get(LIST_URL, ({ request }) => {
|
||||
const page = new URL(request.url).searchParams.get('page') ?? ''
|
||||
|
||||
@@ -241,7 +241,7 @@ describe('MediaDetailView detail and actions', () => {
|
||||
[
|
||||
'extension',
|
||||
'custom:item-8204',
|
||||
createMediaInfo({ media_id: 'item-8204', mediaid_prefix: 'custom', tmdb_id: 8204, type: '电影' }),
|
||||
createMediaInfo({ media_id: 'item-8204', media_source: 'custom', tmdb_id: 8204, type: '电影' }),
|
||||
],
|
||||
])('loads the exact %s media path and query', async (_source, mediaId, media) => {
|
||||
const requested = vi.fn<(url: URL) => void>()
|
||||
@@ -611,7 +611,7 @@ describe('MediaDetailView subscriptions, seasons, and episode groups', () => {
|
||||
const media = createSubscribeTv({
|
||||
anilist_id: 154587,
|
||||
episode_group: 'auxiliary-group',
|
||||
source: 'anilist',
|
||||
media_source: 'anilist',
|
||||
title: 'AniList 主来源剧集',
|
||||
tmdb_id: 8700,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import MusicView from '@/views/discover/MusicView.vue'
|
||||
import { screen, waitFor } from '@testing-library/vue'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { renderWithProviders } from '@tests/support/render'
|
||||
import { defineComponent } from 'vue'
|
||||
@@ -11,9 +11,8 @@ const MediaCardListViewStub = defineComponent({
|
||||
})
|
||||
|
||||
/** 渲染音乐探索筛选,列表区域用桩组件回显请求参数。 */
|
||||
function renderMusicView(source: 'musicbrainz' | 'theaudiodb' = 'musicbrainz') {
|
||||
function renderMusicView() {
|
||||
return renderWithProviders(MusicView, {
|
||||
props: { source },
|
||||
global: { stubs: { MediaCardListView: MediaCardListViewStub } },
|
||||
})
|
||||
}
|
||||
@@ -23,6 +22,8 @@ describe('MusicView', () => {
|
||||
await renderMusicView()
|
||||
|
||||
const params = screen.getByTestId('music-params')
|
||||
expect(params).toHaveTextContent('"media_source":"musicbrainz"')
|
||||
expect(params).not.toHaveTextContent('"source"')
|
||||
expect(params).toHaveTextContent('"mode":"chart"')
|
||||
expect(params).toHaveTextContent('"entity":"recording"')
|
||||
expect(params).toHaveTextContent('"range_name":"this_month"')
|
||||
@@ -64,19 +65,4 @@ describe('MusicView', () => {
|
||||
expect(params).toHaveTextContent('"past":false')
|
||||
expect(params).toHaveTextContent('"future":true')
|
||||
})
|
||||
|
||||
it('uses the same card list with TheAudioDB entity and country filters', async () => {
|
||||
const user = userEvent.setup()
|
||||
await renderMusicView('theaudiodb')
|
||||
|
||||
const params = screen.getByTestId('music-params')
|
||||
expect(params).toHaveTextContent('"source":"theaudiodb"')
|
||||
expect(params).toHaveTextContent('"entity":"recording"')
|
||||
expect(params).toHaveTextContent('"country":"us"')
|
||||
expect(params).not.toHaveTextContent('"mode"')
|
||||
expect(screen.queryByText('模式')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('热门专辑'))
|
||||
await waitFor(() => expect(screen.getByTestId('music-params')).toHaveTextContent('"entity":"album"'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,7 +154,7 @@ function getParams() {
|
||||
|
||||
// MediaInfo 去重的字段
|
||||
const dedupFields = [
|
||||
'source',
|
||||
'media_source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
@@ -163,7 +163,6 @@ const dedupFields = [
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'anilist_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
|
||||
|
||||
@@ -433,8 +433,8 @@ describe('SubscribePopularView', () => {
|
||||
it('provides unique progressive-grid keys for different seasons of the same TMDB title', async () => {
|
||||
server.use(
|
||||
popularSubscribesHandler([
|
||||
createSubscribeTv({ season: 1, source: undefined, title: '同剧第一季', tmdb_id: 880 }),
|
||||
createSubscribeTv({ season: 2, source: undefined, title: '同剧第二季', tmdb_id: 880 }),
|
||||
createSubscribeTv({ season: 1, title: '同剧第一季', tmdb_id: 880 }),
|
||||
createSubscribeTv({ season: 2, title: '同剧第二季', tmdb_id: 880 }),
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -445,7 +445,7 @@ describe('SubscribePopularView', () => {
|
||||
|
||||
// 渲染键与生产媒体身份字段保持一致,避免不同季条目发生键冲突。
|
||||
const identityFields = [
|
||||
'source',
|
||||
'media_source',
|
||||
'type',
|
||||
'season',
|
||||
'tmdb_id',
|
||||
@@ -454,11 +454,10 @@ describe('SubscribePopularView', () => {
|
||||
'douban_id',
|
||||
'bangumi_id',
|
||||
'anilist_id',
|
||||
'mediaid_prefix',
|
||||
'media_id',
|
||||
] as const
|
||||
const item1 = createSubscribeTv({ season: 1, source: undefined, title: '同剧第一季', tmdb_id: 880 })
|
||||
const item2 = createSubscribeTv({ season: 2, source: undefined, title: '同剧第二季', tmdb_id: 880 })
|
||||
const item1 = createSubscribeTv({ season: 1, title: '同剧第一季', tmdb_id: 880 })
|
||||
const item2 = createSubscribeTv({ season: 2, title: '同剧第二季', tmdb_id: 880 })
|
||||
const expectedKeys = [
|
||||
JSON.stringify(identityFields.map(field => item1[field] ?? null)),
|
||||
JSON.stringify(identityFields.map(field => item2[field] ?? null)),
|
||||
|
||||
@@ -305,7 +305,7 @@ async function nameTest() {
|
||||
subtitle: nameTestForm.subtitle,
|
||||
// 音乐识别不应用识别词,隐藏状态下不随请求携带
|
||||
custom_words: showCustomWords.value ? nameTestForm.customWords?.trim() || undefined : undefined,
|
||||
source: nameTestForm.source,
|
||||
media_source: nameTestForm.source,
|
||||
},
|
||||
})
|
||||
nameTestText.value = t('nameTest.recognizeAgain')
|
||||
|
||||
@@ -38,7 +38,7 @@ interface RecognizedMedia {
|
||||
category?: string
|
||||
detail_link?: string
|
||||
media_id: string
|
||||
source: string
|
||||
media_source: string
|
||||
title: string
|
||||
type: string
|
||||
year: string
|
||||
@@ -82,22 +82,22 @@ describe('NameTestView media identity', () => {
|
||||
it.each([
|
||||
[
|
||||
'TheMovieDb',
|
||||
{ media_id: '271016', source: 'themoviedb', title: '测试剧集', type: '电视剧', year: '2026' },
|
||||
{ media_id: '271016', media_source: 'themoviedb', title: '测试剧集', type: '电视剧', year: '2026' },
|
||||
'https://www.themoviedb.org/tv/271016',
|
||||
],
|
||||
[
|
||||
'Douban',
|
||||
{ media_id: '1295644', source: 'douban', title: '测试电影', type: '电影', year: '1994' },
|
||||
{ media_id: '1295644', media_source: 'douban', title: '测试电影', type: '电影', year: '1994' },
|
||||
'https://movie.douban.com/subject/1295644',
|
||||
],
|
||||
[
|
||||
'Bangumi',
|
||||
{ media_id: '485', source: 'bangumi', title: '测试动画', type: '电视剧', year: '2026' },
|
||||
{ media_id: '485', media_source: 'bangumi', title: '测试动画', type: '电视剧', year: '2026' },
|
||||
'https://bgm.tv/subject/485',
|
||||
],
|
||||
[
|
||||
'AniList',
|
||||
{ media_id: '154587', source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
|
||||
{ media_id: '154587', media_source: 'anilist', title: '测试番剧', type: '电视剧', year: '2026' },
|
||||
'https://anilist.co/anime/154587',
|
||||
],
|
||||
[
|
||||
@@ -105,7 +105,7 @@ describe('NameTestView media identity', () => {
|
||||
{
|
||||
detail_link: 'https://musicbrainz.org/recording/8f97b17d-1234-4abc-9def-1234567890ab',
|
||||
media_id: '8f97b17d-1234-4abc-9def-1234567890ab',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '测试单曲',
|
||||
type: '音乐',
|
||||
year: '2026',
|
||||
@@ -116,7 +116,7 @@ describe('NameTestView media identity', () => {
|
||||
'TheAudioDB',
|
||||
{
|
||||
media_id: '32793500',
|
||||
source: 'theaudiodb',
|
||||
media_source: 'theaudiodb',
|
||||
title: 'Yellow',
|
||||
type: '音乐',
|
||||
year: '2000',
|
||||
@@ -127,7 +127,7 @@ describe('NameTestView media identity', () => {
|
||||
'豆瓣音乐',
|
||||
{
|
||||
media_id: '1401853',
|
||||
source: 'doubanmusic',
|
||||
media_source: 'doubanmusic',
|
||||
title: '范特西',
|
||||
type: '音乐',
|
||||
year: '2001',
|
||||
@@ -144,7 +144,7 @@ describe('NameTestView media identity', () => {
|
||||
expect(mediaIdLink.closest('.pipeline-step')).toHaveTextContent(`媒体 ID${media.media_id}`)
|
||||
expect(mediaIdLink.closest('.pipeline-step')).not.toHaveTextContent(sourceLabel)
|
||||
const sourceDisplay = screen.getByTestId('recognition-source')
|
||||
expect(sourceDisplay).toHaveAttribute('data-source', media.source)
|
||||
expect(sourceDisplay).toHaveAttribute('data-source', media.media_source)
|
||||
expect(sourceDisplay).toHaveAccessibleName(sourceLabel)
|
||||
expect(sourceDisplay.closest('.pipeline-step')).toHaveTextContent(`识别数据源${sourceLabel}`)
|
||||
expect(sourceDisplay.querySelector('.media-source-logo')).toBeInTheDocument()
|
||||
@@ -154,7 +154,7 @@ describe('NameTestView media identity', () => {
|
||||
await renderRecognizedMedia({
|
||||
category: '动漫',
|
||||
media_id: '485',
|
||||
source: 'bangumi',
|
||||
media_source: 'bangumi',
|
||||
title: '测试动画',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
@@ -171,7 +171,7 @@ describe('NameTestView media identity', () => {
|
||||
artist: '周杰伦',
|
||||
category: 'Single',
|
||||
media_id: '8f97b17d-1234-4abc-9def-1234567890ab',
|
||||
source: 'musicbrainz',
|
||||
media_source: 'musicbrainz',
|
||||
title: '晴天',
|
||||
type: '音乐',
|
||||
year: 2003,
|
||||
@@ -239,6 +239,27 @@ describe('NameTestView media identity', () => {
|
||||
expect(screen.getByLabelText('识别词')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends TheAudioDB through the unified media source parameter', async () => {
|
||||
mocks.apiGet.mockResolvedValueOnce({})
|
||||
await renderWithProviders(NameTestView, {
|
||||
initialState: {
|
||||
globalSettings: {
|
||||
data: { RECOGNIZE_SOURCE: 'themoviedb' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByLabelText('识别数据源'))
|
||||
await user.click(await screen.findByRole('option', { name: 'TheAudioDB' }))
|
||||
await user.type(screen.getByLabelText('标题'), 'Coldplay - Yellow')
|
||||
await user.click(screen.getByRole('button', { name: '识别' }))
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('media/recognize', {
|
||||
params: expect.objectContaining({ media_source: 'theaudiodb' }),
|
||||
})
|
||||
})
|
||||
|
||||
it('closes the recognition dialog before navigating to the media detail', async () => {
|
||||
const eventOrder: string[] = []
|
||||
const onClose = vi.fn(() => eventOrder.push('close'))
|
||||
@@ -247,7 +268,7 @@ describe('NameTestView media identity', () => {
|
||||
})
|
||||
const media = {
|
||||
media_id: '271016',
|
||||
source: 'themoviedb',
|
||||
media_source: 'themoviedb',
|
||||
title: '测试剧集',
|
||||
type: '电视剧',
|
||||
year: '2026',
|
||||
|
||||
Reference in New Issue
Block a user