feat(music): refine source browsing and subscriptions

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