From 009ce6173beebb8cb7016f5f14355f7c60c2b637 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 9 Aug 2026 10:18:42 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=9F=B3=E4=B9=90=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E4=B8=8E=E8=AF=86=E5=88=AB=E5=89=8D=E7=AB=AF=E8=81=94=E5=8A=A8?= =?UTF-8?q?=E7=BB=9F=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 音乐搜索从 music/search 切换为 media/search?type=music - MediaInfoCard 支持音乐卡片(方形封面、时长、音乐实体类型、详情外链) - 订阅与资源选择器适配音乐元数据 - 同步中英繁语言包 --- src/api/types.ts | 4 +- src/components/cards/MediaInfoCard.vue | 190 ++++++++++++++++--- src/components/dialog/SiteResourceDialog.vue | 45 +---- src/components/misc/MediaIdSelector.vue | 22 +-- src/composables/useMediaSubscribe.ts | 3 +- src/locales/en-US.ts | 4 + src/locales/zh-CN.ts | 4 + src/locales/zh-TW.ts | 4 + src/pages/__tests__/music.spec.ts | 6 +- src/pages/music.vue | 2 +- src/views/setting/AccountSettingSystem.vue | 9 +- 11 files changed, 204 insertions(+), 89 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index 05640630..f11ac496 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -336,8 +336,8 @@ export interface MediaInfo { type?: string // 媒体标题 title?: string - // 年份 - year?: string + // 年份(音乐等数据源返回数字,订阅接口要求字符串) + year?: string | number // 标题(年) title_year?: string // 季号 diff --git a/src/components/cards/MediaInfoCard.vue b/src/components/cards/MediaInfoCard.vue index 5427fca5..71431542 100644 --- a/src/components/cards/MediaInfoCard.vue +++ b/src/components/cards/MediaInfoCard.vue @@ -11,6 +11,11 @@ const props = defineProps({ // 音乐元数据使用 title,影视元数据使用 name,识别结果卡片统一兼容两种字段。 const recognizedName = computed(() => props.context?.meta_info?.name || props.context?.meta_info?.title) +// 是否为音乐识别结果 +const isMusic = computed( + () => props.context?.media_info?.type === '音乐' || props.context?.meta_info?.type === '音乐', +) + // TMDB图片转换为w500大小 function getW500Image(url = '') { if (!url) return '' @@ -24,6 +29,27 @@ function openTmdbPage(type: string, tmdbId: number) { const url = `https://www.themoviedb.org/${type === '电影' ? 'movie' : 'tv'}/${tmdbId}` window.open(url, '_blank') } + +// 秒数格式化为 m:ss 时长文本 +function formatDuration(seconds: number | undefined) { + if (!seconds || seconds <= 0) return '' + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return `${minutes}:${rest.toString().padStart(2, '0')}` +} + +// 音乐封面优先使用方形封面,仅 W500 图片处理对TMDB类 URL 生效 +const musicCover = computed(() => getW500Image( + props.context?.media_info?.cover_url || props.context?.media_info?.poster_path || '', +)) + +// 音乐详情外链 +const musicLink = computed(() => props.context?.media_info?.detail_link || '') + +// 打开音乐详情外链 +function openMusicDetail() { + if (musicLink.value) window.open(musicLink.value, '_blank') +} + \ No newline at end of file diff --git a/src/components/dialog/SiteResourceDialog.vue b/src/components/dialog/SiteResourceDialog.vue index ba178c63..3cc524a0 100644 --- a/src/components/dialog/SiteResourceDialog.vue +++ b/src/components/dialog/SiteResourceDialog.vue @@ -27,9 +27,6 @@ const keyword = ref() // 选择分类 const selectCategory = ref([]) -// 选择主媒体类型,用于按站点定义的电影、电视剧、音乐分类浏览。 -const selectMediaType = ref() - // 全部分类 const siteCategoryList = ref() @@ -69,11 +66,8 @@ const categoryOptions = computed(() => { }) }) -const mediaTypeOptions = computed(() => [ - { title: t('mediaType.movie'), value: '电影' }, - { title: t('mediaType.tv'), value: '电视剧' }, - { title: t('mediaType.music'), value: '音乐' }, -]) +// 站点是否配置了资源分类 +const hasSiteCategory = computed(() => (siteCategoryList.value?.length ?? 0) > 0) // 总条数 const resourceTotalItems = computed(() => resourceDataList.value.length) @@ -169,7 +163,6 @@ async function getResourceList() { params: { keyword: keyword.value, cat: selectCategory.value?.join(','), - mtype: selectMediaType.value, }, }) @@ -288,20 +281,9 @@ onMounted(() => { clearable prepend-inner-icon="mdi-folder" hide-details - /> - - - @@ -369,20 +351,6 @@ onMounted(() => { @keyup.enter="getResourceList" /> - - - { clearable prepend-inner-icon="mdi-folder" hide-details + :disabled="!hasSiteCategory" + :hint="hasSiteCategory ? '' : t('dialog.siteResource.noCategory')" + persistent-hint /> diff --git a/src/components/misc/MediaIdSelector.vue b/src/components/misc/MediaIdSelector.vue index 2cd7f9fb..652a4e8f 100644 --- a/src/components/misc/MediaIdSelector.vue +++ b/src/components/misc/MediaIdSelector.vue @@ -58,19 +58,15 @@ async function searchMedias() { // 调用API搜索词条 try { loading.value = true - const result: MediaInfo[] = - props.type === 'musicbrainz' - ? await api.get('music/search', { - params: { query: searchKeyword, count: 20 }, - }) - : await api.get('media/search', { - params: { - title: searchKeyword, - page: 1, - count: 20, - source: props.type, - }, - }) + const result: MediaInfo[] = await api.get('media/search', { + params: { + title: searchKeyword, + type: props.type === 'musicbrainz' ? 'music' : 'media', + page: 1, + count: 20, + source: props.type, + }, + }) // 清空 items.value = [] diff --git a/src/composables/useMediaSubscribe.ts b/src/composables/useMediaSubscribe.ts index 719a26e2..4ed509ac 100644 --- a/src/composables/useMediaSubscribe.ts +++ b/src/composables/useMediaSubscribe.ts @@ -317,7 +317,8 @@ export function useMediaSubscribe(options: UseMediaSubscribeOptions) { const result: { [key: string]: any } = await api.post('subscribe/', { name: media.title, type: media.type, - year: media.year, + // 后端的订阅模型 year 为字符串,音乐的 year 是数字,需统一转字符串避免 422 + year: media.year?.toString() ?? '', tmdbid: media.tmdb_id, doubanid: media.douban_id, bangumiid: media.bangumi_id, diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 1a81cc70..75622bce 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -2273,6 +2273,9 @@ export default { seasonThumb: 'Thumb', episodeNfo: 'NFO', episodeThumb: 'Thumb', + music: 'Music', + musicNfo: 'Audio Tags', + musicPoster: 'Cover Art', scrapingSwitchSaveFailed: 'Scraping switch settings save failed: {message}', scrapingSwitchSaveError: 'Scraping switch settings save failed', policy: { @@ -3482,6 +3485,7 @@ export default { browseTitle: 'Browse - {name}', searchKeyword: 'Search Keyword', resourceCategory: 'Resource Category', + noCategory: 'This site has no resource categories configured', search: 'Search', itemsPerPage: 'Items Per Page', noData: 'No Data', diff --git a/src/locales/zh-CN.ts b/src/locales/zh-CN.ts index 930b7154..0c4a45ee 100644 --- a/src/locales/zh-CN.ts +++ b/src/locales/zh-CN.ts @@ -2236,6 +2236,9 @@ export default { seasonThumb: '缩略图', episodeNfo: 'NFO', episodeThumb: '缩略图', + music: '音乐', + musicNfo: '音乐标签', + musicPoster: '封面', scrapingSwitchSaveFailed: '刮削开关设置保存失败:{message}', scrapingSwitchSaveError: '刮削开关设置保存失败', policy: { @@ -3424,6 +3427,7 @@ export default { browseTitle: '浏览 - {name}', searchKeyword: '搜索关键字', resourceCategory: '资源分类', + noCategory: '该站点未配置资源分类', search: '搜索', itemsPerPage: '每页条数', noData: '没有数据', diff --git a/src/locales/zh-TW.ts b/src/locales/zh-TW.ts index 357d1bb0..2e3f3766 100644 --- a/src/locales/zh-TW.ts +++ b/src/locales/zh-TW.ts @@ -2235,6 +2235,9 @@ export default { seasonThumb: '縮略圖', episodeNfo: 'NFO', episodeThumb: '縮略圖', + music: '音樂', + musicNfo: '音樂標籤', + musicPoster: '封面', scrapingSwitchSaveFailed: '刮削開關設定保存失敗:{message}', scrapingSwitchSaveError: '刮削開關設定保存失敗', policy: { @@ -3423,6 +3426,7 @@ export default { browseTitle: '瀏覽 - {name}', searchKeyword: '搜索關鍵字', resourceCategory: '資源分類', + noCategory: '該站點未配置資源分類', search: '搜索', itemsPerPage: '每頁條數', pageText: '{0}-{1} 共 {2} 條', diff --git a/src/pages/__tests__/music.spec.ts b/src/pages/__tests__/music.spec.ts index 132598d7..534042e2 100644 --- a/src/pages/__tests__/music.spec.ts +++ b/src/pages/__tests__/music.spec.ts @@ -47,7 +47,7 @@ const musicResult = { /** 按请求路径分派音乐搜索与订阅状态查询。 */ function mockSearchAndSubscribeState(subscribed: boolean) { mocks.apiGet.mockImplementation((path: string) => { - if (path === 'music/search') return Promise.resolve([musicResult]) + if (path === 'media/search') return Promise.resolve([musicResult]) if (path.startsWith('subscribe/media/')) { return subscribed ? Promise.resolve({ id: 9 }) : Promise.reject({ response: { status: 404 } }) } @@ -77,8 +77,8 @@ describe('music page', () => { await renderMusicPage() await waitFor(() => - expect(mocks.apiGet).toHaveBeenCalledWith('music/search', { - params: { count: 30, query: '晴天' }, + expect(mocks.apiGet).toHaveBeenCalledWith('media/search', { + params: { type: 'music', count: 30, title: '晴天' }, }), ) expect(screen.queryByRole('button', { name: '搜索音乐' })).not.toBeInTheDocument() diff --git a/src/pages/music.vue b/src/pages/music.vue index 6bb29235..1a994e7a 100644 --- a/src/pages/music.vue +++ b/src/pages/music.vue @@ -26,7 +26,7 @@ async function searchMusic() { loading.value = true searched.value = true try { - results.value = (await api.get('music/search', { params: { query: query.value, count: 30 } })) || [] + results.value = (await api.get('media/search', { params: { title: query.value, type: 'music', count: 30 } })) || [] } catch (error) { console.error(error) results.value = [] diff --git a/src/views/setting/AccountSettingSystem.vue b/src/views/setting/AccountSettingSystem.vue index 0b3c77b8..6323cee1 100644 --- a/src/views/setting/AccountSettingSystem.vue +++ b/src/views/setting/AccountSettingSystem.vue @@ -183,6 +183,13 @@ const scrapingConfig = [ { key: 'episode_thumb', label: 'setting.system.episodeThumb' }, ], }, + { + section: 'music', + items: [ + { key: 'music_nfo', label: 'setting.system.musicNfo' }, + { key: 'music_poster', label: 'setting.system.musicPoster' }, + ], + }, ] // 刮削策略设置 @@ -2203,7 +2210,7 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => { {{ t(item.label) }} - +