From 6afa2bdc8a7a3953e6fa5fd09bfa1c51e650e672 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 7 Aug 2026 21:50:22 +0800 Subject: [PATCH] feat(v3): add music search and subscription UI --- src/api/constants.ts | 81 +++++--- src/api/types.ts | 30 ++- src/components/cards/DirectoryCard.vue | 1 + src/components/cards/DownloadingCard.vue | 2 + src/components/cards/MediaCard.vue | 17 +- src/components/cards/SubscribeCard.vue | 8 + .../cards/__tests__/MediaCard.spec.ts | 34 +++ src/components/dialog/AddDownloadDialog.vue | 1 + .../dialog/AddSubtitleDownloadDialog.vue | 1 + src/components/dialog/ReorganizeDialog.vue | 2 + src/components/dialog/ScrapeDialog.vue | 1 + src/components/dialog/SearchBarDialog.vue | 40 +++- src/components/dialog/SiteResourceDialog.vue | 42 +++- src/components/dialog/SubscribeEditDialog.vue | 8 +- .../workflow/FetchTorrentsAction.vue | 4 + .../workflow/FilterMediasAction.vue | 4 + src/composables/useMediaSubscribe.ts | 1 + src/locales/en-US.ts | 26 +++ src/locales/zh-CN.ts | 26 +++ src/locales/zh-TW.ts | 26 +++ src/pages/__tests__/discover.spec.ts | 53 +++-- src/pages/__tests__/music.spec.ts | 81 ++++++++ src/pages/__tests__/recommend.spec.ts | 3 +- src/pages/discover.vue | 6 + src/pages/music.vue | 193 ++++++++++++++++++ src/pages/recommend.vue | 7 +- src/pages/resource.vue | 2 + src/pages/subscribe.vue | 44 +++- src/router/i18n-menu.ts | 41 ++++ src/router/index.ts | 22 ++ src/utils/__tests__/recommendSources.spec.ts | 8 +- src/utils/permission.ts | 18 +- src/utils/recommendSources.ts | 6 + src/views/discover/MusicView.vue | 7 + src/views/setting/AccountSettingDirectory.vue | 31 +++ src/views/subscribe/SubscribeListView.vue | 15 +- 36 files changed, 812 insertions(+), 80 deletions(-) create mode 100644 src/pages/__tests__/music.spec.ts create mode 100644 src/pages/music.vue create mode 100644 src/views/discover/MusicView.vue diff --git a/src/api/constants.ts b/src/api/constants.ts index 86322c4e..1fc732a8 100644 --- a/src/api/constants.ts +++ b/src/api/constants.ts @@ -33,15 +33,21 @@ export const storageAttributes = [ }, ] -export const storageIconDict = storageAttributes.reduce((dict, item) => { - dict[item.type] = item.icon - return dict -}, {} as Record) +export const storageIconDict = storageAttributes.reduce( + (dict, item) => { + dict[item.type] = item.icon + return dict + }, + {} as Record, +) -export const storageRemoteDict = storageAttributes.reduce((dict, item) => { - dict[item.type] = item.remote - return dict -}, {} as Record) +export const storageRemoteDict = storageAttributes.reduce( + (dict, item) => { + dict[item.type] = item.remote + return dict + }, + {} as Record, +) export const downloaderOptions = [ { @@ -58,10 +64,13 @@ export const downloaderOptions = [ }, ] -export const downloaderDict = downloaderOptions.reduce((dict, item) => { - dict[item.value] = item.title - return dict -}, {} as Record) +export const downloaderDict = downloaderOptions.reduce( + (dict, item) => { + dict[item.value] = item.title + return dict + }, + {} as Record, +) export const mediaServerOptions = [ { @@ -90,10 +99,13 @@ export const mediaServerOptions = [ }, ] -export const mediaServerDict = mediaServerOptions.reduce((dict, item) => { - dict[item.value] = item.title - return dict -}, {} as Record) +export const mediaServerDict = mediaServerOptions.reduce( + (dict, item) => { + dict[item.value] = item.title + return dict + }, + {} as Record, +) export const innerFilterRules = [ { title: i18n.global.t('filterRules.specSub'), value: ' SPECSUB ' }, @@ -236,6 +248,10 @@ export const mediaTypeOptions = [ title: i18n.global.t('mediaType.tv'), value: '电视剧', }, + { + title: i18n.global.t('mediaType.music'), + value: '音乐', + }, { title: i18n.global.t('mediaType.anime'), value: '动漫', @@ -251,10 +267,13 @@ export const mediaTypeOptions = [ ] // 媒体类型字典 -export const mediaTypeDict = mediaTypeOptions.reduce((dict, item) => { - dict[item.value] = item.title - return dict -}, {} as Record) +export const mediaTypeDict = mediaTypeOptions.reduce( + (dict, item) => { + dict[item.value] = item.title + return dict + }, + {} as Record, +) // 通知开关选项 export const notificationSwitchOptions = [ @@ -297,10 +316,13 @@ export const notificationSwitchOptions = [ ] // 通知开关字典 -export const notificationSwitchDict = notificationSwitchOptions.reduce((dict, item) => { - dict[item.value] = item.title - return dict -}, {} as Record) +export const notificationSwitchDict = notificationSwitchOptions.reduce( + (dict, item) => { + dict[item.value] = item.title + return dict + }, + {} as Record, +) // 操作步骤选项 export const actionStepOptions = [ @@ -367,7 +389,10 @@ export const actionStepOptions = [ ] // 操作步骤字典 -export const actionStepDict = actionStepOptions.reduce((dict, item) => { - dict[item.value] = item.title - return dict -}, {} as Record) +export const actionStepDict = actionStepOptions.reduce( + (dict, item) => { + dict[item.value] = item.title + return dict + }, + {} as Record, +) diff --git a/src/api/types.ts b/src/api/types.ts index 684f7979..b7d2f53f 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -1,4 +1,4 @@ -export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | (string & {}) +export type MediaDataSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' | (string & {}) // 手动刮削选项 export interface ManualScrapeOptions { @@ -330,9 +330,9 @@ export interface DownloadHistory { // 媒体信息 export interface MediaInfo { - // 来源:themoviedb、douban、bangumi、anilist + // 来源:themoviedb、douban、bangumi、anilist、musicbrainz source?: string - // 类型 电影、电视剧、合集 + // 类型 电影、电视剧、音乐、合集 type?: string // 媒体标题 title?: string @@ -436,6 +436,30 @@ export interface MediaInfo { names?: string[] // 剧集组 episode_group?: string + // 音乐艺术家列表 + artists?: string[] + // 音乐艺术家展示文本 + artist?: string + // 专辑 + album?: string + // 专辑艺术家 + album_artist?: string + // 发行版本 + version?: string + // 音轨号 + track_number?: number + // 碟号 + disc_number?: number + // 总音轨数 + total_tracks?: number + // 音轨时长(秒) + duration?: number + // ISRC + isrc?: string + // 音乐封面 + cover_url?: string + // ListenBrainz 收听次数 + listen_count?: number } // 季信息 diff --git a/src/components/cards/DirectoryCard.vue b/src/components/cards/DirectoryCard.vue index 76365b72..6ba3f392 100644 --- a/src/components/cards/DirectoryCard.vue +++ b/src/components/cards/DirectoryCard.vue @@ -47,6 +47,7 @@ const typeItems = computed(() => [ { title: t('common.all'), value: '' }, { title: t('mediaType.movie'), value: '电影' }, { title: t('mediaType.tv'), value: '电视剧' }, + { title: t('mediaType.music'), value: '音乐' }, ]) // 计算资源存储字典(整理方式为下载器时不能为远程存储) diff --git a/src/components/cards/DownloadingCard.vue b/src/components/cards/DownloadingCard.vue index 96f24607..995a941e 100644 --- a/src/components/cards/DownloadingCard.vue +++ b/src/components/cards/DownloadingCard.vue @@ -46,6 +46,7 @@ const mediaTypeText = computed(() => { const type = String(media.value.type || '').trim() if (type === '电影' || type.toLowerCase() === 'movie') return t('mediaType.movie') if (type === '电视剧' || type.toLowerCase() === 'tv') return t('mediaType.tv') + if (type === '音乐' || type.toLowerCase() === 'music') return t('mediaType.music') if (type) return type if (media.value.season || media.value.episode || props.info?.season_episode) return t('mediaType.tv') return media.value.title ? t('mediaType.movie') : '' @@ -57,6 +58,7 @@ const mediaTypeIcon = computed(() => { .toLowerCase() if (type === '电影' || type === 'movie') return 'mdi-movie-outline' if (type === '电视剧' || type === 'tv') return 'mdi-television-classic' + if (type === '音乐' || type === 'music') return 'mdi-music-note' return 'mdi-play-box-outline' }) diff --git a/src/components/cards/MediaCard.vue b/src/components/cards/MediaCard.vue index 3638558c..ce574275 100644 --- a/src/components/cards/MediaCard.vue +++ b/src/components/cards/MediaCard.vue @@ -183,6 +183,7 @@ function isSameSubscribeMedia(subscribe: Subscribe) { function getChipColor(type: string) { if (type === '电影') return 'border-blue-500 bg-blue-600' else if (type === '电视剧') return ' bg-indigo-500 border-indigo-600' + else if (type === '音乐') return 'border-pink-500 bg-pink-600' else return 'border-purple-600 bg-purple-600' } @@ -230,6 +231,7 @@ async function querySubscribedSeasons() { // 查询当前媒体是否已入库 async function handleCheckExists() { + if (props.media?.type === '音乐') return try { const exists = await getCachedMediaExistsStatus(getExistsStatusKey(), async () => { const result: { [key: string]: any } = await api.get('mediaserver/exists', { @@ -268,7 +270,14 @@ function goMediaDetail(isHovering = false) { if (isHovering) { resetMediaCardDetailState() - if (props.media?.collection_id) { + if (props.media?.type === '音乐') { + router.push({ + path: '/music', + query: { + query: [props.media?.artist, props.media?.title].filter(Boolean).join(' - '), + }, + }) + } else if (props.media?.collection_id) { // 跳转到合集列表 router.push({ path: `/browse/tmdb/collection/${props.media?.collection_id}`, @@ -542,6 +551,12 @@ onBeforeUnmount(() => { v-if="!isMediaCardActive(hover.isHovering) && isImageLoaded && props.media?.source && !imageLoadError" > + diff --git a/src/components/cards/SubscribeCard.vue b/src/components/cards/SubscribeCard.vue index 7f179dbb..2a454fee 100644 --- a/src/components/cards/SubscribeCard.vue +++ b/src/components/cards/SubscribeCard.vue @@ -283,6 +283,13 @@ function getMediaId() { // 查看媒体详情 async function viewMediaDetail() { + if (props.media?.type === '音乐') { + router.push({ + path: '/music', + query: { query: props.media?.name }, + }) + return + } router.push({ path: '/media', query: { @@ -360,6 +367,7 @@ const dropdownItems = computed(() => [ prependIcon: 'mdi-file-document-outline', click: viewSubscribeFiles, }, + show: props.media?.type !== '音乐', }, { title: t('common.unsubscribe'), diff --git a/src/components/cards/__tests__/MediaCard.spec.ts b/src/components/cards/__tests__/MediaCard.spec.ts index 9ccc64eb..cc3e282f 100644 --- a/src/components/cards/__tests__/MediaCard.spec.ts +++ b/src/components/cards/__tests__/MediaCard.spec.ts @@ -304,6 +304,40 @@ describe('MediaCard', () => { await waitFor(() => expect(mocks.routerPush).toHaveBeenCalledWith({ path, query })) }) + it('opens music search and skips media-library existence checks', async () => { + const media = createMediaInfo({ + artist: '周杰伦', + media_id: 'recording-1', + mediaid_prefix: 'musicbrainz', + source: 'musicbrainz', + title: '晴天', + tmdb_id: undefined, + type: '音乐', + }) + const subscribeRequest = vi.fn<(url: URL) => void>() + const existsRequest = vi.fn<(url: URL) => void>() + server.use( + querySubscribeByMediaHandler('musicbrainz:recording-1', {}, 200, subscribeRequest), + mediaExistsHandler({ data: { item: {} }, success: false }, 200, existsRequest), + ) + + const { container } = await renderCard(media) + getStatusObservers()[0]?.trigger() + await waitFor(() => expect(subscribeRequest).toHaveBeenCalledOnce()) + expect(existsRequest).not.toHaveBeenCalled() + + await fireEvent.mouseEnter(getHoverArea(container)) + await waitFor(() => expect(getCard(container)).toHaveClass('app-hover-lift-card--hovering')) + await fireEvent.click(getCard(container)) + + await waitFor(() => + expect(mocks.routerPush).toHaveBeenCalledWith({ + path: '/music', + query: { query: '周杰伦 - 晴天' }, + }), + ) + }) + it('routes directly to resource search when no active sites are available', async () => { installSearchHandlers([], [3, 5]) const media = createMediaInfo({ season: 4, title: '直接搜索剧集', tmdb_id: 9501, type: '电视剧' }) diff --git a/src/components/dialog/AddDownloadDialog.vue b/src/components/dialog/AddDownloadDialog.vue index 8c56250c..7467384c 100644 --- a/src/components/dialog/AddDownloadDialog.vue +++ b/src/components/dialog/AddDownloadDialog.vue @@ -72,6 +72,7 @@ const mediaIdLabel = computed(() => { douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + musicbrainz: 'MusicBrainz ID', } return labels[mediaSource.value] }) diff --git a/src/components/dialog/AddSubtitleDownloadDialog.vue b/src/components/dialog/AddSubtitleDownloadDialog.vue index bf03aa9c..f14c2ca0 100644 --- a/src/components/dialog/AddSubtitleDownloadDialog.vue +++ b/src/components/dialog/AddSubtitleDownloadDialog.vue @@ -57,6 +57,7 @@ const mediaIdLabel = computed(() => { douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + musicbrainz: 'MusicBrainz ID', } return labels[mediaSource.value] }) diff --git a/src/components/dialog/ReorganizeDialog.vue b/src/components/dialog/ReorganizeDialog.vue index ebf23247..e37d10d2 100644 --- a/src/components/dialog/ReorganizeDialog.vue +++ b/src/components/dialog/ReorganizeDialog.vue @@ -347,6 +347,7 @@ const mediaIdLabel = computed(() => { douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + musicbrainz: 'MusicBrainz ID', } return labels[mediaSource.value] }) @@ -1495,6 +1496,7 @@ onUnmounted(() => { { title: t('dialog.reorganize.auto'), value: '' }, { title: t('dialog.reorganize.movie'), value: '电影' }, { title: t('dialog.reorganize.tv'), value: '电视剧' }, + { title: t('mediaType.music'), value: '音乐' }, ]" :hint="t('dialog.reorganize.mediaTypeHint')" persistent-hint diff --git a/src/components/dialog/ScrapeDialog.vue b/src/components/dialog/ScrapeDialog.vue index df117716..da111391 100644 --- a/src/components/dialog/ScrapeDialog.vue +++ b/src/components/dialog/ScrapeDialog.vue @@ -55,6 +55,7 @@ const mediaIdLabel = computed(() => { douban: t('dialog.reorganize.doubanId'), bangumi: t('dialog.reorganize.bangumiId'), anilist: t('dialog.reorganize.anilistId'), + musicbrainz: 'MusicBrainz ID', } return labels[mediaSource.value] }) diff --git a/src/components/dialog/SearchBarDialog.vue b/src/components/dialog/SearchBarDialog.vue index 5c73cd9c..063ddd8f 100644 --- a/src/components/dialog/SearchBarDialog.vue +++ b/src/components/dialog/SearchBarDialog.vue @@ -96,8 +96,8 @@ const searchOverlayProps = computed(() => // 搜索词 const searchWord = ref(null) -type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' -type MediaSearchType = 'media' | 'collection' | 'person' +type MediaSearchSource = 'themoviedb' | 'douban' | 'bangumi' | 'anilist' | 'musicbrainz' +type MediaSearchType = 'media' | 'music' | 'collection' | 'person' interface MediaSearchSourceOption { label: string @@ -115,6 +115,7 @@ interface MediaSearchAction { // 三类搜索各自维护来源选择,首次使用均默认 TheMovieDB。 const selectedMediaSearchSources = reactive>({ media: 'themoviedb', + music: 'musicbrainz', collection: 'themoviedb', person: 'themoviedb', }) @@ -141,9 +142,15 @@ const mediaSearchSourceOptions = computed { title: `${t('recommend.categoryMovie')}、${t('recommend.categoryTV')}`, description: t('resource.title'), }, + { + type: 'music', + icon: 'mdi-music-note-search', + title: t('mediaType.music'), + description: t('music.subtitle'), + }, { type: 'collection', icon: 'mdi-movie-filter', @@ -389,6 +402,14 @@ function searchSubtitle() { function searchMedia(searchType: MediaSearchType) { if (!searchWord.value || !hasDiscoveryPermission.value) return saveRecentSearches(searchWord.value) + if (searchType === 'music') { + router.push({ + path: '/music', + query: { query: searchWord.value }, + }) + closeSearch() + return + } router.push({ path: '/browse/media/search', query: { @@ -453,6 +474,13 @@ function goSubscribe(subscribe: Subscribe) { id: subscribe.id, }, }) + } else if (subscribe.type === '音乐') { + router.push({ + path: '/subscribe/music', + query: { + id: subscribe.id, + }, + }) } else { router.push({ path: '/subscribe/tv', @@ -669,7 +697,13 @@ onMounted(() => {